Compare commits

...

2 Commits

Author SHA1 Message Date
jif-oai
507e43b69c more 2026-01-16 18:29:52 +01:00
jif-oai
8869f662bf mini vite client 2026-01-16 18:23:28 +01:00
15 changed files with 2005 additions and 11 deletions

24
app-server-ui/.gitignore vendored Normal file
View 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
View 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
View 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>

View 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"
}
}

View 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

View 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
View 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
View 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>
);
}

View 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>,
);

View 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"]
}

View 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,
},
});

View File

@@ -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": {

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ packages:
- docs
- sdk/typescript
- shell-tool-mcp
- app-server-ui
ignoredBuiltDependencies:
- esbuild