mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
2 Commits
1271d450b1
...
jif/mini-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
507e43b69c | ||
|
|
8869f662bf |
24
app-server-ui/.gitignore
vendored
Normal file
24
app-server-ui/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
30
app-server-ui/README.md
Normal file
30
app-server-ui/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Codex App Server UI
|
||||
|
||||
Minimal React + Vite client for the codex app-server v2 JSON-RPC protocol.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `codex` CLI available in your PATH (or set `CODEX_BIN`).
|
||||
- If you are working from this repo, the bridge will prefer the local
|
||||
`codex-rs/target/debug/codex-app-server` binary when it exists.
|
||||
- A configured Codex environment (API key or login) as required by the app-server.
|
||||
|
||||
## Quickstart
|
||||
|
||||
From the repo root:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --filter app-server-ui dev
|
||||
```
|
||||
|
||||
This starts:
|
||||
- a WebSocket bridge at `ws://localhost:8787` that spawns `codex app-server`
|
||||
- the Vite dev server at `http://localhost:5173`
|
||||
|
||||
## Configuration
|
||||
|
||||
- `CODEX_BIN`: path to the `codex` executable (default: `codex`).
|
||||
- `APP_SERVER_BIN` / `CODEX_APP_SERVER_BIN`: path to a `codex-app-server` binary (overrides `CODEX_BIN`).
|
||||
- `APP_SERVER_UI_PORT`: port for the bridge server (default: `8787`).
|
||||
- `VITE_APP_SERVER_WS`: WebSocket URL for the UI (default: `ws://localhost:8787`).
|
||||
12
app-server-ui/index.html
Normal file
12
app-server-ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codex App Server UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
app-server-ui/package.json
Normal file
26
app-server-ui/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "app-server-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"pnpm:dev:server\" \"pnpm:dev:client\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "node server/index.mjs",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"concurrently": "^8.2.2",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
1
app-server-ui/public/vite.svg
Normal file
1
app-server-ui/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
181
app-server-ui/server/index.mjs
Normal file
181
app-server-ui/server/index.mjs
Normal file
@@ -0,0 +1,181 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const port = Number(process.env.APP_SERVER_UI_PORT ?? 8787);
|
||||
const codexBin = process.env.CODEX_BIN ?? "codex";
|
||||
const explicitAppServerBin =
|
||||
process.env.APP_SERVER_BIN ?? process.env.CODEX_APP_SERVER_BIN ?? null;
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..");
|
||||
const localAppServerBin = resolve(repoRoot, "codex-rs/target/debug/codex-app-server");
|
||||
const appServerBin =
|
||||
explicitAppServerBin ?? (existsSync(localAppServerBin) ? localAppServerBin : null);
|
||||
|
||||
const sockets = new Set();
|
||||
let appServer = null;
|
||||
|
||||
const broadcast = (payload) => {
|
||||
const message = JSON.stringify(payload);
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startAppServer = () => {
|
||||
if (appServer?.child?.exitCode === null) {
|
||||
return appServer;
|
||||
}
|
||||
|
||||
const command = appServerBin ?? codexBin;
|
||||
const args = appServerBin ? [] : ["app-server"];
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const stdout = child.stdout;
|
||||
const stderr = child.stderr;
|
||||
const stdoutRl = stdout ? createInterface({ input: stdout }) : null;
|
||||
const stderrRl = stderr ? createInterface({ input: stderr }) : null;
|
||||
|
||||
stdoutRl?.on("line", (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = trimmed;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
payload = JSON.stringify(parsed);
|
||||
} catch {
|
||||
payload = JSON.stringify({
|
||||
method: "ui/raw",
|
||||
params: { line: trimmed },
|
||||
});
|
||||
}
|
||||
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
ws.send(payload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stderrRl?.on("line", (line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
console.error(trimmed);
|
||||
broadcast({ method: "ui/stderr", params: { line: trimmed } });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error("codex app-server spawn error:", err);
|
||||
broadcast({ method: "ui/error", params: { message: "Failed to spawn app-server.", details: String(err) } });
|
||||
appServer = null;
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(`codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"})`);
|
||||
broadcast({ method: "ui/exit", params: { code, signal } });
|
||||
appServer = null;
|
||||
});
|
||||
|
||||
appServer = { child, stdoutRl, stderrRl };
|
||||
return appServer;
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/health") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "content-type": "text/plain" });
|
||||
res.end("Not found");
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
sockets.add(ws);
|
||||
const running = Boolean(appServer?.child?.exitCode === null);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: "ui/connected",
|
||||
params: { pid: appServer?.child?.pid ?? null, running },
|
||||
}),
|
||||
);
|
||||
|
||||
ws.on("close", () => {
|
||||
sockets.delete(ws);
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
const text = typeof data === "string" ? data : data.toString("utf8");
|
||||
if (!text.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (err) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: "ui/error",
|
||||
params: {
|
||||
message: "Failed to parse JSON from client.",
|
||||
details: String(err),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!appServer || appServer.child.exitCode !== null || !appServer.child.stdin?.writable) {
|
||||
startAppServer();
|
||||
}
|
||||
|
||||
if (!appServer || !appServer.child.stdin?.writable) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
method: "ui/error",
|
||||
params: {
|
||||
message: "app-server stdin is closed.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
appServer.child.stdin.write(`${JSON.stringify(parsed)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, () => {
|
||||
console.log(`App server bridge listening on ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
startAppServer();
|
||||
|
||||
const shutdown = () => {
|
||||
appServer?.stdoutRl?.close();
|
||||
appServer?.stderrRl?.close();
|
||||
wss.close();
|
||||
server.close();
|
||||
appServer?.child?.kill("SIGTERM");
|
||||
};
|
||||
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
347
app-server-ui/src/App.css
Normal file
347
app-server-ui/src/App.css
Normal file
@@ -0,0 +1,347 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
font-family: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
|
||||
color: #1d2327;
|
||||
background-color: #f5f4f2;
|
||||
--panel-bg: #ffffff;
|
||||
--panel-border: #d9d4cd;
|
||||
--accent: #ff6a3d;
|
||||
--accent-2: #227c88;
|
||||
--text-muted: #4f5b62;
|
||||
--shadow: 0 10px 22px rgba(28, 36, 40, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #f5f4f2;
|
||||
background-image:
|
||||
linear-gradient(180deg, #f8f7f5 0%, #f0ede9 100%),
|
||||
repeating-linear-gradient(90deg, rgba(29, 35, 39, 0.05) 0 1px, transparent 1px 120px),
|
||||
repeating-linear-gradient(0deg, rgba(29, 35, 39, 0.04) 0 1px, transparent 1px 120px);
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
padding: 28px 36px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cfc8c0;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: #c2c2c2;
|
||||
}
|
||||
|
||||
.status-dot.on {
|
||||
background: #3cb371;
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: #c46a5e;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 14px;
|
||||
font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--panel-border);
|
||||
background: #ffffff;
|
||||
color: #1f2a2e;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover:enabled {
|
||||
box-shadow: 0 6px 14px rgba(31, 42, 46, 0.12);
|
||||
border-color: #c3bbb2;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(120deg, var(--accent), #ff946b);
|
||||
border-color: transparent;
|
||||
color: #1b1a19;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 106, 61, 0.12);
|
||||
color: #9a3f1e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--panel-border);
|
||||
padding: 10px 12px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.thread-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thread-item {
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e4ded7;
|
||||
background: #ffffff;
|
||||
font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread-item:hover {
|
||||
border-color: rgba(34, 124, 136, 0.6);
|
||||
}
|
||||
|
||||
.thread-item.active {
|
||||
border-color: rgba(255, 106, 61, 0.7);
|
||||
background: rgba(255, 106, 61, 0.08);
|
||||
}
|
||||
|
||||
.approvals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-card {
|
||||
background: #ffffff;
|
||||
border: 1px dashed rgba(31, 42, 46, 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.approval-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.approval-time {
|
||||
font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
}
|
||||
|
||||
.approval-card pre {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #f6f4f1;
|
||||
font-size: 12px;
|
||||
font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid transparent;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.bubble.user {
|
||||
align-self: flex-start;
|
||||
border-color: rgba(34, 124, 136, 0.35);
|
||||
background: rgba(34, 124, 136, 0.06);
|
||||
}
|
||||
|
||||
.bubble.assistant {
|
||||
align-self: flex-end;
|
||||
border-color: rgba(255, 106, 61, 0.35);
|
||||
background: rgba(255, 106, 61, 0.07);
|
||||
}
|
||||
|
||||
.bubble-role {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bubble-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log-scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
font-family: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(31, 42, 46, 0.12);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.log-entry.out {
|
||||
border-color: rgba(34, 124, 136, 0.28);
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-detail {
|
||||
color: #1f2a2e;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
705
app-server-ui/src/App.tsx
Normal file
705
app-server-ui/src/App.tsx
Normal file
@@ -0,0 +1,705 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type RpcError = {
|
||||
message?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type RpcMessage = {
|
||||
id?: number | string;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
result?: Record<string, unknown>;
|
||||
error?: RpcError;
|
||||
};
|
||||
|
||||
type LogEntry = {
|
||||
id: number;
|
||||
direction: "in" | "out";
|
||||
label: string;
|
||||
detail?: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
};
|
||||
|
||||
type ApprovalRequest = {
|
||||
id: number | string;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
receivedAt: string;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
method: string;
|
||||
};
|
||||
|
||||
const wsUrl = import.meta.env.VITE_APP_SERVER_WS ?? "ws://localhost:8787";
|
||||
|
||||
const formatTime = () =>
|
||||
new Date().toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
|
||||
const summarizeParams = (params: Record<string, unknown> | undefined) => {
|
||||
if (!params) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(params);
|
||||
} catch {
|
||||
return "[unserializable params]";
|
||||
}
|
||||
};
|
||||
|
||||
const shouldLogMethod = (method: string) => {
|
||||
if (method.includes("/delta")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const nextIdRef = useRef(1);
|
||||
const pendingRef = useRef<Map<number | string, PendingRequest>>(new Map());
|
||||
const agentIndexRef = useRef<Map<string, Map<string, number>>>(new Map());
|
||||
const selectedThreadIdRef = useRef<string | null>(null);
|
||||
const userItemIdsRef = useRef<Map<string, Set<string>>>(new Map());
|
||||
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [threads, setThreads] = useState<string[]>([]);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [activeTurnId, setActiveTurnId] = useState<string | null>(null);
|
||||
const [activeTurnThreadId, setActiveTurnThreadId] = useState<string | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [threadMessages, setThreadMessages] = useState<Record<string, ChatMessage[]>>({});
|
||||
const [approvals, setApprovals] = useState<ApprovalRequest[]>([]);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
|
||||
const pushLog = useCallback((entry: Omit<LogEntry, "id" | "time">) => {
|
||||
setLogs((prev) => {
|
||||
const next: LogEntry[] = [
|
||||
{
|
||||
id: prev.length ? prev[0].id + 1 : 1,
|
||||
time: formatTime(),
|
||||
...entry,
|
||||
},
|
||||
...prev,
|
||||
];
|
||||
return next.slice(0, 200);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sendPayload = useCallback(
|
||||
(payload: RpcMessage, label?: string) => {
|
||||
const socket = wsRef.current;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const json = JSON.stringify(payload);
|
||||
socket.send(json);
|
||||
pushLog({
|
||||
direction: "out",
|
||||
label: label ?? payload.method ?? "response",
|
||||
detail: summarizeParams(payload.params) ?? summarizeParams(payload.result),
|
||||
});
|
||||
},
|
||||
[pushLog],
|
||||
);
|
||||
|
||||
const sendRequest = useCallback(
|
||||
(method: string, params?: Record<string, unknown>) => {
|
||||
const id = nextIdRef.current++;
|
||||
pendingRef.current.set(id, { method });
|
||||
sendPayload({ id, method, params }, method);
|
||||
return id;
|
||||
},
|
||||
[sendPayload],
|
||||
);
|
||||
|
||||
const sendNotification = useCallback(
|
||||
(method: string, params?: Record<string, unknown>) => {
|
||||
sendPayload({ method, params }, method);
|
||||
},
|
||||
[sendPayload],
|
||||
);
|
||||
|
||||
const selectThread = useCallback((threadId: string | null) => {
|
||||
selectedThreadIdRef.current = threadId;
|
||||
setSelectedThreadId(threadId);
|
||||
}, []);
|
||||
|
||||
const ensureThread = useCallback(
|
||||
(threadId: string) => {
|
||||
setThreads((prev) => (prev.includes(threadId) ? prev : [...prev, threadId]));
|
||||
setThreadMessages((prev) => (prev[threadId] ? prev : { ...prev, [threadId]: [] }));
|
||||
if (!selectedThreadIdRef.current) {
|
||||
selectThread(threadId);
|
||||
}
|
||||
},
|
||||
[selectThread],
|
||||
);
|
||||
|
||||
const getAgentIndexForThread = useCallback((threadId: string) => {
|
||||
const existing = agentIndexRef.current.get(threadId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next = new Map<string, number>();
|
||||
agentIndexRef.current.set(threadId, next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const handleInitialize = useCallback(() => {
|
||||
sendRequest("initialize", {
|
||||
clientInfo: {
|
||||
name: "codex_app_server_ui",
|
||||
title: "Codex App Server UI",
|
||||
version: "0.1.0",
|
||||
},
|
||||
});
|
||||
}, [sendRequest]);
|
||||
|
||||
const handleStartThread = useCallback(() => {
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendRequest("thread/start", {});
|
||||
}, [initialized, sendRequest]);
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
if (!initialized || !selectedThreadId || !input.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = input.trim();
|
||||
setInput("");
|
||||
|
||||
sendRequest("turn/start", {
|
||||
threadId: selectedThreadId,
|
||||
input: [{ type: "text", text }],
|
||||
});
|
||||
}, [initialized, selectedThreadId, input, sendRequest]);
|
||||
|
||||
const handleApprovalDecision = useCallback(
|
||||
(approvalId: number | string, decision: "accept" | "decline") => {
|
||||
sendPayload({
|
||||
id: approvalId,
|
||||
result: {
|
||||
decision,
|
||||
},
|
||||
});
|
||||
|
||||
setApprovals((prev) => prev.filter((approval) => approval.id !== approvalId));
|
||||
},
|
||||
[sendPayload],
|
||||
);
|
||||
|
||||
const updateAgentMessage = useCallback(
|
||||
(threadId: string, itemId: string, delta: string) => {
|
||||
setThreadMessages((prev) => {
|
||||
const threadLog = prev[threadId] ?? [];
|
||||
const indexMap = getAgentIndexForThread(threadId);
|
||||
const existingIndex = indexMap.get(itemId);
|
||||
if (existingIndex === undefined) {
|
||||
indexMap.set(itemId, threadLog.length);
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: [...threadLog, { id: itemId, role: "assistant", text: delta }],
|
||||
};
|
||||
}
|
||||
|
||||
const nextThreadLog = [...threadLog];
|
||||
nextThreadLog[existingIndex] = {
|
||||
...nextThreadLog[existingIndex],
|
||||
text: nextThreadLog[existingIndex].text + delta,
|
||||
};
|
||||
return { ...prev, [threadId]: nextThreadLog };
|
||||
});
|
||||
},
|
||||
[getAgentIndexForThread],
|
||||
);
|
||||
|
||||
const extractUserText = useCallback((content: unknown) => {
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const parts = content
|
||||
.map((entry) => {
|
||||
if (entry && typeof entry === "object" && (entry as { type?: string }).type === "text") {
|
||||
return (entry as { text?: string }).text ?? "";
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter((text) => text.length > 0);
|
||||
return parts.length ? parts.join("\n") : null;
|
||||
}, []);
|
||||
|
||||
const markUserItemSeen = useCallback((threadId: string, itemId: string) => {
|
||||
const seen = userItemIdsRef.current.get(threadId) ?? new Set<string>();
|
||||
if (!userItemIdsRef.current.has(threadId)) {
|
||||
userItemIdsRef.current.set(threadId, seen);
|
||||
}
|
||||
if (seen.has(itemId)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(itemId);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const handleIncomingMessage = useCallback(
|
||||
(message: RpcMessage) => {
|
||||
if (message.id !== undefined && message.method) {
|
||||
const requestId = message.id;
|
||||
if (
|
||||
message.method === "item/commandExecution/requestApproval" ||
|
||||
message.method === "item/fileChange/requestApproval"
|
||||
) {
|
||||
setApprovals((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: requestId,
|
||||
method: message.method ?? "",
|
||||
params: message.params ?? {},
|
||||
receivedAt: formatTime(),
|
||||
},
|
||||
]);
|
||||
pushLog({
|
||||
direction: "in",
|
||||
label: message.method,
|
||||
detail: summarizeParams(message.params),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== undefined) {
|
||||
const pending = pendingRef.current.get(message.id);
|
||||
pendingRef.current.delete(message.id);
|
||||
|
||||
if (pending) {
|
||||
if (pending.method === "initialize") {
|
||||
const errorMessage =
|
||||
message.error && typeof message.error.message === "string"
|
||||
? message.error.message
|
||||
: null;
|
||||
const alreadyInitialized = errorMessage === "Already initialized";
|
||||
if (!message.error) {
|
||||
sendNotification("initialized");
|
||||
}
|
||||
if (!message.error || alreadyInitialized) {
|
||||
setInitialized(true);
|
||||
sendRequest("thread/loaded/list");
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.method === "thread/start" || pending.method === "thread/resume") {
|
||||
const thread = message.result?.thread as { id?: string } | undefined;
|
||||
if (thread?.id) {
|
||||
ensureThread(thread.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.method === "thread/loaded/list") {
|
||||
const data = message.result?.data;
|
||||
if (Array.isArray(data)) {
|
||||
const ids = data.filter((entry): entry is string => typeof entry === "string");
|
||||
setThreads(ids);
|
||||
setThreadMessages((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const id of ids) {
|
||||
if (!next[id]) {
|
||||
next[id] = [];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!selectedThreadIdRef.current && ids.length > 0) {
|
||||
selectThread(ids[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.method === "turn/start") {
|
||||
const turn = message.result?.turn as { id?: string } | undefined;
|
||||
if (turn?.id) {
|
||||
setActiveTurnId(turn.id);
|
||||
setActiveTurnThreadId(selectedThreadIdRef.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pushLog({
|
||||
direction: "in",
|
||||
label: pending?.method ?? "response",
|
||||
detail: summarizeParams(message.result) ?? summarizeParams(message.error),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method) {
|
||||
const eventThreadId = message.params?.threadId as string | undefined;
|
||||
if (eventThreadId) {
|
||||
ensureThread(eventThreadId);
|
||||
}
|
||||
|
||||
if (shouldLogMethod(message.method)) {
|
||||
pushLog({
|
||||
direction: "in",
|
||||
label: message.method,
|
||||
detail: summarizeParams(message.params),
|
||||
});
|
||||
}
|
||||
|
||||
if (message.method === "thread/started") {
|
||||
const thread = (message.params?.thread as { id?: string } | undefined) ?? undefined;
|
||||
if (thread?.id) {
|
||||
ensureThread(thread.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.method === "turn/started") {
|
||||
const turn = (message.params?.turn as { id?: string } | undefined) ?? undefined;
|
||||
const threadId = message.params?.threadId as string | undefined;
|
||||
if (turn?.id) {
|
||||
setActiveTurnId(turn.id);
|
||||
setActiveTurnThreadId(threadId ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.method === "turn/completed") {
|
||||
setActiveTurnId(null);
|
||||
setActiveTurnThreadId(null);
|
||||
}
|
||||
|
||||
if (message.method === "item/started") {
|
||||
const item = message.params?.item as {
|
||||
id?: string;
|
||||
type?: string;
|
||||
content?: unknown;
|
||||
text?: string;
|
||||
} | undefined;
|
||||
const threadId = message.params?.threadId as string | undefined;
|
||||
if (!threadId) {
|
||||
return;
|
||||
}
|
||||
const itemId = item?.id;
|
||||
if (item?.type === "agentMessage" && itemId) {
|
||||
setThreadMessages((prev) => {
|
||||
const threadLog = prev[threadId] ?? [];
|
||||
const indexMap = getAgentIndexForThread(threadId);
|
||||
indexMap.set(itemId, threadLog.length);
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: [...threadLog, { id: itemId, role: "assistant", text: "" }],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.type === "userMessage") {
|
||||
const userText = extractUserText(item.content);
|
||||
if (userText) {
|
||||
if (itemId && !markUserItemSeen(threadId, itemId)) {
|
||||
return;
|
||||
}
|
||||
setThreadMessages((prev) => {
|
||||
const threadLog = prev[threadId] ?? [];
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: [
|
||||
...threadLog,
|
||||
{ id: itemId ?? `user-${Date.now()}`, role: "user", text: userText },
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.method === "item/agentMessage/delta") {
|
||||
const itemId = message.params?.itemId as string | undefined;
|
||||
const threadId = message.params?.threadId as string | undefined;
|
||||
const delta = message.params?.delta as string | undefined;
|
||||
if (itemId && delta && threadId) {
|
||||
updateAgentMessage(threadId, itemId, delta);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.method === "item/completed") {
|
||||
const item = message.params?.item as {
|
||||
id?: string;
|
||||
type?: string;
|
||||
text?: string;
|
||||
content?: unknown;
|
||||
} | undefined;
|
||||
const threadId = message.params?.threadId as string | undefined;
|
||||
if (!threadId) {
|
||||
return;
|
||||
}
|
||||
const itemId = item?.id;
|
||||
if (item?.type === "agentMessage" && itemId && typeof item.text === "string") {
|
||||
setThreadMessages((prev) => {
|
||||
const threadLog = prev[threadId] ?? [];
|
||||
const index = getAgentIndexForThread(threadId).get(itemId);
|
||||
if (index === undefined) {
|
||||
getAgentIndexForThread(threadId).set(itemId, threadLog.length);
|
||||
return {
|
||||
...prev,
|
||||
[threadId]: [...threadLog, { id: itemId, role: "assistant", text: item.text ?? "" }],
|
||||
};
|
||||
}
|
||||
|
||||
const nextThreadLog = [...threadLog];
|
||||
nextThreadLog[index] = { ...nextThreadLog[index], text: item.text ?? "" };
|
||||
return { ...prev, [threadId]: nextThreadLog };
|
||||
});
|
||||
}
|
||||
|
||||
if (item?.type === "userMessage") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
ensureThread,
|
||||
extractUserText,
|
||||
getAgentIndexForThread,
|
||||
markUserItemSeen,
|
||||
pushLog,
|
||||
sendNotification,
|
||||
selectThread,
|
||||
sendRequest,
|
||||
updateAgentMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
const socket = new WebSocket(wsUrl);
|
||||
wsRef.current = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
setConnected(true);
|
||||
setConnectionError(null);
|
||||
handleInitialize();
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
setConnected(false);
|
||||
setInitialized(false);
|
||||
setThreads([]);
|
||||
selectThread(null);
|
||||
setActiveTurnId(null);
|
||||
setActiveTurnThreadId(null);
|
||||
setApprovals([]);
|
||||
setThreadMessages({});
|
||||
agentIndexRef.current.clear();
|
||||
userItemIdsRef.current.clear();
|
||||
pendingRef.current.clear();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
setConnectionError("WebSocket error. Check the bridge server.");
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data as string) as RpcMessage;
|
||||
handleIncomingMessage(parsed);
|
||||
} catch (err) {
|
||||
pushLog({
|
||||
direction: "in",
|
||||
label: "ui/error",
|
||||
detail: `Failed to parse message: ${String(err)}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [handleIncomingMessage, handleInitialize, pushLog, selectThread]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const statusLabel = useMemo(() => {
|
||||
if (!connected) {
|
||||
return "Disconnected";
|
||||
}
|
||||
|
||||
if (!initialized) {
|
||||
return "Connecting";
|
||||
}
|
||||
|
||||
return "Ready";
|
||||
}, [connected, initialized]);
|
||||
|
||||
const activeMessages = selectedThreadId ? threadMessages[selectedThreadId] ?? [] : [];
|
||||
const displayedTurnId =
|
||||
selectedThreadId && activeTurnThreadId === selectedThreadId ? activeTurnId : null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="hero">
|
||||
<div className="status">
|
||||
<span className={`status-dot ${connected ? "on" : "off"}`} />
|
||||
<div>
|
||||
<div className="status-label">{statusLabel}</div>
|
||||
<div className="status-meta">{wsUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="grid">
|
||||
<section className="panel control">
|
||||
<div className="panel-title">Session</div>
|
||||
<div className="control-row">
|
||||
<div>
|
||||
<div className="label">Thread</div>
|
||||
<div className="value">{selectedThreadId ?? "none"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="label">Turn</div>
|
||||
<div className="value">{displayedTurnId ?? "idle"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button className="btn" onClick={connect} type="button">
|
||||
Reconnect
|
||||
</button>
|
||||
<button className="btn primary" onClick={handleStartThread} type="button" disabled={!initialized}>
|
||||
Start Thread
|
||||
</button>
|
||||
</div>
|
||||
{connectionError ? <div className="notice">{connectionError}</div> : null}
|
||||
|
||||
<div className="composer">
|
||||
<label className="label" htmlFor="message">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
value={input}
|
||||
placeholder="Ask Codex for a change or summary..."
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<button
|
||||
className="btn primary"
|
||||
type="button"
|
||||
onClick={handleSendMessage}
|
||||
disabled={!initialized || !selectedThreadId || !input.trim()}
|
||||
>
|
||||
Send Turn
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="thread-list">
|
||||
<div className="panel-title">Subscribed Threads</div>
|
||||
{threads.length === 0 ? (
|
||||
<div className="empty">No threads yet.</div>
|
||||
) : (
|
||||
threads.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`thread-item ${selectedThreadId === id ? "active" : ""}`}
|
||||
onClick={() => selectThread(id)}
|
||||
>
|
||||
{id}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{approvals.length ? (
|
||||
<div className="approvals">
|
||||
<div className="panel-title">Approvals</div>
|
||||
{approvals.map((approval) => (
|
||||
<div className="approval-card" key={String(approval.id)}>
|
||||
<div className="approval-header">
|
||||
<span>{approval.method}</span>
|
||||
<span className="approval-time">{approval.receivedAt}</span>
|
||||
</div>
|
||||
<pre>{JSON.stringify(approval.params, null, 2)}</pre>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="btn"
|
||||
type="button"
|
||||
onClick={() => handleApprovalDecision(approval.id, "decline")}
|
||||
>
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
className="btn primary"
|
||||
type="button"
|
||||
onClick={() => handleApprovalDecision(approval.id, "accept")}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel chat">
|
||||
<div className="panel-title">Conversation</div>
|
||||
<div className="chat-scroll">
|
||||
{activeMessages.length === 0 ? (
|
||||
<div className="empty">No messages yet. Start a thread to begin.</div>
|
||||
) : (
|
||||
activeMessages.map((message) => (
|
||||
<div className={`bubble ${message.role}`} key={message.id}>
|
||||
<div className="bubble-role">{message.role === "user" ? "You" : "Codex"}</div>
|
||||
<div className="bubble-text">{message.text}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel logs">
|
||||
<div className="panel-title">Event Log</div>
|
||||
<div className="log-scroll">
|
||||
{logs.length === 0 ? (
|
||||
<div className="empty">Events from app-server will appear here.</div>
|
||||
) : (
|
||||
logs.map((entry) => (
|
||||
<div className={`log-entry ${entry.direction}`} key={entry.id}>
|
||||
<div className="log-meta">
|
||||
<span className="log-time">{entry.time}</span>
|
||||
<span className="log-label">{entry.label}</span>
|
||||
</div>
|
||||
{entry.detail ? <div className="log-detail">{entry.detail}</div> : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app-server-ui/src/main.tsx
Normal file
16
app-server-ui/src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./App.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
27
app-server-ui/tsconfig.json
Normal file
27
app-server-ui/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
app-server-ui/vite.config.ts
Normal file
10
app-server-ui/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
@@ -75,6 +75,9 @@
|
||||
"apply_patch_freeform": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"collab": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -99,9 +102,6 @@
|
||||
"experimental_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -373,6 +373,15 @@
|
||||
"description": "When set to `true`, `AgentReasoningRawContentEvent` events will be shown in the UI/output. Defaults to `false`.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"skills": {
|
||||
"description": "Additional skill sources to load from local paths or URLs.",
|
||||
"default": null,
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SkillsConfigToml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tool_output_token_limit": {
|
||||
"description": "Token budget applied when storing tool/function outputs in the context manager.",
|
||||
"type": "integer",
|
||||
@@ -543,6 +552,9 @@
|
||||
"apply_patch_freeform": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"collab": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -567,9 +579,6 @@
|
||||
"experimental_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"child_agents_md": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1288,6 +1297,55 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SkillSourcePathToml": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SkillSourceToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SkillSourcePathToml"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SkillSourceUrlToml"
|
||||
}
|
||||
]
|
||||
},
|
||||
"SkillSourceUrlToml": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SkillsConfigToml": {
|
||||
"description": "Configuration for additional skill sources.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sources": {
|
||||
"description": "Additional skill sources to load. Each entry can be a local path or a URL.",
|
||||
"default": [],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SkillSourceToml"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ToolsToml": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -18,6 +18,21 @@ Codex can run a notification hook when the agent finishes a turn. See the config
|
||||
|
||||
- https://developers.openai.com/codex/config-reference
|
||||
|
||||
## Skills sources
|
||||
|
||||
You can add extra skill sources in `config.toml` using the `[skills]` table:
|
||||
|
||||
```toml
|
||||
[skills]
|
||||
sources = [
|
||||
{ path = "/path/to/skills" },
|
||||
{ url = "https://example.com/my-skill.skill" }
|
||||
]
|
||||
```
|
||||
|
||||
- `path` should point to a directory that contains one or more skill folders with `SKILL.md`.
|
||||
- `url` can point to a `.skill` (zip) archive or a raw `SKILL.md`. Remote skills are cached under `~/.codex/skills/.remote/`.
|
||||
|
||||
## JSON Schema
|
||||
|
||||
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.
|
||||
|
||||
551
pnpm-lock.yaml
generated
551
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ packages:
|
||||
- docs
|
||||
- sdk/typescript
|
||||
- shell-tool-mcp
|
||||
- app-server-ui
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
Reference in New Issue
Block a user