diff --git a/README.md b/README.md index 514ff7354a5a37008e7a110eec02690142616d43..85dbd6e248b82e9317bdf4d1485faf96a02df0e6 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,22 @@ -# Electron TODO Monorepo (Turborepo + Bun) +# CodeThing (Electron + Vite + Bun) -A long-lived Electron starter focused on: +CodeThing is a desktop shell for coding agents. This first implementation is: -1. Performance: Vite renderer, small typed IPC surface, serialized persistence writes. -2. Maintainability: strict TypeScript, explicit package boundaries, isolated renderer. -3. Modern best practices: Bun workspaces, Turborepo task graph, shared contracts. +1. Codex-first: connects to `codex app-server` and streams turn/item events. +2. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later. +3. Typed end-to-end: contracts validate payloads at preload/main boundaries. ## Workspace layout -- `apps/desktop`: Electron main + preload process. -- `apps/renderer`: React + Vite + Tailwind renderer UI. -- `packages/contracts`: shared runtime-validated schemas and IPC/type contracts. +- `/apps/desktop`: Electron main + preload process, includes provider and Codex session managers. +- `/apps/renderer`: React + Vite UI for session control, conversation, and protocol event stream. +- `/packages/contracts`: shared Zod schemas + TypeScript types for IPC and provider events. + +## Codex prerequisites + +- Install Codex CLI so `codex` is on your PATH. +- Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex). +- CodeThing starts the server via `codex app-server` per session. ## Security and boundary model @@ -25,18 +31,20 @@ A long-lived Electron starter focused on: - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. - `bun run build`: builds contracts, renderer, and desktop bundles through Turbo. - `bun run typecheck`: strict TypeScript checks for all packages. +- `bun run test`: runs workspace tests. Optional: - `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. -## Notes for future native bindings +## Provider architecture -Add new main-process capabilities by extending `packages/contracts` first: +The renderer now depends on `nativeApi.providers.*`: -1. Add schema + types for payload/result. -2. Add an IPC channel constant. -3. Implement `ipcMain.handle` in `apps/desktop/src/main.ts`. -4. Expose a typed preload wrapper in `apps/desktop/src/preload.ts`. -5. Consume only via `window.nativeApi` in renderer. +1. `startSession` +2. `sendTurn` +3. `interruptTurn` +4. `stopSession` +5. `listSessions` +6. `onEvent` -This keeps native concerns out of React and makes refactors low risk. +Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba21e340293b7c39df7e06d0f00fc4f3f4184cd0 --- /dev/null +++ b/apps/desktop/src/codexAppServerManager.ts @@ -0,0 +1,690 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { EventEmitter } from "node:events"; +import readline from "node:readline"; + +import type { + ProviderEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@acme/contracts"; + +type PendingRequestKey = string; + +interface PendingRequest { + method: string; + timeout: ReturnType; + resolve: (value: unknown) => void; + reject: (error: Error) => void; +} + +interface CodexSessionContext { + session: ProviderSession; + child: ChildProcessWithoutNullStreams; + output: readline.Interface; + pending: Map; + nextRequestId: number; + stopping: boolean; +} + +interface JsonRpcError { + code?: number; + message?: string; +} + +interface JsonRpcRequest { + id: string | number; + method: string; + params?: unknown; +} + +interface JsonRpcResponse { + id: string | number; + result?: unknown; + error?: JsonRpcError; +} + +interface JsonRpcNotification { + method: string; + params?: unknown; +} + +export interface CodexAppServerManagerEvents { + event: [event: ProviderEvent]; +} + +export class CodexAppServerManager extends EventEmitter { + private readonly sessions = new Map(); + + async startSession( + input: ProviderSessionStartInput, + ): Promise { + const sessionId = randomUUID(); + const now = new Date().toISOString(); + + const session: ProviderSession = { + sessionId, + provider: "codex", + status: "connecting", + model: input.model, + cwd: input.cwd, + createdAt: now, + updatedAt: now, + }; + + const child = spawn("codex", ["app-server"], { + cwd: input.cwd, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const output = readline.createInterface({ input: child.stdout }); + + const context: CodexSessionContext = { + session, + child, + output, + pending: new Map(), + nextRequestId: 1, + stopping: false, + }; + + this.sessions.set(sessionId, context); + this.attachProcessListeners(context); + + this.emitLifecycleEvent( + context, + "session/connecting", + "Starting codex app-server", + ); + + try { + await this.sendRequest(context, "initialize", { + clientInfo: { + name: "codething_desktop", + title: "CodeThing Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: false, + }, + }); + + this.writeMessage(context, { method: "initialized" }); + + const threadStart = await this.sendRequest(context, "thread/start", { + model: input.model ?? null, + cwd: input.cwd ?? null, + approvalPolicy: input.approvalPolicy, + sandbox: input.sandboxMode, + experimentalRawEvents: false, + }); + + const threadId = this.readString( + this.readObject(threadStart)?.thread, + "id", + ); + if (!threadId) { + throw new Error("thread/start response did not include a thread id."); + } + + this.updateSession(context, { + status: "ready", + threadId, + }); + this.emitLifecycleEvent( + context, + "session/ready", + `Connected to thread ${threadId}`, + ); + return { ...context.session }; + } catch (error) { + const message = + error instanceof Error + ? error.message + : "Failed to start Codex session."; + this.updateSession(context, { + status: "error", + lastError: message, + }); + this.emitErrorEvent(context, "session/startFailed", message); + this.stopSession(sessionId); + throw new Error(message); + } + } + + async sendTurn( + input: ProviderSendTurnInput, + ): Promise { + const context = this.requireSession(input.sessionId); + if (!context.session.threadId) { + throw new Error("Session is missing a thread id."); + } + + const response = await this.sendRequest(context, "turn/start", { + threadId: context.session.threadId, + input: [ + { + type: "text", + text: input.input, + text_elements: [], + }, + ], + }); + + const turn = this.readObject(this.readObject(response), "turn"); + const turnId = this.readString(turn, "id"); + if (!turnId) { + throw new Error("turn/start response did not include a turn id."); + } + + this.updateSession(context, { + status: "running", + activeTurnId: turnId, + }); + + return { + threadId: context.session.threadId, + turnId, + }; + } + + async interruptTurn(sessionId: string, turnId?: string): Promise { + const context = this.requireSession(sessionId); + const effectiveTurnId = turnId ?? context.session.activeTurnId; + + if (!effectiveTurnId || !context.session.threadId) { + return; + } + + await this.sendRequest(context, "turn/interrupt", { + threadId: context.session.threadId, + turnId: effectiveTurnId, + }); + } + + stopSession(sessionId: string): void { + const context = this.sessions.get(sessionId); + if (!context) { + return; + } + + context.stopping = true; + + for (const pending of context.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(new Error("Session stopped before request completed.")); + } + context.pending.clear(); + + context.output.close(); + + if (!context.child.killed) { + context.child.kill(); + } + + this.updateSession(context, { + status: "closed", + activeTurnId: undefined, + }); + this.emitLifecycleEvent(context, "session/closed", "Session stopped"); + this.sessions.delete(sessionId); + } + + listSessions(): ProviderSession[] { + return Array.from(this.sessions.values(), ({ session }) => ({ + ...session, + })); + } + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + stopAll(): void { + for (const sessionId of this.sessions.keys()) { + this.stopSession(sessionId); + } + } + + private requireSession(sessionId: string): CodexSessionContext { + const context = this.sessions.get(sessionId); + if (!context) { + throw new Error(`Unknown session: ${sessionId}`); + } + + if (context.session.status === "closed") { + throw new Error(`Session is closed: ${sessionId}`); + } + + return context; + } + + private attachProcessListeners(context: CodexSessionContext): void { + context.output.on("line", (line) => { + this.handleStdoutLine(context, line); + }); + + context.child.stderr.on("data", (chunk: Buffer) => { + const message = chunk.toString().trim(); + if (!message) { + return; + } + + this.emitErrorEvent(context, "process/stderr", message); + }); + + context.child.on("error", (error) => { + const message = error.message || "codex app-server process errored."; + this.updateSession(context, { + status: "error", + lastError: message, + }); + this.emitErrorEvent(context, "process/error", message); + }); + + context.child.on("exit", (code, signal) => { + if (context.stopping) { + return; + } + + const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; + this.updateSession(context, { + status: "closed", + activeTurnId: undefined, + lastError: code === 0 ? context.session.lastError : message, + }); + this.emitLifecycleEvent(context, "session/exited", message); + this.sessions.delete(context.session.sessionId); + }); + } + + private handleStdoutLine(context: CodexSessionContext, line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + this.emitErrorEvent( + context, + "protocol/parseError", + "Received invalid JSON from codex app-server.", + ); + return; + } + + if (!parsed || typeof parsed !== "object") { + this.emitErrorEvent( + context, + "protocol/invalidMessage", + "Received non-object protocol message.", + ); + return; + } + + if (this.isServerRequest(parsed)) { + this.handleServerRequest(context, parsed); + return; + } + + if (this.isServerNotification(parsed)) { + this.handleServerNotification(context, parsed); + return; + } + + if (this.isResponse(parsed)) { + this.handleResponse(context, parsed); + return; + } + + this.emitErrorEvent( + context, + "protocol/unrecognizedMessage", + "Received protocol message in an unknown shape.", + ); + } + + private handleServerNotification( + context: CodexSessionContext, + notification: JsonRpcNotification, + ): void { + const route = this.readRouteFields(notification.params); + const textDelta = + notification.method === "item/agentMessage/delta" + ? this.readString(notification.params, "delta") + : undefined; + + this.emitEvent({ + id: randomUUID(), + kind: "notification", + provider: "codex", + sessionId: context.session.sessionId, + createdAt: new Date().toISOString(), + method: notification.method, + threadId: route.threadId, + turnId: route.turnId, + itemId: route.itemId, + textDelta, + payload: notification.params, + }); + + if (notification.method === "thread/started") { + const threadId = this.readString( + this.readObject(notification.params)?.thread, + "id", + ); + if (threadId) { + this.updateSession(context, { threadId }); + } + return; + } + + if (notification.method === "turn/started") { + const turnId = this.readString( + this.readObject(notification.params)?.turn, + "id", + ); + this.updateSession(context, { + status: "running", + activeTurnId: turnId, + }); + return; + } + + if (notification.method === "turn/completed") { + const turn = this.readObject(notification.params, "turn"); + const status = this.readString(turn, "status"); + const errorMessage = this.readString( + this.readObject(turn, "error"), + "message", + ); + this.updateSession(context, { + status: status === "failed" ? "error" : "ready", + activeTurnId: undefined, + lastError: errorMessage ?? context.session.lastError, + }); + return; + } + + if (notification.method === "error") { + const message = this.readString( + this.readObject(notification.params)?.error, + "message", + ); + const willRetry = this.readBoolean(notification.params, "willRetry"); + + this.updateSession(context, { + status: willRetry ? "running" : "error", + lastError: message ?? context.session.lastError, + }); + } + } + + private handleServerRequest( + context: CodexSessionContext, + request: JsonRpcRequest, + ): void { + const route = this.readRouteFields(request.params); + this.emitEvent({ + id: randomUUID(), + kind: "request", + provider: "codex", + sessionId: context.session.sessionId, + createdAt: new Date().toISOString(), + method: request.method, + threadId: route.threadId, + turnId: route.turnId, + itemId: route.itemId, + payload: request.params, + }); + + if (request.method === "item/commandExecution/requestApproval") { + this.writeMessage(context, { + id: request.id, + result: { decision: "decline" }, + }); + return; + } + + if (request.method === "item/fileChange/requestApproval") { + this.writeMessage(context, { + id: request.id, + result: { decision: "decline" }, + }); + return; + } + + if (request.method === "item/tool/requestUserInput") { + this.writeMessage(context, { + id: request.id, + result: { answers: {} }, + }); + return; + } + + this.writeMessage(context, { + id: request.id, + error: { + code: -32601, + message: `Unsupported server request: ${request.method}`, + }, + }); + } + + private handleResponse( + context: CodexSessionContext, + response: JsonRpcResponse, + ): void { + const key = String(response.id); + const pending = context.pending.get(key); + if (!pending) { + return; + } + + clearTimeout(pending.timeout); + context.pending.delete(key); + + if (response.error?.message) { + pending.reject( + new Error( + `${pending.method} failed: ${String(response.error.message)}`, + ), + ); + return; + } + + pending.resolve(response.result); + } + + private async sendRequest( + context: CodexSessionContext, + method: string, + params: unknown, + timeoutMs = 20_000, + ): Promise { + const id = context.nextRequestId; + context.nextRequestId += 1; + + const result = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + context.pending.delete(String(id)); + reject(new Error(`Timed out waiting for ${method}.`)); + }, timeoutMs); + + context.pending.set(String(id), { + method, + timeout, + resolve, + reject, + }); + this.writeMessage(context, { + method, + id, + params, + }); + }); + + return result as TResponse; + } + + private writeMessage(context: CodexSessionContext, message: unknown): void { + const encoded = JSON.stringify(message); + if (!context.child.stdin.writable) { + throw new Error("Cannot write to codex app-server stdin."); + } + + context.child.stdin.write(`${encoded}\n`); + } + + private emitLifecycleEvent( + context: CodexSessionContext, + method: string, + message: string, + ): void { + this.emitEvent({ + id: randomUUID(), + kind: "session", + provider: "codex", + sessionId: context.session.sessionId, + createdAt: new Date().toISOString(), + method, + message, + }); + } + + private emitErrorEvent( + context: CodexSessionContext, + method: string, + message: string, + ): void { + this.emitEvent({ + id: randomUUID(), + kind: "error", + provider: "codex", + sessionId: context.session.sessionId, + createdAt: new Date().toISOString(), + method, + message, + }); + } + + private emitEvent(event: ProviderEvent): void { + this.emit("event", event); + } + + private updateSession( + context: CodexSessionContext, + updates: Partial, + ): void { + context.session = { + ...context.session, + ...updates, + updatedAt: new Date().toISOString(), + }; + } + + private isServerRequest(value: unknown): value is JsonRpcRequest { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.method === "string" && + (typeof candidate.id === "string" || typeof candidate.id === "number") + ); + } + + private isServerNotification(value: unknown): value is JsonRpcNotification { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return typeof candidate.method === "string" && !("id" in candidate); + } + + private isResponse(value: unknown): value is JsonRpcResponse { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + const hasId = + typeof candidate.id === "string" || typeof candidate.id === "number"; + const hasMethod = typeof candidate.method === "string"; + return hasId && !hasMethod; + } + + private readRouteFields(params: unknown): { + threadId?: string; + turnId?: string; + itemId?: string; + } { + const route: { + threadId?: string; + turnId?: string; + itemId?: string; + } = {}; + + const threadId = + this.readString(params, "threadId") ?? + this.readString(this.readObject(params, "thread"), "id"); + const turnId = + this.readString(params, "turnId") ?? + this.readString(this.readObject(params, "turn"), "id"); + const itemId = + this.readString(params, "itemId") ?? + this.readString(this.readObject(params, "item"), "id"); + + if (threadId) { + route.threadId = threadId; + } + + if (turnId) { + route.turnId = turnId; + } + + if (itemId) { + route.itemId = itemId; + } + + return route; + } + + private readObject( + value: unknown, + key?: string, + ): Record | undefined { + const target = + key === undefined + ? value + : value && typeof value === "object" + ? (value as Record)[key] + : undefined; + + if (!target || typeof target !== "object") { + return undefined; + } + + return target as Record; + } + + private readString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const candidate = (value as Record)[key]; + return typeof candidate === "string" ? candidate : undefined; + } + + private readBoolean(value: unknown, key: string): boolean | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + const candidate = (value as Record)[key]; + return typeof candidate === "boolean" ? candidate : undefined; + } +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 881ab29a70ff6a05f48123909369201347b45e3d..9c4990a85b0df9880101a32ca42bd2447c8d589f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,8 +1,8 @@ import { fixPath } from "./fixPath"; fixPath(); -import path from "node:path"; import { spawn } from "node:child_process"; +import path from "node:path"; import { BrowserWindow, app, ipcMain, session } from "electron"; import { @@ -16,12 +16,14 @@ import { todoIdSchema, } from "@acme/contracts"; import { ProcessManager } from "./processManager"; +import { ProviderManager } from "./providerManager"; import { TodoStore } from "./todoStore"; const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); let todoStore: TodoStore; const processManager = new ProcessManager(); +const providerManager = new ProviderManager(); function createWindow(): BrowserWindow { const window = new BrowserWindow({ @@ -94,6 +96,47 @@ function registerIpcHandlers(): void { processManager.write(agentSessionIdSchema.parse(sessionId), String(data)); }, ); + + // Provider handlers + ipcMain.handle( + IPC_CHANNELS.providerSessionStart, + async (_event, payload: unknown) => { + return providerManager.startSession( + payload as Parameters[0], + ); + }, + ); + + ipcMain.handle( + IPC_CHANNELS.providerTurnStart, + async (_event, payload: unknown) => { + return providerManager.sendTurn( + payload as Parameters[0], + ); + }, + ); + + ipcMain.handle( + IPC_CHANNELS.providerTurnInterrupt, + async (_event, payload: unknown) => { + await providerManager.interruptTurn( + payload as Parameters[0], + ); + }, + ); + + ipcMain.handle( + IPC_CHANNELS.providerSessionStop, + async (_event, payload: unknown) => { + providerManager.stopSession( + payload as Parameters[0], + ); + }, + ); + + ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { + return providerManager.listSessions(); + }); } async function runTerminalCommand( @@ -101,8 +144,8 @@ async function runTerminalCommand( ): Promise { const shellPath = process.platform === "win32" - ? process.env.ComSpec ?? "cmd.exe" - : process.env.SHELL ?? "/bin/sh"; + ? (process.env.ComSpec ?? "cmd.exe") + : (process.env.SHELL ?? "/bin/sh"); const args = process.platform === "win32" @@ -169,12 +212,20 @@ function setupEventForwarding(window: BrowserWindow): void { } }; + const onProviderEvent = (event: unknown) => { + if (!window.isDestroyed()) { + window.webContents.send(IPC_CHANNELS.providerEvent, event); + } + }; + processManager.on("output", onOutput); processManager.on("exit", onExit); + providerManager.on("event", onProviderEvent); window.on("closed", () => { processManager.off("output", onOutput); processManager.off("exit", onExit); + providerManager.off("event", onProviderEvent); }); } @@ -211,6 +262,7 @@ async function bootstrap(): Promise { app.on("before-quit", () => { processManager.killAll(); + providerManager.stopAll(); }); app.whenReady().then(() => { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index b9207d85cd3da18ca16e4280bb4abdb3dc4f208c..9c44b6ef95241ddcde451fc9694bd5aaf2de0572 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -31,6 +31,24 @@ const nativeApi: NativeApi = { return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener); }, }, + providers: { + startSession: (input) => + ipcRenderer.invoke(IPC_CHANNELS.providerSessionStart, input), + sendTurn: (input) => + ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), + interruptTurn: (input) => + ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), + stopSession: (input) => + ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), + listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), + onEvent: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => + callback(payload as Parameters[0]); + ipcRenderer.on(IPC_CHANNELS.providerEvent, listener); + return () => + ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); + }, + }, }; contextBridge.exposeInMainWorld("nativeApi", nativeApi); diff --git a/apps/desktop/src/providerManager.ts b/apps/desktop/src/providerManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a9f37bcb2013647a86bd0b20d554037bb8f854b --- /dev/null +++ b/apps/desktop/src/providerManager.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from "node:events"; + +import { + type ProviderEvent, + type ProviderInterruptTurnInput, + type ProviderSendTurnInput, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderStopSessionInput, + type ProviderTurnStartResult, + providerInterruptTurnInputSchema, + providerSendTurnInputSchema, + providerSessionStartInputSchema, + providerStopSessionInputSchema, +} from "@acme/contracts"; +import { CodexAppServerManager } from "./codexAppServerManager"; + +export interface ProviderManagerEvents { + event: [event: ProviderEvent]; +} + +export class ProviderManager extends EventEmitter { + private readonly codex = new CodexAppServerManager(); + + constructor() { + super(); + this.codex.on("event", (event) => this.emit("event", event)); + } + + async startSession(raw: ProviderSessionStartInput): Promise { + const input = providerSessionStartInputSchema.parse(raw); + if (input.provider !== "codex") { + throw new Error(`Provider '${input.provider}' is not implemented yet.`); + } + + return this.codex.startSession(input); + } + + async sendTurn(raw: ProviderSendTurnInput): Promise { + const input = providerSendTurnInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + return this.codex.sendTurn(input); + } + + async interruptTurn(raw: ProviderInterruptTurnInput): Promise { + const input = providerInterruptTurnInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + await this.codex.interruptTurn(input.sessionId, input.turnId); + } + + stopSession(raw: ProviderStopSessionInput): void { + const input = providerStopSessionInputSchema.parse(raw); + this.codex.stopSession(input.sessionId); + } + + listSessions(): ProviderSession[] { + return this.codex.listSessions(); + } + + stopAll(): void { + this.codex.stopAll(); + } +} diff --git a/apps/renderer/src/App.tsx b/apps/renderer/src/App.tsx index 1774e2962d99238fea2256c5e9acfe0bfbb73759..6654da9f809591e06161efe368a0cb427c461eba 100644 --- a/apps/renderer/src/App.tsx +++ b/apps/renderer/src/App.tsx @@ -1,12 +1,55 @@ -import { type FormEvent, useMemo, useState } from "react"; +import { + type FormEvent, + type MutableRefObject, + useEffect, + useMemo, + useRef, + useState, +} from "react"; -import type { TerminalCommandResult } from "@acme/contracts"; +import type { + ProviderEvent, + ProviderKind, + ProviderSession, +} from "@acme/contracts"; -interface TerminalEntry { +type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; + +interface ChatMessage { id: string; - command: string; - result: TerminalCommandResult; + role: "user" | "assistant"; + text: string; createdAt: string; + streaming: boolean; +} + +const PROVIDER_OPTIONS: Array<{ + value: ProviderKind; + label: string; + available: boolean; +}> = [ + { value: "codex", label: "Codex", available: true }, + { value: "claudeCode", label: "Claude Code (soon)", available: false }, +]; + +function readNativeApi() { + if (typeof window === "undefined") { + return undefined; + } + + return window.nativeApi; +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; } function formatTimestamp(isoDate: string): string { @@ -17,66 +60,364 @@ function formatTimestamp(isoDate: string): string { }).format(new Date(isoDate)); } -function readNativeApi() { - if (typeof window === "undefined") { - return undefined; +function derivePhase(session: ProviderSession | null): SessionPhase { + if (!session || session.status === "closed") { + return "disconnected"; } - return window.nativeApi; + if (session.status === "connecting") { + return "connecting"; + } + + if (session.status === "running") { + return "running"; + } + + return "ready"; +} + +function evolveSession( + previous: ProviderSession, + event: ProviderEvent, +): ProviderSession { + const payload = asObject(event.payload); + + if (event.method === "thread/started") { + const thread = asObject(payload?.thread); + return { + ...previous, + threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, + updatedAt: event.createdAt, + }; + } + + if (event.method === "turn/started") { + const turn = asObject(payload?.turn); + return { + ...previous, + status: "running", + activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, + updatedAt: event.createdAt, + }; + } + + if (event.method === "turn/completed") { + const turn = asObject(payload?.turn); + const status = asString(turn?.status); + const turnError = asObject(turn?.error); + return { + ...previous, + status: status === "failed" ? "error" : "ready", + activeTurnId: undefined, + lastError: asString(turnError?.message) ?? previous.lastError, + updatedAt: event.createdAt, + }; + } + + if (event.kind === "error") { + return { + ...previous, + status: "error", + lastError: event.message ?? previous.lastError, + updatedAt: event.createdAt, + }; + } + + if (event.method === "session/closed" || event.method === "session/exited") { + return { + ...previous, + status: "closed", + activeTurnId: undefined, + lastError: event.message ?? previous.lastError, + updatedAt: event.createdAt, + }; + } + + return { + ...previous, + updatedAt: event.createdAt, + }; +} + +function applyEventToMessages( + previous: ChatMessage[], + event: ProviderEvent, + activeAssistantItemRef: MutableRefObject, +): ChatMessage[] { + const payload = asObject(event.payload); + + if (event.method === "item/started") { + const item = asObject(payload?.item); + if (asString(item?.type) !== "agentMessage") { + return previous; + } + + const itemId = asString(item?.id); + if (!itemId) { + return previous; + } + + activeAssistantItemRef.current = itemId; + const seedText = asString(item?.text) ?? ""; + const filtered = previous.filter((entry) => entry.id !== itemId); + return [ + ...filtered, + { + id: itemId, + role: "assistant", + text: seedText, + createdAt: event.createdAt, + streaming: true, + }, + ]; + } + + if (event.method === "item/agentMessage/delta") { + const itemId = event.itemId ?? asString(payload?.itemId); + const delta = event.textDelta ?? asString(payload?.delta) ?? ""; + if (!itemId || !delta) { + return previous; + } + + const existingIndex = previous.findIndex((entry) => entry.id === itemId); + if (existingIndex === -1) { + activeAssistantItemRef.current = itemId; + return [ + ...previous, + { + id: itemId, + role: "assistant", + text: delta, + createdAt: event.createdAt, + streaming: true, + }, + ]; + } + + const updated = [...previous]; + const existing = updated[existingIndex]; + if (!existing) { + return previous; + } + updated[existingIndex] = { + ...existing, + text: `${existing.text}${delta}`, + streaming: true, + }; + return updated; + } + + if (event.method === "item/completed") { + const item = asObject(payload?.item); + if (asString(item?.type) !== "agentMessage") { + return previous; + } + + const itemId = asString(item?.id); + if (!itemId) { + return previous; + } + + const fullText = asString(item?.text); + const existingIndex = previous.findIndex((entry) => entry.id === itemId); + if (existingIndex === -1) { + return [ + ...previous, + { + id: itemId, + role: "assistant", + text: fullText ?? "", + createdAt: event.createdAt, + streaming: false, + }, + ]; + } + + const updated = [...previous]; + const existing = updated[existingIndex]; + if (!existing) { + return previous; + } + updated[existingIndex] = { + ...existing, + text: fullText ?? existing.text, + streaming: false, + }; + + if (activeAssistantItemRef.current === itemId) { + activeAssistantItemRef.current = null; + } + + return updated; + } + + if (event.method === "turn/completed") { + return previous.map((entry) => ({ ...entry, streaming: false })); + } + + return previous; } export default function App() { const api = useMemo(() => readNativeApi(), []); - const [command, setCommand] = useState(""); - const [history, setHistory] = useState([]); - const [isRunning, setIsRunning] = useState(false); + const [provider, setProvider] = useState("codex"); + const [cwd, setCwd] = useState(""); + const [model, setModel] = useState("gpt-5.1-codex"); + const [prompt, setPrompt] = useState(""); + const [session, setSession] = useState(null); + const [messages, setMessages] = useState([]); + const [events, setEvents] = useState([]); + const [isConnecting, setIsConnecting] = useState(false); + const [isSending, setIsSending] = useState(false); const [error, setError] = useState(null); - const onSubmit = async (event: FormEvent) => { + const currentSessionIdRef = useRef(null); + const activeAssistantItemRef = useRef(null); + + useEffect(() => { + currentSessionIdRef.current = session?.sessionId ?? null; + }, [session?.sessionId]); + + useEffect(() => { + if (!api) { + return; + } + + return api.providers.onEvent((event) => { + const currentSessionId = currentSessionIdRef.current; + if (!currentSessionId || currentSessionId !== event.sessionId) { + return; + } + + setEvents((previous) => [event, ...previous].slice(0, 200)); + setSession((previous) => { + if (!previous || previous.sessionId !== event.sessionId) { + return previous; + } + return evolveSession(previous, event); + }); + setMessages((previous) => + applyEventToMessages(previous, event, activeAssistantItemRef), + ); + + if (event.kind === "error" && event.message) { + setError(event.message); + } + }); + }, [api]); + + const phase = derivePhase(session); + + const onConnect = async (event: FormEvent) => { event.preventDefault(); + if (!api || provider !== "codex" || isConnecting) { + return; + } - if (!api || isRunning) { + setError(null); + setIsConnecting(true); + try { + const nextSession = await api.providers.startSession({ + provider, + cwd: cwd.trim() || undefined, + model: model.trim() || undefined, + }); + currentSessionIdRef.current = nextSession.sessionId; + activeAssistantItemRef.current = null; + setSession(nextSession); + setMessages([]); + setEvents([]); + } catch (connectError) { + setError( + connectError instanceof Error + ? connectError.message + : "Unable to connect to Codex app-server.", + ); + } finally { + setIsConnecting(false); + } + }; + + const onDisconnect = async () => { + if (!api || !session) { + return; + } + + await api.providers.stopSession({ sessionId: session.sessionId }); + currentSessionIdRef.current = null; + setSession((previous) => + previous + ? { + ...previous, + status: "closed", + activeTurnId: undefined, + updatedAt: new Date().toISOString(), + } + : previous, + ); + }; + + const onSendPrompt = async (event: FormEvent) => { + event.preventDefault(); + if (!api || !session || isSending) { return; } - const trimmed = command.trim(); + const trimmed = prompt.trim(); if (!trimmed) { return; } setError(null); - setIsRunning(true); + setMessages((previous) => [ + ...previous, + { + id: crypto.randomUUID(), + role: "user", + text: trimmed, + createdAt: new Date().toISOString(), + streaming: false, + }, + ]); + setPrompt(""); + setIsSending(true); try { - const result = await api.terminal.run({ command: trimmed }); - setHistory((previous) => [ - ...previous, - { - id: crypto.randomUUID(), - command: trimmed, - result, - createdAt: new Date().toISOString(), - }, - ]); - setCommand(""); - } catch (runError) { - const message = - runError instanceof Error - ? runError.message - : "Could not execute command."; - setError(message); + await api.providers.sendTurn({ + sessionId: session.sessionId, + input: trimmed, + }); + } catch (sendError) { + setError( + sendError instanceof Error + ? sendError.message + : "Failed to start a Codex turn.", + ); } finally { - setIsRunning(false); + setIsSending(false); } }; + const onInterrupt = async () => { + if (!api || !session) { + return; + } + + await api.providers.interruptTurn({ + sessionId: session.sessionId, + turnId: session.activeTurnId, + }); + }; + if (!api) { return ( -
-
-

Native bridge unavailable

-

- Launch this UI through Electron so the preload API is available. +

+
+

CodeThing

+

Native bridge unavailable

+

+ Launch the renderer through Electron so `window.nativeApi` is + exposed by preload.

@@ -84,102 +425,192 @@ export default function App() { } return ( -
-
-

- Demo Terminal -

-

- Shell Command Runner +
+
+
+

CodeThing / Provider Shell

+

+ Codex-first workspace

-

- Runs one command at a time in your shell and prints stdout/stderr. +

+ Built on the Codex app-server protocol with a provider abstraction in + the IPC layer, so Claude Code can be added without reshaping the UI + contract.

-
- setCommand(event.target.value)} - placeholder="e.g. ls -la" - className="h-11 flex-1 rounded-xl border border-emerald-500/50 bg-slate-950 px-4 font-mono text-sm text-emerald-100 outline-none ring-0 transition focus:border-emerald-300" - maxLength={4000} - /> - - + + + + + + + +
+ {phase === "disconnected" ? ( + + ) : ( + + )} +
+ +
+ + phase={phase} provider={session?.provider ?? "none"} + + + thread={session?.threadId ?? "none"} + + + turn={session?.activeTurnId ?? "none"} + +
{error ? ( -
+
{error}
) : null} -
- {history.length === 0 ? ( -
- No commands run yet. +
+
+

Conversation

+
+ {messages.length === 0 ? ( +
+ No turns yet. Start a session, then send a prompt. +
+ ) : null} + + {messages.map((entry) => ( +
+
+ {entry.role === "user" ? "you" : "codex"} + {formatTimestamp(entry.createdAt)} +
+
+                  {entry.text || (entry.streaming ? "..." : "(empty response)")}
+                
+
+ ))}
- ) : null} - {history.map((entry) => ( -
-
-

$ {entry.command}

-

{formatTimestamp(entry.createdAt)}

-
- -
- {entry.result.stdout ? ( -
-

- stdout -

-
-                    {entry.result.stdout}
-                  
-
- ) : null} - - {entry.result.stderr ? ( -
-

- stderr -

-
-                    {entry.result.stderr}
-                  
-
- ) : null} - - {!entry.result.stdout && !entry.result.stderr ? ( -

(no output)

- ) : null} +
+