| author | |
| committer | |
| log | f733cb7e5f44344a1feb4dc82b18739548a9c9a4 |
| tree | 29f70edd9e4e98f7c5a34406fff923cefdb24995 |
| parent | 58261b9ba01e4c73f2ef0298b3d8b59f7af7c464 |
| signature | Commit is signed but in an unrecognized format. |
11 files changed, 1742 insertions(+), 146 deletions(-)
README.md+24-16| ... | @@ -1,16 +1,22 @@ | ... | @@ -1,16 +1,22 @@ |
| 1 | # Electron TODO Monorepo (Turborepo + Bun) | 1 | # CodeThing (Electron + Vite + Bun) |
| 2 | 2 | ||
| 3 | A long-lived Electron starter focused on: | 3 | CodeThing is a desktop shell for coding agents. This first implementation is: |
| 4 | 4 | ||
| 5 | 1. Performance: Vite renderer, small typed IPC surface, serialized persistence writes. | 5 | 1. Codex-first: connects to `codex app-server` and streams turn/item events. |
| 6 | 2. Maintainability: strict TypeScript, explicit package boundaries, isolated renderer. | 6 | 2. Provider-ready: renderer speaks a provider abstraction so Claude Code can plug in later. |
| 7 | 3. Modern best practices: Bun workspaces, Turborepo task graph, shared contracts. | 7 | 3. Typed end-to-end: contracts validate payloads at preload/main boundaries. |
| 8 | 8 | ||
| 9 | ## Workspace layout | 9 | ## Workspace layout |
| 10 | 10 | ||
| 11 | - `apps/desktop`: Electron main + preload process. | 11 | - `/apps/desktop`: Electron main + preload process, includes provider and Codex session managers. |
| 12 | - `apps/renderer`: React + Vite + Tailwind renderer UI. | 12 | - `/apps/renderer`: React + Vite UI for session control, conversation, and protocol event stream. |
| 13 | - `packages/contracts`: shared runtime-validated schemas and IPC/type contracts. | 13 | - `/packages/contracts`: shared Zod schemas + TypeScript types for IPC and provider events. |
| 14 | |||
| 15 | ## Codex prerequisites | ||
| 16 | |||
| 17 | - Install Codex CLI so `codex` is on your PATH. | ||
| 18 | - Authenticate Codex before running CodeThing (for example via API key or ChatGPT auth supported by Codex). | ||
| 19 | - CodeThing starts the server via `codex app-server` per session. | ||
| 14 | 20 | ||
| 15 | ## Security and boundary model | 21 | ## Security and boundary model |
| 16 | 22 | ||
| ... | @@ -25,18 +31,20 @@ A long-lived Electron starter focused on: | ... | @@ -25,18 +31,20 @@ A long-lived Electron starter focused on: |
| 25 | - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. | 31 | - `bun run dev`: starts contract build/watch, renderer dev server, and Electron process. |
| 26 | - `bun run build`: builds contracts, renderer, and desktop bundles through Turbo. | 32 | - `bun run build`: builds contracts, renderer, and desktop bundles through Turbo. |
| 27 | - `bun run typecheck`: strict TypeScript checks for all packages. | 33 | - `bun run typecheck`: strict TypeScript checks for all packages. |
| 34 | - `bun run test`: runs workspace tests. | ||
| 28 | 35 | ||
| 29 | Optional: | 36 | Optional: |
| 30 | - `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. | 37 | - `ELECTRON_RENDERER_PORT=5180 bun run dev` if `5173` is already in use. |
| 31 | 38 | ||
| 32 | ## Notes for future native bindings | 39 | ## Provider architecture |
| 33 | 40 | ||
| 34 | Add new main-process capabilities by extending `packages/contracts` first: | 41 | The renderer now depends on `nativeApi.providers.*`: |
| 35 | 42 | ||
| 36 | 1. Add schema + types for payload/result. | 43 | 1. `startSession` |
| 37 | 2. Add an IPC channel constant. | 44 | 2. `sendTurn` |
| 38 | 3. Implement `ipcMain.handle` in `apps/desktop/src/main.ts`. | 45 | 3. `interruptTurn` |
| 39 | 4. Expose a typed preload wrapper in `apps/desktop/src/preload.ts`. | 46 | 4. `stopSession` |
| 40 | 5. Consume only via `window.nativeApi` in renderer. | 47 | 5. `listSessions` |
| 48 | 6. `onEvent` | ||
| 41 | 49 | ||
| 42 | This keeps native concerns out of React and makes refactors low risk. | 50 | Codex is the only implemented provider right now. `claudeCode` is reserved in contracts/UI but returns a not-implemented error in main-process dispatch. |
apps/desktop/src/codexAppServerManager.ts created+690| ... | @@ -0,0 +1,690 @@ | ||
| 1 | import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; | ||
| 2 | import { randomUUID } from "node:crypto"; | ||
| 3 | import { EventEmitter } from "node:events"; | ||
| 4 | import readline from "node:readline"; | ||
| 5 | |||
| 6 | import type { | ||
| 7 | ProviderEvent, | ||
| 8 | ProviderSendTurnInput, | ||
| 9 | ProviderSession, | ||
| 10 | ProviderSessionStartInput, | ||
| 11 | ProviderTurnStartResult, | ||
| 12 | } from "@acme/contracts"; | ||
| 13 | |||
| 14 | type PendingRequestKey = string; | ||
| 15 | |||
| 16 | interface PendingRequest { | ||
| 17 | method: string; | ||
| 18 | timeout: ReturnType<typeof setTimeout>; | ||
| 19 | resolve: (value: unknown) => void; | ||
| 20 | reject: (error: Error) => void; | ||
| 21 | } | ||
| 22 | |||
| 23 | interface CodexSessionContext { | ||
| 24 | session: ProviderSession; | ||
| 25 | child: ChildProcessWithoutNullStreams; | ||
| 26 | output: readline.Interface; | ||
| 27 | pending: Map<PendingRequestKey, PendingRequest>; | ||
| 28 | nextRequestId: number; | ||
| 29 | stopping: boolean; | ||
| 30 | } | ||
| 31 | |||
| 32 | interface JsonRpcError { | ||
| 33 | code?: number; | ||
| 34 | message?: string; | ||
| 35 | } | ||
| 36 | |||
| 37 | interface JsonRpcRequest { | ||
| 38 | id: string | number; | ||
| 39 | method: string; | ||
| 40 | params?: unknown; | ||
| 41 | } | ||
| 42 | |||
| 43 | interface JsonRpcResponse { | ||
| 44 | id: string | number; | ||
| 45 | result?: unknown; | ||
| 46 | error?: JsonRpcError; | ||
| 47 | } | ||
| 48 | |||
| 49 | interface JsonRpcNotification { | ||
| 50 | method: string; | ||
| 51 | params?: unknown; | ||
| 52 | } | ||
| 53 | |||
| 54 | export interface CodexAppServerManagerEvents { | ||
| 55 | event: [event: ProviderEvent]; | ||
| 56 | } | ||
| 57 | |||
| 58 | export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEvents> { | ||
| 59 | private readonly sessions = new Map<string, CodexSessionContext>(); | ||
| 60 | |||
| 61 | async startSession( | ||
| 62 | input: ProviderSessionStartInput, | ||
| 63 | ): Promise<ProviderSession> { | ||
| 64 | const sessionId = randomUUID(); | ||
| 65 | const now = new Date().toISOString(); | ||
| 66 | |||
| 67 | const session: ProviderSession = { | ||
| 68 | sessionId, | ||
| 69 | provider: "codex", | ||
| 70 | status: "connecting", | ||
| 71 | model: input.model, | ||
| 72 | cwd: input.cwd, | ||
| 73 | createdAt: now, | ||
| 74 | updatedAt: now, | ||
| 75 | }; | ||
| 76 | |||
| 77 | const child = spawn("codex", ["app-server"], { | ||
| 78 | cwd: input.cwd, | ||
| 79 | env: process.env, | ||
| 80 | stdio: ["pipe", "pipe", "pipe"], | ||
| 81 | }); | ||
| 82 | const output = readline.createInterface({ input: child.stdout }); | ||
| 83 | |||
| 84 | const context: CodexSessionContext = { | ||
| 85 | session, | ||
| 86 | child, | ||
| 87 | output, | ||
| 88 | pending: new Map(), | ||
| 89 | nextRequestId: 1, | ||
| 90 | stopping: false, | ||
| 91 | }; | ||
| 92 | |||
| 93 | this.sessions.set(sessionId, context); | ||
| 94 | this.attachProcessListeners(context); | ||
| 95 | |||
| 96 | this.emitLifecycleEvent( | ||
| 97 | context, | ||
| 98 | "session/connecting", | ||
| 99 | "Starting codex app-server", | ||
| 100 | ); | ||
| 101 | |||
| 102 | try { | ||
| 103 | await this.sendRequest(context, "initialize", { | ||
| 104 | clientInfo: { | ||
| 105 | name: "codething_desktop", | ||
| 106 | title: "CodeThing Desktop", | ||
| 107 | version: "0.1.0", | ||
| 108 | }, | ||
| 109 | capabilities: { | ||
| 110 | experimentalApi: false, | ||
| 111 | }, | ||
| 112 | }); | ||
| 113 | |||
| 114 | this.writeMessage(context, { method: "initialized" }); | ||
| 115 | |||
| 116 | const threadStart = await this.sendRequest(context, "thread/start", { | ||
| 117 | model: input.model ?? null, | ||
| 118 | cwd: input.cwd ?? null, | ||
| 119 | approvalPolicy: input.approvalPolicy, | ||
| 120 | sandbox: input.sandboxMode, | ||
| 121 | experimentalRawEvents: false, | ||
| 122 | }); | ||
| 123 | |||
| 124 | const threadId = this.readString( | ||
| 125 | this.readObject(threadStart)?.thread, | ||
| 126 | "id", | ||
| 127 | ); | ||
| 128 | if (!threadId) { | ||
| 129 | throw new Error("thread/start response did not include a thread id."); | ||
| 130 | } | ||
| 131 | |||
| 132 | this.updateSession(context, { | ||
| 133 | status: "ready", | ||
| 134 | threadId, | ||
| 135 | }); | ||
| 136 | this.emitLifecycleEvent( | ||
| 137 | context, | ||
| 138 | "session/ready", | ||
| 139 | `Connected to thread ${threadId}`, | ||
| 140 | ); | ||
| 141 | return { ...context.session }; | ||
| 142 | } catch (error) { | ||
| 143 | const message = | ||
| 144 | error instanceof Error | ||
| 145 | ? error.message | ||
| 146 | : "Failed to start Codex session."; | ||
| 147 | this.updateSession(context, { | ||
| 148 | status: "error", | ||
| 149 | lastError: message, | ||
| 150 | }); | ||
| 151 | this.emitErrorEvent(context, "session/startFailed", message); | ||
| 152 | this.stopSession(sessionId); | ||
| 153 | throw new Error(message); | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | async sendTurn( | ||
| 158 | input: ProviderSendTurnInput, | ||
| 159 | ): Promise<ProviderTurnStartResult> { | ||
| 160 | const context = this.requireSession(input.sessionId); | ||
| 161 | if (!context.session.threadId) { | ||
| 162 | throw new Error("Session is missing a thread id."); | ||
| 163 | } | ||
| 164 | |||
| 165 | const response = await this.sendRequest(context, "turn/start", { | ||
| 166 | threadId: context.session.threadId, | ||
| 167 | input: [ | ||
| 168 | { | ||
| 169 | type: "text", | ||
| 170 | text: input.input, | ||
| 171 | text_elements: [], | ||
| 172 | }, | ||
| 173 | ], | ||
| 174 | }); | ||
| 175 | |||
| 176 | const turn = this.readObject(this.readObject(response), "turn"); | ||
| 177 | const turnId = this.readString(turn, "id"); | ||
| 178 | if (!turnId) { | ||
| 179 | throw new Error("turn/start response did not include a turn id."); | ||
| 180 | } | ||
| 181 | |||
| 182 | this.updateSession(context, { | ||
| 183 | status: "running", | ||
| 184 | activeTurnId: turnId, | ||
| 185 | }); | ||
| 186 | |||
| 187 | return { | ||
| 188 | threadId: context.session.threadId, | ||
| 189 | turnId, | ||
| 190 | }; | ||
| 191 | } | ||
| 192 | |||
| 193 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | ||
| 194 | const context = this.requireSession(sessionId); | ||
| 195 | const effectiveTurnId = turnId ?? context.session.activeTurnId; | ||
| 196 | |||
| 197 | if (!effectiveTurnId || !context.session.threadId) { | ||
| 198 | return; | ||
| 199 | } | ||
| 200 | |||
| 201 | await this.sendRequest(context, "turn/interrupt", { | ||
| 202 | threadId: context.session.threadId, | ||
| 203 | turnId: effectiveTurnId, | ||
| 204 | }); | ||
| 205 | } | ||
| 206 | |||
| 207 | stopSession(sessionId: string): void { | ||
| 208 | const context = this.sessions.get(sessionId); | ||
| 209 | if (!context) { | ||
| 210 | return; | ||
| 211 | } | ||
| 212 | |||
| 213 | context.stopping = true; | ||
| 214 | |||
| 215 | for (const pending of context.pending.values()) { | ||
| 216 | clearTimeout(pending.timeout); | ||
| 217 | pending.reject(new Error("Session stopped before request completed.")); | ||
| 218 | } | ||
| 219 | context.pending.clear(); | ||
| 220 | |||
| 221 | context.output.close(); | ||
| 222 | |||
| 223 | if (!context.child.killed) { | ||
| 224 | context.child.kill(); | ||
| 225 | } | ||
| 226 | |||
| 227 | this.updateSession(context, { | ||
| 228 | status: "closed", | ||
| 229 | activeTurnId: undefined, | ||
| 230 | }); | ||
| 231 | this.emitLifecycleEvent(context, "session/closed", "Session stopped"); | ||
| 232 | this.sessions.delete(sessionId); | ||
| 233 | } | ||
| 234 | |||
| 235 | listSessions(): ProviderSession[] { | ||
| 236 | return Array.from(this.sessions.values(), ({ session }) => ({ | ||
| 237 | ...session, | ||
| 238 | })); | ||
| 239 | } | ||
| 240 | |||
| 241 | hasSession(sessionId: string): boolean { | ||
| 242 | return this.sessions.has(sessionId); | ||
| 243 | } | ||
| 244 | |||
| 245 | stopAll(): void { | ||
| 246 | for (const sessionId of this.sessions.keys()) { | ||
| 247 | this.stopSession(sessionId); | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | private requireSession(sessionId: string): CodexSessionContext { | ||
| 252 | const context = this.sessions.get(sessionId); | ||
| 253 | if (!context) { | ||
| 254 | throw new Error(`Unknown session: ${sessionId}`); | ||
| 255 | } | ||
| 256 | |||
| 257 | if (context.session.status === "closed") { | ||
| 258 | throw new Error(`Session is closed: ${sessionId}`); | ||
| 259 | } | ||
| 260 | |||
| 261 | return context; | ||
| 262 | } | ||
| 263 | |||
| 264 | private attachProcessListeners(context: CodexSessionContext): void { | ||
| 265 | context.output.on("line", (line) => { | ||
| 266 | this.handleStdoutLine(context, line); | ||
| 267 | }); | ||
| 268 | |||
| 269 | context.child.stderr.on("data", (chunk: Buffer) => { | ||
| 270 | const message = chunk.toString().trim(); | ||
| 271 | if (!message) { | ||
| 272 | return; | ||
| 273 | } | ||
| 274 | |||
| 275 | this.emitErrorEvent(context, "process/stderr", message); | ||
| 276 | }); | ||
| 277 | |||
| 278 | context.child.on("error", (error) => { | ||
| 279 | const message = error.message || "codex app-server process errored."; | ||
| 280 | this.updateSession(context, { | ||
| 281 | status: "error", | ||
| 282 | lastError: message, | ||
| 283 | }); | ||
| 284 | this.emitErrorEvent(context, "process/error", message); | ||
| 285 | }); | ||
| 286 | |||
| 287 | context.child.on("exit", (code, signal) => { | ||
| 288 | if (context.stopping) { | ||
| 289 | return; | ||
| 290 | } | ||
| 291 | |||
| 292 | const message = `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`; | ||
| 293 | this.updateSession(context, { | ||
| 294 | status: "closed", | ||
| 295 | activeTurnId: undefined, | ||
| 296 | lastError: code === 0 ? context.session.lastError : message, | ||
| 297 | }); | ||
| 298 | this.emitLifecycleEvent(context, "session/exited", message); | ||
| 299 | this.sessions.delete(context.session.sessionId); | ||
| 300 | }); | ||
| 301 | } | ||
| 302 | |||
| 303 | private handleStdoutLine(context: CodexSessionContext, line: string): void { | ||
| 304 | let parsed: unknown; | ||
| 305 | try { | ||
| 306 | parsed = JSON.parse(line); | ||
| 307 | } catch { | ||
| 308 | this.emitErrorEvent( | ||
| 309 | context, | ||
| 310 | "protocol/parseError", | ||
| 311 | "Received invalid JSON from codex app-server.", | ||
| 312 | ); | ||
| 313 | return; | ||
| 314 | } | ||
| 315 | |||
| 316 | if (!parsed || typeof parsed !== "object") { | ||
| 317 | this.emitErrorEvent( | ||
| 318 | context, | ||
| 319 | "protocol/invalidMessage", | ||
| 320 | "Received non-object protocol message.", | ||
| 321 | ); | ||
| 322 | return; | ||
| 323 | } | ||
| 324 | |||
| 325 | if (this.isServerRequest(parsed)) { | ||
| 326 | this.handleServerRequest(context, parsed); | ||
| 327 | return; | ||
| 328 | } | ||
| 329 | |||
| 330 | if (this.isServerNotification(parsed)) { | ||
| 331 | this.handleServerNotification(context, parsed); | ||
| 332 | return; | ||
| 333 | } | ||
| 334 | |||
| 335 | if (this.isResponse(parsed)) { | ||
| 336 | this.handleResponse(context, parsed); | ||
| 337 | return; | ||
| 338 | } | ||
| 339 | |||
| 340 | this.emitErrorEvent( | ||
| 341 | context, | ||
| 342 | "protocol/unrecognizedMessage", | ||
| 343 | "Received protocol message in an unknown shape.", | ||
| 344 | ); | ||
| 345 | } | ||
| 346 | |||
| 347 | private handleServerNotification( | ||
| 348 | context: CodexSessionContext, | ||
| 349 | notification: JsonRpcNotification, | ||
| 350 | ): void { | ||
| 351 | const route = this.readRouteFields(notification.params); | ||
| 352 | const textDelta = | ||
| 353 | notification.method === "item/agentMessage/delta" | ||
| 354 | ? this.readString(notification.params, "delta") | ||
| 355 | : undefined; | ||
| 356 | |||
| 357 | this.emitEvent({ | ||
| 358 | id: randomUUID(), | ||
| 359 | kind: "notification", | ||
| 360 | provider: "codex", | ||
| 361 | sessionId: context.session.sessionId, | ||
| 362 | createdAt: new Date().toISOString(), | ||
| 363 | method: notification.method, | ||
| 364 | threadId: route.threadId, | ||
| 365 | turnId: route.turnId, | ||
| 366 | itemId: route.itemId, | ||
| 367 | textDelta, | ||
| 368 | payload: notification.params, | ||
| 369 | }); | ||
| 370 | |||
| 371 | if (notification.method === "thread/started") { | ||
| 372 | const threadId = this.readString( | ||
| 373 | this.readObject(notification.params)?.thread, | ||
| 374 | "id", | ||
| 375 | ); | ||
| 376 | if (threadId) { | ||
| 377 | this.updateSession(context, { threadId }); | ||
| 378 | } | ||
| 379 | return; | ||
| 380 | } | ||
| 381 | |||
| 382 | if (notification.method === "turn/started") { | ||
| 383 | const turnId = this.readString( | ||
| 384 | this.readObject(notification.params)?.turn, | ||
| 385 | "id", | ||
| 386 | ); | ||
| 387 | this.updateSession(context, { | ||
| 388 | status: "running", | ||
| 389 | activeTurnId: turnId, | ||
| 390 | }); | ||
| 391 | return; | ||
| 392 | } | ||
| 393 | |||
| 394 | if (notification.method === "turn/completed") { | ||
| 395 | const turn = this.readObject(notification.params, "turn"); | ||
| 396 | const status = this.readString(turn, "status"); | ||
| 397 | const errorMessage = this.readString( | ||
| 398 | this.readObject(turn, "error"), | ||
| 399 | "message", | ||
| 400 | ); | ||
| 401 | this.updateSession(context, { | ||
| 402 | status: status === "failed" ? "error" : "ready", | ||
| 403 | activeTurnId: undefined, | ||
| 404 | lastError: errorMessage ?? context.session.lastError, | ||
| 405 | }); | ||
| 406 | return; | ||
| 407 | } | ||
| 408 | |||
| 409 | if (notification.method === "error") { | ||
| 410 | const message = this.readString( | ||
| 411 | this.readObject(notification.params)?.error, | ||
| 412 | "message", | ||
| 413 | ); | ||
| 414 | const willRetry = this.readBoolean(notification.params, "willRetry"); | ||
| 415 | |||
| 416 | this.updateSession(context, { | ||
| 417 | status: willRetry ? "running" : "error", | ||
| 418 | lastError: message ?? context.session.lastError, | ||
| 419 | }); | ||
| 420 | } | ||
| 421 | } | ||
| 422 | |||
| 423 | private handleServerRequest( | ||
| 424 | context: CodexSessionContext, | ||
| 425 | request: JsonRpcRequest, | ||
| 426 | ): void { | ||
| 427 | const route = this.readRouteFields(request.params); | ||
| 428 | this.emitEvent({ | ||
| 429 | id: randomUUID(), | ||
| 430 | kind: "request", | ||
| 431 | provider: "codex", | ||
| 432 | sessionId: context.session.sessionId, | ||
| 433 | createdAt: new Date().toISOString(), | ||
| 434 | method: request.method, | ||
| 435 | threadId: route.threadId, | ||
| 436 | turnId: route.turnId, | ||
| 437 | itemId: route.itemId, | ||
| 438 | payload: request.params, | ||
| 439 | }); | ||
| 440 | |||
| 441 | if (request.method === "item/commandExecution/requestApproval") { | ||
| 442 | this.writeMessage(context, { | ||
| 443 | id: request.id, | ||
| 444 | result: { decision: "decline" }, | ||
| 445 | }); | ||
| 446 | return; | ||
| 447 | } | ||
| 448 | |||
| 449 | if (request.method === "item/fileChange/requestApproval") { | ||
| 450 | this.writeMessage(context, { | ||
| 451 | id: request.id, | ||
| 452 | result: { decision: "decline" }, | ||
| 453 | }); | ||
| 454 | return; | ||
| 455 | } | ||
| 456 | |||
| 457 | if (request.method === "item/tool/requestUserInput") { | ||
| 458 | this.writeMessage(context, { | ||
| 459 | id: request.id, | ||
| 460 | result: { answers: {} }, | ||
| 461 | }); | ||
| 462 | return; | ||
| 463 | } | ||
| 464 | |||
| 465 | this.writeMessage(context, { | ||
| 466 | id: request.id, | ||
| 467 | error: { | ||
| 468 | code: -32601, | ||
| 469 | message: `Unsupported server request: ${request.method}`, | ||
| 470 | }, | ||
| 471 | }); | ||
| 472 | } | ||
| 473 | |||
| 474 | private handleResponse( | ||
| 475 | context: CodexSessionContext, | ||
| 476 | response: JsonRpcResponse, | ||
| 477 | ): void { | ||
| 478 | const key = String(response.id); | ||
| 479 | const pending = context.pending.get(key); | ||
| 480 | if (!pending) { | ||
| 481 | return; | ||
| 482 | } | ||
| 483 | |||
| 484 | clearTimeout(pending.timeout); | ||
| 485 | context.pending.delete(key); | ||
| 486 | |||
| 487 | if (response.error?.message) { | ||
| 488 | pending.reject( | ||
| 489 | new Error( | ||
| 490 | `${pending.method} failed: ${String(response.error.message)}`, | ||
| 491 | ), | ||
| 492 | ); | ||
| 493 | return; | ||
| 494 | } | ||
| 495 | |||
| 496 | pending.resolve(response.result); | ||
| 497 | } | ||
| 498 | |||
| 499 | private async sendRequest<TResponse>( | ||
| 500 | context: CodexSessionContext, | ||
| 501 | method: string, | ||
| 502 | params: unknown, | ||
| 503 | timeoutMs = 20_000, | ||
| 504 | ): Promise<TResponse> { | ||
| 505 | const id = context.nextRequestId; | ||
| 506 | context.nextRequestId += 1; | ||
| 507 | |||
| 508 | const result = await new Promise<unknown>((resolve, reject) => { | ||
| 509 | const timeout = setTimeout(() => { | ||
| 510 | context.pending.delete(String(id)); | ||
| 511 | reject(new Error(`Timed out waiting for ${method}.`)); | ||
| 512 | }, timeoutMs); | ||
| 513 | |||
| 514 | context.pending.set(String(id), { | ||
| 515 | method, | ||
| 516 | timeout, | ||
| 517 | resolve, | ||
| 518 | reject, | ||
| 519 | }); | ||
| 520 | this.writeMessage(context, { | ||
| 521 | method, | ||
| 522 | id, | ||
| 523 | params, | ||
| 524 | }); | ||
| 525 | }); | ||
| 526 | |||
| 527 | return result as TResponse; | ||
| 528 | } | ||
| 529 | |||
| 530 | private writeMessage(context: CodexSessionContext, message: unknown): void { | ||
| 531 | const encoded = JSON.stringify(message); | ||
| 532 | if (!context.child.stdin.writable) { | ||
| 533 | throw new Error("Cannot write to codex app-server stdin."); | ||
| 534 | } | ||
| 535 | |||
| 536 | context.child.stdin.write(`${encoded}\n`); | ||
| 537 | } | ||
| 538 | |||
| 539 | private emitLifecycleEvent( | ||
| 540 | context: CodexSessionContext, | ||
| 541 | method: string, | ||
| 542 | message: string, | ||
| 543 | ): void { | ||
| 544 | this.emitEvent({ | ||
| 545 | id: randomUUID(), | ||
| 546 | kind: "session", | ||
| 547 | provider: "codex", | ||
| 548 | sessionId: context.session.sessionId, | ||
| 549 | createdAt: new Date().toISOString(), | ||
| 550 | method, | ||
| 551 | message, | ||
| 552 | }); | ||
| 553 | } | ||
| 554 | |||
| 555 | private emitErrorEvent( | ||
| 556 | context: CodexSessionContext, | ||
| 557 | method: string, | ||
| 558 | message: string, | ||
| 559 | ): void { | ||
| 560 | this.emitEvent({ | ||
| 561 | id: randomUUID(), | ||
| 562 | kind: "error", | ||
| 563 | provider: "codex", | ||
| 564 | sessionId: context.session.sessionId, | ||
| 565 | createdAt: new Date().toISOString(), | ||
| 566 | method, | ||
| 567 | message, | ||
| 568 | }); | ||
| 569 | } | ||
| 570 | |||
| 571 | private emitEvent(event: ProviderEvent): void { | ||
| 572 | this.emit("event", event); | ||
| 573 | } | ||
| 574 | |||
| 575 | private updateSession( | ||
| 576 | context: CodexSessionContext, | ||
| 577 | updates: Partial<ProviderSession>, | ||
| 578 | ): void { | ||
| 579 | context.session = { | ||
| 580 | ...context.session, | ||
| 581 | ...updates, | ||
| 582 | updatedAt: new Date().toISOString(), | ||
| 583 | }; | ||
| 584 | } | ||
| 585 | |||
| 586 | private isServerRequest(value: unknown): value is JsonRpcRequest { | ||
| 587 | if (!value || typeof value !== "object") { | ||
| 588 | return false; | ||
| 589 | } | ||
| 590 | |||
| 591 | const candidate = value as Record<string, unknown>; | ||
| 592 | return ( | ||
| 593 | typeof candidate.method === "string" && | ||
| 594 | (typeof candidate.id === "string" || typeof candidate.id === "number") | ||
| 595 | ); | ||
| 596 | } | ||
| 597 | |||
| 598 | private isServerNotification(value: unknown): value is JsonRpcNotification { | ||
| 599 | if (!value || typeof value !== "object") { | ||
| 600 | return false; | ||
| 601 | } | ||
| 602 | |||
| 603 | const candidate = value as Record<string, unknown>; | ||
| 604 | return typeof candidate.method === "string" && !("id" in candidate); | ||
| 605 | } | ||
| 606 | |||
| 607 | private isResponse(value: unknown): value is JsonRpcResponse { | ||
| 608 | if (!value || typeof value !== "object") { | ||
| 609 | return false; | ||
| 610 | } | ||
| 611 | |||
| 612 | const candidate = value as Record<string, unknown>; | ||
| 613 | const hasId = | ||
| 614 | typeof candidate.id === "string" || typeof candidate.id === "number"; | ||
| 615 | const hasMethod = typeof candidate.method === "string"; | ||
| 616 | return hasId && !hasMethod; | ||
| 617 | } | ||
| 618 | |||
| 619 | private readRouteFields(params: unknown): { | ||
| 620 | threadId?: string; | ||
| 621 | turnId?: string; | ||
| 622 | itemId?: string; | ||
| 623 | } { | ||
| 624 | const route: { | ||
| 625 | threadId?: string; | ||
| 626 | turnId?: string; | ||
| 627 | itemId?: string; | ||
| 628 | } = {}; | ||
| 629 | |||
| 630 | const threadId = | ||
| 631 | this.readString(params, "threadId") ?? | ||
| 632 | this.readString(this.readObject(params, "thread"), "id"); | ||
| 633 | const turnId = | ||
| 634 | this.readString(params, "turnId") ?? | ||
| 635 | this.readString(this.readObject(params, "turn"), "id"); | ||
| 636 | const itemId = | ||
| 637 | this.readString(params, "itemId") ?? | ||
| 638 | this.readString(this.readObject(params, "item"), "id"); | ||
| 639 | |||
| 640 | if (threadId) { | ||
| 641 | route.threadId = threadId; | ||
| 642 | } | ||
| 643 | |||
| 644 | if (turnId) { | ||
| 645 | route.turnId = turnId; | ||
| 646 | } | ||
| 647 | |||
| 648 | if (itemId) { | ||
| 649 | route.itemId = itemId; | ||
| 650 | } | ||
| 651 | |||
| 652 | return route; | ||
| 653 | } | ||
| 654 | |||
| 655 | private readObject( | ||
| 656 | value: unknown, | ||
| 657 | key?: string, | ||
| 658 | ): Record<string, unknown> | undefined { | ||
| 659 | const target = | ||
| 660 | key === undefined | ||
| 661 | ? value | ||
| 662 | : value && typeof value === "object" | ||
| 663 | ? (value as Record<string, unknown>)[key] | ||
| 664 | : undefined; | ||
| 665 | |||
| 666 | if (!target || typeof target !== "object") { | ||
| 667 | return undefined; | ||
| 668 | } | ||
| 669 | |||
| 670 | return target as Record<string, unknown>; | ||
| 671 | } | ||
| 672 | |||
| 673 | private readString(value: unknown, key: string): string | undefined { | ||
| 674 | if (!value || typeof value !== "object") { | ||
| 675 | return undefined; | ||
| 676 | } | ||
| 677 | |||
| 678 | const candidate = (value as Record<string, unknown>)[key]; | ||
| 679 | return typeof candidate === "string" ? candidate : undefined; | ||
| 680 | } | ||
| 681 | |||
| 682 | private readBoolean(value: unknown, key: string): boolean | undefined { | ||
| 683 | if (!value || typeof value !== "object") { | ||
| 684 | return undefined; | ||
| 685 | } | ||
| 686 | |||
| 687 | const candidate = (value as Record<string, unknown>)[key]; | ||
| 688 | return typeof candidate === "boolean" ? candidate : undefined; | ||
| 689 | } | ||
| 690 | } | ||
apps/desktop/src/main.ts+55-3| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | import { fixPath } from "./fixPath"; | 1 | import { fixPath } from "./fixPath"; |
| 2 | fixPath(); | 2 | fixPath(); |
| 3 | 3 | ||
| 4 | import path from "node:path"; | ||
| 5 | import { spawn } from "node:child_process"; | 4 | import { spawn } from "node:child_process"; |
| 5 | import path from "node:path"; | ||
| 6 | import { BrowserWindow, app, ipcMain, session } from "electron"; | 6 | import { BrowserWindow, app, ipcMain, session } from "electron"; |
| 7 | 7 | ||
| 8 | import { | 8 | import { |
| ... | @@ -16,12 +16,14 @@ import { | ... | @@ -16,12 +16,14 @@ import { |
| 16 | todoIdSchema, | 16 | todoIdSchema, |
| 17 | } from "@acme/contracts"; | 17 | } from "@acme/contracts"; |
| 18 | import { ProcessManager } from "./processManager"; | 18 | import { ProcessManager } from "./processManager"; |
| 19 | import { ProviderManager } from "./providerManager"; | ||
| 19 | import { TodoStore } from "./todoStore"; | 20 | import { TodoStore } from "./todoStore"; |
| 20 | 21 | ||
| 21 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | 22 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 22 | 23 | ||
| 23 | let todoStore: TodoStore; | 24 | let todoStore: TodoStore; |
| 24 | const processManager = new ProcessManager(); | 25 | const processManager = new ProcessManager(); |
| 26 | const providerManager = new ProviderManager(); | ||
| 25 | 27 | ||
| 26 | function createWindow(): BrowserWindow { | 28 | function createWindow(): BrowserWindow { |
| 27 | const window = new BrowserWindow({ | 29 | const window = new BrowserWindow({ |
| ... | @@ -94,6 +96,47 @@ function registerIpcHandlers(): void { | ... | @@ -94,6 +96,47 @@ function registerIpcHandlers(): void { |
| 94 | processManager.write(agentSessionIdSchema.parse(sessionId), String(data)); | 96 | processManager.write(agentSessionIdSchema.parse(sessionId), String(data)); |
| 95 | }, | 97 | }, |
| 96 | ); | 98 | ); |
| 99 | |||
| 100 | // Provider handlers | ||
| 101 | ipcMain.handle( | ||
| 102 | IPC_CHANNELS.providerSessionStart, | ||
| 103 | async (_event, payload: unknown) => { | ||
| 104 | return providerManager.startSession( | ||
| 105 | payload as Parameters<typeof providerManager.startSession>[0], | ||
| 106 | ); | ||
| 107 | }, | ||
| 108 | ); | ||
| 109 | |||
| 110 | ipcMain.handle( | ||
| 111 | IPC_CHANNELS.providerTurnStart, | ||
| 112 | async (_event, payload: unknown) => { | ||
| 113 | return providerManager.sendTurn( | ||
| 114 | payload as Parameters<typeof providerManager.sendTurn>[0], | ||
| 115 | ); | ||
| 116 | }, | ||
| 117 | ); | ||
| 118 | |||
| 119 | ipcMain.handle( | ||
| 120 | IPC_CHANNELS.providerTurnInterrupt, | ||
| 121 | async (_event, payload: unknown) => { | ||
| 122 | await providerManager.interruptTurn( | ||
| 123 | payload as Parameters<typeof providerManager.interruptTurn>[0], | ||
| 124 | ); | ||
| 125 | }, | ||
| 126 | ); | ||
| 127 | |||
| 128 | ipcMain.handle( | ||
| 129 | IPC_CHANNELS.providerSessionStop, | ||
| 130 | async (_event, payload: unknown) => { | ||
| 131 | providerManager.stopSession( | ||
| 132 | payload as Parameters<typeof providerManager.stopSession>[0], | ||
| 133 | ); | ||
| 134 | }, | ||
| 135 | ); | ||
| 136 | |||
| 137 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { | ||
| 138 | return providerManager.listSessions(); | ||
| 139 | }); | ||
| 97 | } | 140 | } |
| 98 | 141 | ||
| 99 | async function runTerminalCommand( | 142 | async function runTerminalCommand( |
| ... | @@ -101,8 +144,8 @@ async function runTerminalCommand( | ... | @@ -101,8 +144,8 @@ async function runTerminalCommand( |
| 101 | ): Promise<TerminalCommandResult> { | 144 | ): Promise<TerminalCommandResult> { |
| 102 | const shellPath = | 145 | const shellPath = |
| 103 | process.platform === "win32" | 146 | process.platform === "win32" |
| 104 | ? process.env.ComSpec ?? "cmd.exe" | 147 | ? (process.env.ComSpec ?? "cmd.exe") |
| 105 | : process.env.SHELL ?? "/bin/sh"; | 148 | : (process.env.SHELL ?? "/bin/sh"); |
| 106 | 149 | ||
| 107 | const args = | 150 | const args = |
| 108 | process.platform === "win32" | 151 | process.platform === "win32" |
| ... | @@ -169,12 +212,20 @@ function setupEventForwarding(window: BrowserWindow): void { | ... | @@ -169,12 +212,20 @@ function setupEventForwarding(window: BrowserWindow): void { |
| 169 | } | 212 | } |
| 170 | }; | 213 | }; |
| 171 | 214 | ||
| 215 | const onProviderEvent = (event: unknown) => { | ||
| 216 | if (!window.isDestroyed()) { | ||
| 217 | window.webContents.send(IPC_CHANNELS.providerEvent, event); | ||
| 218 | } | ||
| 219 | }; | ||
| 220 | |||
| 172 | processManager.on("output", onOutput); | 221 | processManager.on("output", onOutput); |
| 173 | processManager.on("exit", onExit); | 222 | processManager.on("exit", onExit); |
| 223 | providerManager.on("event", onProviderEvent); | ||
| 174 | 224 | ||
| 175 | window.on("closed", () => { | 225 | window.on("closed", () => { |
| 176 | processManager.off("output", onOutput); | 226 | processManager.off("output", onOutput); |
| 177 | processManager.off("exit", onExit); | 227 | processManager.off("exit", onExit); |
| 228 | providerManager.off("event", onProviderEvent); | ||
| 178 | }); | 229 | }); |
| 179 | } | 230 | } |
| 180 | 231 | ||
| ... | @@ -211,6 +262,7 @@ async function bootstrap(): Promise<void> { | ... | @@ -211,6 +262,7 @@ async function bootstrap(): Promise<void> { |
| 211 | 262 | ||
| 212 | app.on("before-quit", () => { | 263 | app.on("before-quit", () => { |
| 213 | processManager.killAll(); | 264 | processManager.killAll(); |
| 265 | providerManager.stopAll(); | ||
| 214 | }); | 266 | }); |
| 215 | 267 | ||
| 216 | app.whenReady().then(() => { | 268 | app.whenReady().then(() => { |
apps/desktop/src/preload.ts+18| ... | @@ -31,6 +31,24 @@ const nativeApi: NativeApi = { | ... | @@ -31,6 +31,24 @@ const nativeApi: NativeApi = { |
| 31 | return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener); | 31 | return () => ipcRenderer.removeListener(IPC_CHANNELS.agentExit, listener); |
| 32 | }, | 32 | }, |
| 33 | }, | 33 | }, |
| 34 | providers: { | ||
| 35 | startSession: (input) => | ||
| 36 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStart, input), | ||
| 37 | sendTurn: (input) => | ||
| 38 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnStart, input), | ||
| 39 | interruptTurn: (input) => | ||
| 40 | ipcRenderer.invoke(IPC_CHANNELS.providerTurnInterrupt, input), | ||
| 41 | stopSession: (input) => | ||
| 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), | ||
| 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), | ||
| 44 | onEvent: (callback) => { | ||
| 45 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => | ||
| 46 | callback(payload as Parameters<typeof callback>[0]); | ||
| 47 | ipcRenderer.on(IPC_CHANNELS.providerEvent, listener); | ||
| 48 | return () => | ||
| 49 | ipcRenderer.removeListener(IPC_CHANNELS.providerEvent, listener); | ||
| 50 | }, | ||
| 51 | }, | ||
| 34 | }; | 52 | }; |
| 35 | 53 | ||
| 36 | contextBridge.exposeInMainWorld("nativeApi", nativeApi); | 54 | contextBridge.exposeInMainWorld("nativeApi", nativeApi); |
apps/desktop/src/providerManager.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import { EventEmitter } from "node:events"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | type ProviderEvent, | ||
| 5 | type ProviderInterruptTurnInput, | ||
| 6 | type ProviderSendTurnInput, | ||
| 7 | type ProviderSession, | ||
| 8 | type ProviderSessionStartInput, | ||
| 9 | type ProviderStopSessionInput, | ||
| 10 | type ProviderTurnStartResult, | ||
| 11 | providerInterruptTurnInputSchema, | ||
| 12 | providerSendTurnInputSchema, | ||
| 13 | providerSessionStartInputSchema, | ||
| 14 | providerStopSessionInputSchema, | ||
| 15 | } from "@acme/contracts"; | ||
| 16 | import { CodexAppServerManager } from "./codexAppServerManager"; | ||
| 17 | |||
| 18 | export interface ProviderManagerEvents { | ||
| 19 | event: [event: ProviderEvent]; | ||
| 20 | } | ||
| 21 | |||
| 22 | export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ||
| 23 | private readonly codex = new CodexAppServerManager(); | ||
| 24 | |||
| 25 | constructor() { | ||
| 26 | super(); | ||
| 27 | this.codex.on("event", (event) => this.emit("event", event)); | ||
| 28 | } | ||
| 29 | |||
| 30 | async startSession(raw: ProviderSessionStartInput): Promise<ProviderSession> { | ||
| 31 | const input = providerSessionStartInputSchema.parse(raw); | ||
| 32 | if (input.provider !== "codex") { | ||
| 33 | throw new Error(`Provider '${input.provider}' is not implemented yet.`); | ||
| 34 | } | ||
| 35 | |||
| 36 | return this.codex.startSession(input); | ||
| 37 | } | ||
| 38 | |||
| 39 | async sendTurn(raw: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | ||
| 40 | const input = providerSendTurnInputSchema.parse(raw); | ||
| 41 | if (!this.codex.hasSession(input.sessionId)) { | ||
| 42 | throw new Error(`Unknown provider session: ${input.sessionId}`); | ||
| 43 | } | ||
| 44 | |||
| 45 | return this.codex.sendTurn(input); | ||
| 46 | } | ||
| 47 | |||
| 48 | async interruptTurn(raw: ProviderInterruptTurnInput): Promise<void> { | ||
| 49 | const input = providerInterruptTurnInputSchema.parse(raw); | ||
| 50 | if (!this.codex.hasSession(input.sessionId)) { | ||
| 51 | throw new Error(`Unknown provider session: ${input.sessionId}`); | ||
| 52 | } | ||
| 53 | |||
| 54 | await this.codex.interruptTurn(input.sessionId, input.turnId); | ||
| 55 | } | ||
| 56 | |||
| 57 | stopSession(raw: ProviderStopSessionInput): void { | ||
| 58 | const input = providerStopSessionInputSchema.parse(raw); | ||
| 59 | this.codex.stopSession(input.sessionId); | ||
| 60 | } | ||
| 61 | |||
| 62 | listSessions(): ProviderSession[] { | ||
| 63 | return this.codex.listSessions(); | ||
| 64 | } | ||
| 65 | |||
| 66 | stopAll(): void { | ||
| 67 | this.codex.stopAll(); | ||
| 68 | } | ||
| 69 | } | ||
apps/renderer/src/App.tsx+552-121| ... | @@ -1,12 +1,55 @@ | ... | @@ -1,12 +1,55 @@ |
| 1 | import { type FormEvent, useMemo, useState } from "react"; | 1 | import { |
| 2 | type FormEvent, | ||
| 3 | type MutableRefObject, | ||
| 4 | useEffect, | ||
| 5 | useMemo, | ||
| 6 | useRef, | ||
| 7 | useState, | ||
| 8 | } from "react"; | ||
| 2 | 9 | ||
| 3 | import type { TerminalCommandResult } from "@acme/contracts"; | 10 | import type { |
| 11 | ProviderEvent, | ||
| 12 | ProviderKind, | ||
| 13 | ProviderSession, | ||
| 14 | } from "@acme/contracts"; | ||
| 4 | 15 | ||
| 5 | interface TerminalEntry { | 16 | type SessionPhase = "disconnected" | "connecting" | "ready" | "running"; |
| 17 | |||
| 18 | interface ChatMessage { | ||
| 6 | id: string; | 19 | id: string; |
| 7 | command: string; | 20 | role: "user" | "assistant"; |
| 8 | result: TerminalCommandResult; | 21 | text: string; |
| 9 | createdAt: string; | 22 | createdAt: string; |
| 23 | streaming: boolean; | ||
| 24 | } | ||
| 25 | |||
| 26 | const PROVIDER_OPTIONS: Array<{ | ||
| 27 | value: ProviderKind; | ||
| 28 | label: string; | ||
| 29 | available: boolean; | ||
| 30 | }> = [ | ||
| 31 | { value: "codex", label: "Codex", available: true }, | ||
| 32 | { value: "claudeCode", label: "Claude Code (soon)", available: false }, | ||
| 33 | ]; | ||
| 34 | |||
| 35 | function readNativeApi() { | ||
| 36 | if (typeof window === "undefined") { | ||
| 37 | return undefined; | ||
| 38 | } | ||
| 39 | |||
| 40 | return window.nativeApi; | ||
| 41 | } | ||
| 42 | |||
| 43 | function asObject(value: unknown): Record<string, unknown> | undefined { | ||
| 44 | if (!value || typeof value !== "object") { | ||
| 45 | return undefined; | ||
| 46 | } | ||
| 47 | |||
| 48 | return value as Record<string, unknown>; | ||
| 49 | } | ||
| 50 | |||
| 51 | function asString(value: unknown): string | undefined { | ||
| 52 | return typeof value === "string" ? value : undefined; | ||
| 10 | } | 53 | } |
| 11 | 54 | ||
| 12 | function formatTimestamp(isoDate: string): string { | 55 | function formatTimestamp(isoDate: string): string { |
| ... | @@ -17,66 +60,364 @@ function formatTimestamp(isoDate: string): string { | ... | @@ -17,66 +60,364 @@ function formatTimestamp(isoDate: string): string { |
| 17 | }).format(new Date(isoDate)); | 60 | }).format(new Date(isoDate)); |
| 18 | } | 61 | } |
| 19 | 62 | ||
| 20 | function readNativeApi() { | 63 | function derivePhase(session: ProviderSession | null): SessionPhase { |
| 21 | if (typeof window === "undefined") { | 64 | if (!session || session.status === "closed") { |
| 22 | return undefined; | 65 | return "disconnected"; |
| 23 | } | 66 | } |
| 24 | 67 | ||
| 25 | return window.nativeApi; | 68 | if (session.status === "connecting") { |
| 69 | return "connecting"; | ||
| 70 | } | ||
| 71 | |||
| 72 | if (session.status === "running") { | ||
| 73 | return "running"; | ||
| 74 | } | ||
| 75 | |||
| 76 | return "ready"; | ||
| 77 | } | ||
| 78 | |||
| 79 | function evolveSession( | ||
| 80 | previous: ProviderSession, | ||
| 81 | event: ProviderEvent, | ||
| 82 | ): ProviderSession { | ||
| 83 | const payload = asObject(event.payload); | ||
| 84 | |||
| 85 | if (event.method === "thread/started") { | ||
| 86 | const thread = asObject(payload?.thread); | ||
| 87 | return { | ||
| 88 | ...previous, | ||
| 89 | threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, | ||
| 90 | updatedAt: event.createdAt, | ||
| 91 | }; | ||
| 92 | } | ||
| 93 | |||
| 94 | if (event.method === "turn/started") { | ||
| 95 | const turn = asObject(payload?.turn); | ||
| 96 | return { | ||
| 97 | ...previous, | ||
| 98 | status: "running", | ||
| 99 | activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, | ||
| 100 | updatedAt: event.createdAt, | ||
| 101 | }; | ||
| 102 | } | ||
| 103 | |||
| 104 | if (event.method === "turn/completed") { | ||
| 105 | const turn = asObject(payload?.turn); | ||
| 106 | const status = asString(turn?.status); | ||
| 107 | const turnError = asObject(turn?.error); | ||
| 108 | return { | ||
| 109 | ...previous, | ||
| 110 | status: status === "failed" ? "error" : "ready", | ||
| 111 | activeTurnId: undefined, | ||
| 112 | lastError: asString(turnError?.message) ?? previous.lastError, | ||
| 113 | updatedAt: event.createdAt, | ||
| 114 | }; | ||
| 115 | } | ||
| 116 | |||
| 117 | if (event.kind === "error") { | ||
| 118 | return { | ||
| 119 | ...previous, | ||
| 120 | status: "error", | ||
| 121 | lastError: event.message ?? previous.lastError, | ||
| 122 | updatedAt: event.createdAt, | ||
| 123 | }; | ||
| 124 | } | ||
| 125 | |||
| 126 | if (event.method === "session/closed" || event.method === "session/exited") { | ||
| 127 | return { | ||
| 128 | ...previous, | ||
| 129 | status: "closed", | ||
| 130 | activeTurnId: undefined, | ||
| 131 | lastError: event.message ?? previous.lastError, | ||
| 132 | updatedAt: event.createdAt, | ||
| 133 | }; | ||
| 134 | } | ||
| 135 | |||
| 136 | return { | ||
| 137 | ...previous, | ||
| 138 | updatedAt: event.createdAt, | ||
| 139 | }; | ||
| 140 | } | ||
| 141 | |||
| 142 | function applyEventToMessages( | ||
| 143 | previous: ChatMessage[], | ||
| 144 | event: ProviderEvent, | ||
| 145 | activeAssistantItemRef: MutableRefObject<string | null>, | ||
| 146 | ): ChatMessage[] { | ||
| 147 | const payload = asObject(event.payload); | ||
| 148 | |||
| 149 | if (event.method === "item/started") { | ||
| 150 | const item = asObject(payload?.item); | ||
| 151 | if (asString(item?.type) !== "agentMessage") { | ||
| 152 | return previous; | ||
| 153 | } | ||
| 154 | |||
| 155 | const itemId = asString(item?.id); | ||
| 156 | if (!itemId) { | ||
| 157 | return previous; | ||
| 158 | } | ||
| 159 | |||
| 160 | activeAssistantItemRef.current = itemId; | ||
| 161 | const seedText = asString(item?.text) ?? ""; | ||
| 162 | const filtered = previous.filter((entry) => entry.id !== itemId); | ||
| 163 | return [ | ||
| 164 | ...filtered, | ||
| 165 | { | ||
| 166 | id: itemId, | ||
| 167 | role: "assistant", | ||
| 168 | text: seedText, | ||
| 169 | createdAt: event.createdAt, | ||
| 170 | streaming: true, | ||
| 171 | }, | ||
| 172 | ]; | ||
| 173 | } | ||
| 174 | |||
| 175 | if (event.method === "item/agentMessage/delta") { | ||
| 176 | const itemId = event.itemId ?? asString(payload?.itemId); | ||
| 177 | const delta = event.textDelta ?? asString(payload?.delta) ?? ""; | ||
| 178 | if (!itemId || !delta) { | ||
| 179 | return previous; | ||
| 180 | } | ||
| 181 | |||
| 182 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | ||
| 183 | if (existingIndex === -1) { | ||
| 184 | activeAssistantItemRef.current = itemId; | ||
| 185 | return [ | ||
| 186 | ...previous, | ||
| 187 | { | ||
| 188 | id: itemId, | ||
| 189 | role: "assistant", | ||
| 190 | text: delta, | ||
| 191 | createdAt: event.createdAt, | ||
| 192 | streaming: true, | ||
| 193 | }, | ||
| 194 | ]; | ||
| 195 | } | ||
| 196 | |||
| 197 | const updated = [...previous]; | ||
| 198 | const existing = updated[existingIndex]; | ||
| 199 | if (!existing) { | ||
| 200 | return previous; | ||
| 201 | } | ||
| 202 | updated[existingIndex] = { | ||
| 203 | ...existing, | ||
| 204 | text: `${existing.text}${delta}`, | ||
| 205 | streaming: true, | ||
| 206 | }; | ||
| 207 | return updated; | ||
| 208 | } | ||
| 209 | |||
| 210 | if (event.method === "item/completed") { | ||
| 211 | const item = asObject(payload?.item); | ||
| 212 | if (asString(item?.type) !== "agentMessage") { | ||
| 213 | return previous; | ||
| 214 | } | ||
| 215 | |||
| 216 | const itemId = asString(item?.id); | ||
| 217 | if (!itemId) { | ||
| 218 | return previous; | ||
| 219 | } | ||
| 220 | |||
| 221 | const fullText = asString(item?.text); | ||
| 222 | const existingIndex = previous.findIndex((entry) => entry.id === itemId); | ||
| 223 | if (existingIndex === -1) { | ||
| 224 | return [ | ||
| 225 | ...previous, | ||
| 226 | { | ||
| 227 | id: itemId, | ||
| 228 | role: "assistant", | ||
| 229 | text: fullText ?? "", | ||
| 230 | createdAt: event.createdAt, | ||
| 231 | streaming: false, | ||
| 232 | }, | ||
| 233 | ]; | ||
| 234 | } | ||
| 235 | |||
| 236 | const updated = [...previous]; | ||
| 237 | const existing = updated[existingIndex]; | ||
| 238 | if (!existing) { | ||
| 239 | return previous; | ||
| 240 | } | ||
| 241 | updated[existingIndex] = { | ||
| 242 | ...existing, | ||
| 243 | text: fullText ?? existing.text, | ||
| 244 | streaming: false, | ||
| 245 | }; | ||
| 246 | |||
| 247 | if (activeAssistantItemRef.current === itemId) { | ||
| 248 | activeAssistantItemRef.current = null; | ||
| 249 | } | ||
| 250 | |||
| 251 | return updated; | ||
| 252 | } | ||
| 253 | |||
| 254 | if (event.method === "turn/completed") { | ||
| 255 | return previous.map((entry) => ({ ...entry, streaming: false })); | ||
| 256 | } | ||
| 257 | |||
| 258 | return previous; | ||
| 26 | } | 259 | } |
| 27 | 260 | ||
| 28 | export default function App() { | 261 | export default function App() { |
| 29 | const api = useMemo(() => readNativeApi(), []); | 262 | const api = useMemo(() => readNativeApi(), []); |
| 30 | const [command, setCommand] = useState(""); | 263 | const [provider, setProvider] = useState<ProviderKind>("codex"); |
| 31 | const [history, setHistory] = useState<TerminalEntry[]>([]); | 264 | const [cwd, setCwd] = useState(""); |
| 32 | const [isRunning, setIsRunning] = useState(false); | 265 | const [model, setModel] = useState("gpt-5.1-codex"); |
| 266 | const [prompt, setPrompt] = useState(""); | ||
| 267 | const [session, setSession] = useState<ProviderSession | null>(null); | ||
| 268 | const [messages, setMessages] = useState<ChatMessage[]>([]); | ||
| 269 | const [events, setEvents] = useState<ProviderEvent[]>([]); | ||
| 270 | const [isConnecting, setIsConnecting] = useState(false); | ||
| 271 | const [isSending, setIsSending] = useState(false); | ||
| 33 | const [error, setError] = useState<string | null>(null); | 272 | const [error, setError] = useState<string | null>(null); |
| 34 | 273 | ||
| 35 | const onSubmit = async (event: FormEvent<HTMLFormElement>) => { | 274 | const currentSessionIdRef = useRef<string | null>(null); |
| 275 | const activeAssistantItemRef = useRef<string | null>(null); | ||
| 276 | |||
| 277 | useEffect(() => { | ||
| 278 | currentSessionIdRef.current = session?.sessionId ?? null; | ||
| 279 | }, [session?.sessionId]); | ||
| 280 | |||
| 281 | useEffect(() => { | ||
| 282 | if (!api) { | ||
| 283 | return; | ||
| 284 | } | ||
| 285 | |||
| 286 | return api.providers.onEvent((event) => { | ||
| 287 | const currentSessionId = currentSessionIdRef.current; | ||
| 288 | if (!currentSessionId || currentSessionId !== event.sessionId) { | ||
| 289 | return; | ||
| 290 | } | ||
| 291 | |||
| 292 | setEvents((previous) => [event, ...previous].slice(0, 200)); | ||
| 293 | setSession((previous) => { | ||
| 294 | if (!previous || previous.sessionId !== event.sessionId) { | ||
| 295 | return previous; | ||
| 296 | } | ||
| 297 | return evolveSession(previous, event); | ||
| 298 | }); | ||
| 299 | setMessages((previous) => | ||
| 300 | applyEventToMessages(previous, event, activeAssistantItemRef), | ||
| 301 | ); | ||
| 302 | |||
| 303 | if (event.kind === "error" && event.message) { | ||
| 304 | setError(event.message); | ||
| 305 | } | ||
| 306 | }); | ||
| 307 | }, [api]); | ||
| 308 | |||
| 309 | const phase = derivePhase(session); | ||
| 310 | |||
| 311 | const onConnect = async (event: FormEvent<HTMLFormElement>) => { | ||
| 36 | event.preventDefault(); | 312 | event.preventDefault(); |
| 313 | if (!api || provider !== "codex" || isConnecting) { | ||
| 314 | return; | ||
| 315 | } | ||
| 316 | |||
| 317 | setError(null); | ||
| 318 | setIsConnecting(true); | ||
| 319 | try { | ||
| 320 | const nextSession = await api.providers.startSession({ | ||
| 321 | provider, | ||
| 322 | cwd: cwd.trim() || undefined, | ||
| 323 | model: model.trim() || undefined, | ||
| 324 | }); | ||
| 325 | currentSessionIdRef.current = nextSession.sessionId; | ||
| 326 | activeAssistantItemRef.current = null; | ||
| 327 | setSession(nextSession); | ||
| 328 | setMessages([]); | ||
| 329 | setEvents([]); | ||
| 330 | } catch (connectError) { | ||
| 331 | setError( | ||
| 332 | connectError instanceof Error | ||
| 333 | ? connectError.message | ||
| 334 | : "Unable to connect to Codex app-server.", | ||
| 335 | ); | ||
| 336 | } finally { | ||
| 337 | setIsConnecting(false); | ||
| 338 | } | ||
| 339 | }; | ||
| 340 | |||
| 341 | const onDisconnect = async () => { | ||
| 342 | if (!api || !session) { | ||
| 343 | return; | ||
| 344 | } | ||
| 37 | 345 | ||
| 38 | if (!api || isRunning) { | 346 | await api.providers.stopSession({ sessionId: session.sessionId }); |
| 347 | currentSessionIdRef.current = null; | ||
| 348 | setSession((previous) => | ||
| 349 | previous | ||
| 350 | ? { | ||
| 351 | ...previous, | ||
| 352 | status: "closed", | ||
| 353 | activeTurnId: undefined, | ||
| 354 | updatedAt: new Date().toISOString(), | ||
| 355 | } | ||
| 356 | : previous, | ||
| 357 | ); | ||
| 358 | }; | ||
| 359 | |||
| 360 | const onSendPrompt = async (event: FormEvent<HTMLFormElement>) => { | ||
| 361 | event.preventDefault(); | ||
| 362 | if (!api || !session || isSending) { | ||
| 39 | return; | 363 | return; |
| 40 | } | 364 | } |
| 41 | 365 | ||
| 42 | const trimmed = command.trim(); | 366 | const trimmed = prompt.trim(); |
| 43 | if (!trimmed) { | 367 | if (!trimmed) { |
| 44 | return; | 368 | return; |
| 45 | } | 369 | } |
| 46 | 370 | ||
| 47 | setError(null); | 371 | setError(null); |
| 48 | setIsRunning(true); | 372 | setMessages((previous) => [ |
| 373 | ...previous, | ||
| 374 | { | ||
| 375 | id: crypto.randomUUID(), | ||
| 376 | role: "user", | ||
| 377 | text: trimmed, | ||
| 378 | createdAt: new Date().toISOString(), | ||
| 379 | streaming: false, | ||
| 380 | }, | ||
| 381 | ]); | ||
| 382 | setPrompt(""); | ||
| 383 | setIsSending(true); | ||
| 49 | 384 | ||
| 50 | try { | 385 | try { |
| 51 | const result = await api.terminal.run({ command: trimmed }); | 386 | await api.providers.sendTurn({ |
| 52 | setHistory((previous) => [ | 387 | sessionId: session.sessionId, |
| 53 | ...previous, | 388 | input: trimmed, |
| 54 | { | 389 | }); |
| 55 | id: crypto.randomUUID(), | 390 | } catch (sendError) { |
| 56 | command: trimmed, | 391 | setError( |
| 57 | result, | 392 | sendError instanceof Error |
| 58 | createdAt: new Date().toISOString(), | 393 | ? sendError.message |
| 59 | }, | 394 | : "Failed to start a Codex turn.", |
| 60 | ]); | 395 | ); |
| 61 | setCommand(""); | ||
| 62 | } catch (runError) { | ||
| 63 | const message = | ||
| 64 | runError instanceof Error | ||
| 65 | ? runError.message | ||
| 66 | : "Could not execute command."; | ||
| 67 | setError(message); | ||
| 68 | } finally { | 396 | } finally { |
| 69 | setIsRunning(false); | 397 | setIsSending(false); |
| 398 | } | ||
| 399 | }; | ||
| 400 | |||
| 401 | const onInterrupt = async () => { | ||
| 402 | if (!api || !session) { | ||
| 403 | return; | ||
| 70 | } | 404 | } |
| 405 | |||
| 406 | await api.providers.interruptTurn({ | ||
| 407 | sessionId: session.sessionId, | ||
| 408 | turnId: session.activeTurnId, | ||
| 409 | }); | ||
| 71 | }; | 410 | }; |
| 72 | 411 | ||
| 73 | if (!api) { | 412 | if (!api) { |
| 74 | return ( | 413 | return ( |
| 75 | <main className="mx-auto flex min-h-screen w-full max-w-4xl items-center justify-center px-8"> | 414 | <main className="mx-auto flex min-h-screen w-full max-w-3xl items-center justify-center px-6 py-10"> |
| 76 | <section className="rounded-3xl border border-amber-200 bg-amber-50 px-6 py-5 text-amber-900 shadow-soft"> | 415 | <section className="surface-card w-full rounded-3xl p-8"> |
| 77 | <h1 className="text-lg font-semibold">Native bridge unavailable</h1> | 416 | <p className="label-chip">CodeThing</p> |
| 78 | <p className="mt-2 text-sm"> | 417 | <h1 className="mt-3 text-3xl">Native bridge unavailable</h1> |
| 79 | Launch this UI through Electron so the preload API is available. | 418 | <p className="mt-3 text-sm text-amber-100/80"> |
| 419 | Launch the renderer through Electron so `window.nativeApi` is | ||
| 420 | exposed by preload. | ||
| 80 | </p> | 421 | </p> |
| 81 | </section> | 422 | </section> |
| 82 | </main> | 423 | </main> |
| ... | @@ -84,102 +425,192 @@ export default function App() { | ... | @@ -84,102 +425,192 @@ export default function App() { |
| 84 | } | 425 | } |
| 85 | 426 | ||
| 86 | return ( | 427 | return ( |
| 87 | <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-6 py-10 text-slate-900 sm:px-10"> | 428 | <main className="mx-auto min-h-screen w-full max-w-7xl px-4 py-6 text-[#f3f0e9] sm:px-8"> |
| 88 | <header className="rounded-3xl border border-slate-800/80 bg-slate-900 p-7 text-slate-100 shadow-soft"> | 429 | <header className="surface-card relative overflow-hidden rounded-3xl px-6 py-7 sm:px-8"> |
| 89 | <p className="text-xs uppercase tracking-[0.18em] text-emerald-300"> | 430 | <div className="grain absolute inset-0 pointer-events-none" /> |
| 90 | Demo Terminal | 431 | <p className="label-chip relative">CodeThing / Provider Shell</p> |
| 91 | </p> | 432 | <h1 className="relative mt-3 text-4xl tracking-tight sm:text-5xl"> |
| 92 | <h1 className="mt-2 font-mono text-3xl font-semibold tracking-tight"> | 433 | Codex-first workspace |
| 93 | Shell Command Runner | ||
| 94 | </h1> | 434 | </h1> |
| 95 | <p className="mt-3 text-sm text-slate-300"> | 435 | <p className="relative mt-3 max-w-3xl text-sm text-[#efe8d7]/82 sm:text-base"> |
| 96 | Runs one command at a time in your shell and prints stdout/stderr. | 436 | Built on the Codex app-server protocol with a provider abstraction in |
| 437 | the IPC layer, so Claude Code can be added without reshaping the UI | ||
| 438 | contract. | ||
| 97 | </p> | 439 | </p> |
| 98 | 440 | ||
| 99 | <form className="mt-6 flex gap-3" onSubmit={onSubmit}> | 441 | <form |
| 100 | <input | 442 | className="relative mt-6 grid gap-3 rounded-2xl border border-[#f6a40f]/30 bg-[#0f2034]/65 p-4 sm:grid-cols-6" |
| 101 | value={command} | 443 | onSubmit={onConnect} |
| 102 | onChange={(event) => setCommand(event.target.value)} | 444 | > |
| 103 | placeholder="e.g. ls -la" | 445 | <label className="sm:col-span-2"> |
| 104 | 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" | 446 | <span className="field-label">Provider</span> |
| 105 | maxLength={4000} | 447 | <select |
| 106 | /> | 448 | className="field-input" |
| 107 | <button | 449 | value={provider} |
| 108 | type="submit" | 450 | onChange={(event) => |
| 109 | className="h-11 rounded-xl bg-emerald-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:bg-emerald-800 disabled:text-emerald-300" | 451 | setProvider(event.target.value as ProviderKind) |
| 110 | disabled={isRunning} | 452 | } |
| 111 | > | 453 | disabled={isConnecting || phase !== "disconnected"} |
| 112 | {isRunning ? "Running..." : "Run"} | 454 | > |
| 113 | </button> | 455 | {PROVIDER_OPTIONS.map((option) => ( |
| 114 | <button | 456 | <option |
| 115 | type="button" | 457 | key={option.value} |
| 116 | className="h-11 rounded-xl border border-slate-600 px-5 text-sm font-medium text-slate-200 transition hover:border-slate-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50" | 458 | value={option.value} |
| 117 | disabled={history.length === 0 || isRunning} | 459 | disabled={!option.available} |
| 118 | onClick={() => setHistory([])} | 460 | > |
| 119 | > | 461 | {option.label} |
| 120 | Clear | 462 | </option> |
| 121 | </button> | 463 | ))} |
| 464 | </select> | ||
| 465 | </label> | ||
| 466 | |||
| 467 | <label className="sm:col-span-2"> | ||
| 468 | <span className="field-label">Working directory</span> | ||
| 469 | <input | ||
| 470 | className="field-input font-mono text-xs sm:text-sm" | ||
| 471 | value={cwd} | ||
| 472 | onChange={(event) => setCwd(event.target.value)} | ||
| 473 | placeholder="/Users/theo/Code/Work/..." | ||
| 474 | disabled={isConnecting || phase !== "disconnected"} | ||
| 475 | /> | ||
| 476 | </label> | ||
| 477 | |||
| 478 | <label className="sm:col-span-1"> | ||
| 479 | <span className="field-label">Model</span> | ||
| 480 | <input | ||
| 481 | className="field-input font-mono text-xs sm:text-sm" | ||
| 482 | value={model} | ||
| 483 | onChange={(event) => setModel(event.target.value)} | ||
| 484 | placeholder="gpt-5.1-codex" | ||
| 485 | disabled={isConnecting || phase !== "disconnected"} | ||
| 486 | /> | ||
| 487 | </label> | ||
| 488 | |||
| 489 | <div className="sm:col-span-1 flex items-end"> | ||
| 490 | {phase === "disconnected" ? ( | ||
| 491 | <button | ||
| 492 | type="submit" | ||
| 493 | className="action-button action-primary w-full" | ||
| 494 | disabled={provider !== "codex" || isConnecting} | ||
| 495 | > | ||
| 496 | {isConnecting ? "Connecting..." : "Connect"} | ||
| 497 | </button> | ||
| 498 | ) : ( | ||
| 499 | <button | ||
| 500 | type="button" | ||
| 501 | className="action-button action-danger w-full" | ||
| 502 | onClick={() => void onDisconnect()} | ||
| 503 | > | ||
| 504 | Disconnect | ||
| 505 | </button> | ||
| 506 | )} | ||
| 507 | </div> | ||
| 122 | </form> | 508 | </form> |
| 509 | |||
| 510 | <div className="relative mt-4 flex flex-wrap gap-2 text-xs"> | ||
| 511 | <span className="status-pill"> | ||
| 512 | phase={phase} provider={session?.provider ?? "none"} | ||
| 513 | </span> | ||
| 514 | <span className="status-pill"> | ||
| 515 | thread={session?.threadId ?? "none"} | ||
| 516 | </span> | ||
| 517 | <span className="status-pill"> | ||
| 518 | turn={session?.activeTurnId ?? "none"} | ||
| 519 | </span> | ||
| 520 | </div> | ||
| 123 | </header> | 521 | </header> |
| 124 | 522 | ||
| 125 | {error ? ( | 523 | {error ? ( |
| 126 | <div className="mt-6 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700"> | 524 | <div className="mt-4 rounded-2xl border border-rose-300/35 bg-rose-900/35 px-4 py-3 text-sm text-rose-100"> |
| 127 | {error} | 525 | {error} |
| 128 | </div> | 526 | </div> |
| 129 | ) : null} | 527 | ) : null} |
| 130 | 528 | ||
| 131 | <section className="mt-6 flex-1 space-y-4"> | 529 | <section className="mt-5 grid gap-4 lg:grid-cols-[2fr_1fr]"> |
| 132 | {history.length === 0 ? ( | 530 | <article className="surface-card rounded-3xl p-4 sm:p-5"> |
| 133 | <div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-4 py-6 text-sm text-slate-500"> | 531 | <h2 className="text-xl">Conversation</h2> |
| 134 | No commands run yet. | 532 | <div className="mt-4 max-h-[52vh] space-y-3 overflow-y-auto pr-1"> |
| 533 | {messages.length === 0 ? ( | ||
| 534 | <div className="rounded-2xl border border-dashed border-[#f6d79e]/30 bg-[#0d1827]/70 px-4 py-5 text-sm text-[#d8d0c3]/75"> | ||
| 535 | No turns yet. Start a session, then send a prompt. | ||
| 536 | </div> | ||
| 537 | ) : null} | ||
| 538 | |||
| 539 | {messages.map((entry) => ( | ||
| 540 | <div | ||
| 541 | key={entry.id} | ||
| 542 | className={`message-card ${ | ||
| 543 | entry.role === "user" ? "message-user" : "message-assistant" | ||
| 544 | }`} | ||
| 545 | > | ||
| 546 | <header className="mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.12em] text-[#d8d0c3]/68"> | ||
| 547 | <span>{entry.role === "user" ? "you" : "codex"}</span> | ||
| 548 | <span>{formatTimestamp(entry.createdAt)}</span> | ||
| 549 | </header> | ||
| 550 | <pre className="whitespace-pre-wrap break-words font-mono text-sm leading-6"> | ||
| 551 | {entry.text || (entry.streaming ? "..." : "(empty response)")} | ||
| 552 | </pre> | ||
| 553 | </div> | ||
| 554 | ))} | ||
| 135 | </div> | 555 | </div> |
| 136 | ) : null} | 556 | |
| 137 | 557 | <form className="mt-4 grid gap-3" onSubmit={onSendPrompt}> | |
| 138 | {history.map((entry) => ( | 558 | <textarea |
| 139 | <article | 559 | className="field-input min-h-28 resize-y font-mono text-sm leading-6" |
| 140 | key={entry.id} | 560 | value={prompt} |
| 141 | className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-950 shadow-soft" | 561 | onChange={(event) => setPrompt(event.target.value)} |
| 142 | > | 562 | placeholder="Ask Codex to inspect, plan, or edit this project..." |
| 143 | <header className="flex items-center justify-between border-b border-slate-800 px-4 py-3 font-mono text-xs text-slate-300"> | 563 | disabled={phase === "disconnected" || isSending} |
| 144 | <p>$ {entry.command}</p> | 564 | /> |
| 145 | <p>{formatTimestamp(entry.createdAt)}</p> | 565 | <div className="flex gap-2"> |
| 146 | </header> | 566 | <button |
| 147 | 567 | type="submit" | |
| 148 | <div className="space-y-3 px-4 py-4 font-mono text-sm"> | 568 | className="action-button action-primary" |
| 149 | {entry.result.stdout ? ( | 569 | disabled={phase === "disconnected" || isSending} |
| 150 | <div> | 570 | > |
| 151 | <p className="mb-1 text-xs uppercase tracking-wide text-emerald-300"> | 571 | {isSending ? "Starting turn..." : "Send"} |
| 152 | stdout | 572 | </button> |
| 153 | </p> | 573 | <button |
| 154 | <pre className="whitespace-pre-wrap break-words text-emerald-100"> | 574 | type="button" |
| 155 | {entry.result.stdout} | 575 | className="action-button action-muted" |
| 156 | </pre> | 576 | disabled={phase !== "running"} |
| 157 | </div> | 577 | onClick={() => void onInterrupt()} |
| 158 | ) : null} | 578 | > |
| 159 | 579 | Interrupt | |
| 160 | {entry.result.stderr ? ( | 580 | </button> |
| 161 | <div> | ||
| 162 | <p className="mb-1 text-xs uppercase tracking-wide text-rose-300"> | ||
| 163 | stderr | ||
| 164 | </p> | ||
| 165 | <pre className="whitespace-pre-wrap break-words text-rose-200"> | ||
| 166 | {entry.result.stderr} | ||
| 167 | </pre> | ||
| 168 | </div> | ||
| 169 | ) : null} | ||
| 170 | |||
| 171 | {!entry.result.stdout && !entry.result.stderr ? ( | ||
| 172 | <p className="text-slate-400">(no output)</p> | ||
| 173 | ) : null} | ||
| 174 | </div> | 581 | </div> |
| 582 | </form> | ||
| 583 | </article> | ||
| 175 | 584 | ||
| 176 | <footer className="border-t border-slate-800 bg-slate-900/60 px-4 py-2 font-mono text-xs text-slate-400"> | 585 | <aside className="surface-card rounded-3xl p-4 sm:p-5"> |
| 177 | exit={entry.result.code ?? "null"} signal= | 586 | <h2 className="text-xl">Protocol stream</h2> |
| 178 | {entry.result.signal ?? "null"} timedOut= | 587 | <p className="mt-2 text-xs text-[#d8d0c3]/76"> |
| 179 | {entry.result.timedOut ? "yes" : "no"} | 588 | Live notifications from the app-server (latest first). |
| 180 | </footer> | 589 | </p> |
| 181 | </article> | 590 | <div className="mt-4 max-h-[58vh] space-y-2 overflow-y-auto pr-1"> |
| 182 | ))} | 591 | {events.length === 0 ? ( |
| 592 | <p className="rounded-xl border border-dashed border-[#f6d79e]/30 bg-[#0d1827]/70 px-3 py-4 text-xs text-[#d8d0c3]/76"> | ||
| 593 | Waiting for events... | ||
| 594 | </p> | ||
| 595 | ) : null} | ||
| 596 | {events.map((entry) => ( | ||
| 597 | <div key={entry.id} className="event-card"> | ||
| 598 | <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[#f8ca64]"> | ||
| 599 | {entry.method} | ||
| 600 | </p> | ||
| 601 | {entry.message ? ( | ||
| 602 | <p className="mt-1 text-xs text-[#efe8d7]/88"> | ||
| 603 | {entry.message} | ||
| 604 | </p> | ||
| 605 | ) : null} | ||
| 606 | <p className="mt-2 text-[11px] text-[#d8d0c3]/66"> | ||
| 607 | {formatTimestamp(entry.createdAt)} | ||
| 608 | {entry.turnId ? ` · ${entry.turnId}` : ""} | ||
| 609 | </p> | ||
| 610 | </div> | ||
| 611 | ))} | ||
| 612 | </div> | ||
| 613 | </aside> | ||
| 183 | </section> | 614 | </section> |
| 184 | </main> | 615 | </main> |
| 185 | ); | 616 | ); |
apps/renderer/src/index.css+153-6| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | @import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,600;9..144,700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); | ||
| 1 | @import "tailwindcss"; | 2 | @import "tailwindcss"; |
| 2 | 3 | ||
| 3 | @theme { | 4 | @theme { |
| ... | @@ -5,14 +6,160 @@ | ... | @@ -5,14 +6,160 @@ |
| 5 | } | 6 | } |
| 6 | 7 | ||
| 7 | :root { | 8 | :root { |
| 8 | color-scheme: light; | 9 | color-scheme: dark; |
| 9 | } | 10 | } |
| 10 | 11 | ||
| 11 | body { | 12 | body { |
| 12 | font-family: "Iowan Old Style", "Georgia", serif; | 13 | font-family: "Fraunces", "Iowan Old Style", serif; |
| 13 | min-height: 100vh; | 14 | min-height: 100vh; |
| 14 | background: | 15 | color: #f3f0e9; |
| 15 | radial-gradient(circle at top left, #d1fae5 0%, transparent 38%), | 16 | background: radial-gradient(circle at 8% 3%, #e69f15 0%, transparent 33%), |
| 16 | radial-gradient(circle at 88% 20%, #fef3c7 0%, transparent 28%), | 17 | radial-gradient(circle at 92% 6%, #2d8ba8 0%, transparent 30%), |
| 17 | linear-gradient(130deg, #f8fafc 0%, #eef6f2 50%, #f8fafc 100%); | 18 | linear-gradient(160deg, #050913 0%, #091320 42%, #090f1a 100%); |
| 19 | } | ||
| 20 | |||
| 21 | pre, | ||
| 22 | code, | ||
| 23 | textarea, | ||
| 24 | input, | ||
| 25 | select { | ||
| 26 | font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; | ||
| 27 | } | ||
| 28 | |||
| 29 | .surface-card { | ||
| 30 | border: 1px solid rgba(246, 215, 158, 0.24); | ||
| 31 | background: linear-gradient( | ||
| 32 | 140deg, | ||
| 33 | rgba(11, 24, 40, 0.9), | ||
| 34 | rgba(9, 16, 29, 0.84) | ||
| 35 | ), radial-gradient(circle at top, rgba(245, 166, 35, 0.15), transparent 45%); | ||
| 36 | box-shadow: 0 28px 70px -45px rgba(0, 0, 0, 0.85), inset 0 1px 0 | ||
| 37 | rgba(255, 255, 255, 0.08); | ||
| 38 | } | ||
| 39 | |||
| 40 | .grain { | ||
| 41 | opacity: 0.18; | ||
| 42 | mix-blend-mode: soft-light; | ||
| 43 | background-image: linear-gradient( | ||
| 44 | 45deg, | ||
| 45 | rgba(255, 255, 255, 0.04) 25%, | ||
| 46 | transparent 25%, | ||
| 47 | transparent 50%, | ||
| 48 | rgba(255, 255, 255, 0.04) 50%, | ||
| 49 | rgba(255, 255, 255, 0.04) 75%, | ||
| 50 | transparent 75%, | ||
| 51 | transparent | ||
| 52 | ); | ||
| 53 | background-size: 4px 4px; | ||
| 54 | } | ||
| 55 | |||
| 56 | .label-chip { | ||
| 57 | display: inline-flex; | ||
| 58 | border-radius: 9999px; | ||
| 59 | border: 1px solid rgba(246, 215, 158, 0.44); | ||
| 60 | background: rgba(23, 38, 56, 0.55); | ||
| 61 | padding: 0.28rem 0.72rem; | ||
| 62 | font-size: 0.67rem; | ||
| 63 | letter-spacing: 0.14em; | ||
| 64 | text-transform: uppercase; | ||
| 65 | color: #f8ca64; | ||
| 66 | } | ||
| 67 | |||
| 68 | .field-label { | ||
| 69 | display: block; | ||
| 70 | margin-bottom: 0.32rem; | ||
| 71 | font-size: 0.67rem; | ||
| 72 | letter-spacing: 0.14em; | ||
| 73 | text-transform: uppercase; | ||
| 74 | color: rgba(246, 233, 201, 0.82); | ||
| 75 | } | ||
| 76 | |||
| 77 | .field-input { | ||
| 78 | width: 100%; | ||
| 79 | border-radius: 0.8rem; | ||
| 80 | border: 1px solid rgba(246, 215, 158, 0.3); | ||
| 81 | background: rgba(5, 12, 22, 0.85); | ||
| 82 | padding: 0.65rem 0.75rem; | ||
| 83 | color: #f7f1e6; | ||
| 84 | transition: border-color 140ms ease, box-shadow 140ms ease; | ||
| 85 | } | ||
| 86 | |||
| 87 | .field-input:focus { | ||
| 88 | outline: none; | ||
| 89 | border-color: rgba(250, 206, 99, 0.75); | ||
| 90 | box-shadow: 0 0 0 2px rgba(250, 206, 99, 0.18); | ||
| 91 | } | ||
| 92 | |||
| 93 | .action-button { | ||
| 94 | border-radius: 0.8rem; | ||
| 95 | padding: 0.62rem 0.9rem; | ||
| 96 | font-size: 0.8rem; | ||
| 97 | font-weight: 600; | ||
| 98 | letter-spacing: 0.08em; | ||
| 99 | text-transform: uppercase; | ||
| 100 | transition: filter 140ms ease, transform 140ms ease; | ||
| 101 | } | ||
| 102 | |||
| 103 | .action-button:disabled { | ||
| 104 | cursor: not-allowed; | ||
| 105 | opacity: 0.45; | ||
| 106 | } | ||
| 107 | |||
| 108 | .action-button:not(:disabled):hover { | ||
| 109 | filter: brightness(1.06); | ||
| 110 | transform: translateY(-1px); | ||
| 111 | } | ||
| 112 | |||
| 113 | .action-primary { | ||
| 114 | border: 1px solid rgba(250, 206, 99, 0.6); | ||
| 115 | background: linear-gradient(140deg, #f8c861, #e59f0d); | ||
| 116 | color: #1f2530; | ||
| 117 | } | ||
| 118 | |||
| 119 | .action-danger { | ||
| 120 | border: 1px solid rgba(252, 148, 124, 0.44); | ||
| 121 | background: linear-gradient(140deg, #8f2c2b, #bf4738); | ||
| 122 | color: #fff3ef; | ||
| 123 | } | ||
| 124 | |||
| 125 | .action-muted { | ||
| 126 | border: 1px solid rgba(149, 181, 205, 0.4); | ||
| 127 | background: rgba(13, 31, 49, 0.85); | ||
| 128 | color: #dfecfb; | ||
| 129 | } | ||
| 130 | |||
| 131 | .status-pill { | ||
| 132 | border-radius: 9999px; | ||
| 133 | border: 1px solid rgba(246, 215, 158, 0.29); | ||
| 134 | background: rgba(9, 20, 33, 0.9); | ||
| 135 | padding: 0.27rem 0.64rem; | ||
| 136 | font-family: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; | ||
| 137 | color: rgba(248, 235, 206, 0.86); | ||
| 138 | } | ||
| 139 | |||
| 140 | .message-card { | ||
| 141 | border-radius: 1rem; | ||
| 142 | border: 1px solid rgba(246, 215, 158, 0.25); | ||
| 143 | background: rgba(8, 16, 27, 0.82); | ||
| 144 | padding: 0.9rem; | ||
| 145 | } | ||
| 146 | |||
| 147 | .message-user { | ||
| 148 | border-color: rgba(111, 198, 238, 0.38); | ||
| 149 | background: linear-gradient( | ||
| 150 | 160deg, | ||
| 151 | rgba(14, 44, 64, 0.92), | ||
| 152 | rgba(8, 21, 36, 0.92) | ||
| 153 | ); | ||
| 154 | } | ||
| 155 | |||
| 156 | .message-assistant { | ||
| 157 | border-color: rgba(250, 206, 99, 0.34); | ||
| 158 | } | ||
| 159 | |||
| 160 | .event-card { | ||
| 161 | border-radius: 0.85rem; | ||
| 162 | border: 1px solid rgba(246, 215, 158, 0.2); | ||
| 163 | background: rgba(8, 16, 27, 0.76); | ||
| 164 | padding: 0.62rem 0.72rem; | ||
| 18 | } | 165 | } |
packages/contracts/src/index.ts+1| ... | @@ -2,3 +2,4 @@ export * from "./todo"; | ... | @@ -2,3 +2,4 @@ export * from "./todo"; |
| 2 | export * from "./ipc"; | 2 | export * from "./ipc"; |
| 3 | export * from "./agent"; | 3 | export * from "./agent"; |
| 4 | export * from "./terminal"; | 4 | export * from "./terminal"; |
| 5 | export * from "./provider"; |
packages/contracts/src/ipc.ts+27| ... | @@ -1,4 +1,13 @@ | ... | @@ -1,4 +1,13 @@ |
| 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | 1 | import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | import type { | ||
| 3 | ProviderEvent, | ||
| 4 | ProviderInterruptTurnInput, | ||
| 5 | ProviderSendTurnInput, | ||
| 6 | ProviderSession, | ||
| 7 | ProviderSessionStartInput, | ||
| 8 | ProviderStopSessionInput, | ||
| 9 | ProviderTurnStartResult, | ||
| 10 | } from "./provider"; | ||
| 2 | import type { TerminalCommandInput, TerminalCommandResult } from "./terminal"; | 11 | import type { TerminalCommandInput, TerminalCommandResult } from "./terminal"; |
| 3 | import type { NewTodoInput, Todo } from "./todo"; | 12 | import type { NewTodoInput, Todo } from "./todo"; |
| 4 | 13 | ||
| ... | @@ -13,6 +22,12 @@ export const IPC_CHANNELS = { | ... | @@ -13,6 +22,12 @@ export const IPC_CHANNELS = { |
| 13 | agentWrite: "agent:write", | 22 | agentWrite: "agent:write", |
| 14 | agentOutput: "agent:output", | 23 | agentOutput: "agent:output", |
| 15 | agentExit: "agent:exit", | 24 | agentExit: "agent:exit", |
| 25 | providerSessionStart: "provider:session:start", | ||
| 26 | providerTurnStart: "provider:turn:start", | ||
| 27 | providerTurnInterrupt: "provider:turn:interrupt", | ||
| 28 | providerSessionStop: "provider:session:stop", | ||
| 29 | providerSessionList: "provider:session:list", | ||
| 30 | providerEvent: "provider:event", | ||
| 16 | } as const; | 31 | } as const; |
| 17 | 32 | ||
| 18 | export interface NativeApi { | 33 | export interface NativeApi { |
| ... | @@ -32,4 +47,16 @@ export interface NativeApi { | ... | @@ -32,4 +47,16 @@ export interface NativeApi { |
| 32 | onOutput: (callback: (chunk: OutputChunk) => void) => () => void; | 47 | onOutput: (callback: (chunk: OutputChunk) => void) => () => void; |
| 33 | onExit: (callback: (exit: AgentExit) => void) => () => void; | 48 | onExit: (callback: (exit: AgentExit) => void) => () => void; |
| 34 | }; | 49 | }; |
| 50 | providers: { | ||
| 51 | startSession: ( | ||
| 52 | input: ProviderSessionStartInput, | ||
| 53 | ) => Promise<ProviderSession>; | ||
| 54 | sendTurn: ( | ||
| 55 | input: ProviderSendTurnInput, | ||
| 56 | ) => Promise<ProviderTurnStartResult>; | ||
| 57 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | ||
| 58 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | ||
| 59 | listSessions: () => Promise<ProviderSession[]>; | ||
| 60 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | ||
| 61 | }; | ||
| 35 | } | 62 | } |
packages/contracts/src/provider.test.ts created+44| ... | @@ -0,0 +1,44 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | providerEventSchema, | ||
| 5 | providerSendTurnInputSchema, | ||
| 6 | providerSessionStartInputSchema, | ||
| 7 | } from "./provider"; | ||
| 8 | |||
| 9 | describe("providerSessionStartInputSchema", () => { | ||
| 10 | it("defaults to codex with safe policies", () => { | ||
| 11 | const parsed = providerSessionStartInputSchema.parse({}); | ||
| 12 | expect(parsed.provider).toBe("codex"); | ||
| 13 | expect(parsed.approvalPolicy).toBe("never"); | ||
| 14 | expect(parsed.sandboxMode).toBe("workspace-write"); | ||
| 15 | }); | ||
| 16 | }); | ||
| 17 | |||
| 18 | describe("providerSendTurnInputSchema", () => { | ||
| 19 | it("trims input text", () => { | ||
| 20 | const parsed = providerSendTurnInputSchema.parse({ | ||
| 21 | sessionId: "sess_1", | ||
| 22 | input: " summarize this repo ", | ||
| 23 | }); | ||
| 24 | expect(parsed.input).toBe("summarize this repo"); | ||
| 25 | }); | ||
| 26 | }); | ||
| 27 | |||
| 28 | describe("providerEventSchema", () => { | ||
| 29 | it("accepts notification events with routing metadata", () => { | ||
| 30 | const parsed = providerEventSchema.parse({ | ||
| 31 | id: "evt_1", | ||
| 32 | kind: "notification", | ||
| 33 | provider: "codex", | ||
| 34 | sessionId: "sess_1", | ||
| 35 | createdAt: "2026-01-01T00:00:00.000Z", | ||
| 36 | method: "item/agentMessage/delta", | ||
| 37 | threadId: "thr_1", | ||
| 38 | turnId: "turn_1", | ||
| 39 | itemId: "item_1", | ||
| 40 | textDelta: "hi", | ||
| 41 | }); | ||
| 42 | expect(parsed.method).toBe("item/agentMessage/delta"); | ||
| 43 | }); | ||
| 44 | }); | ||
packages/contracts/src/provider.ts created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | import { z } from "zod"; | ||
| 2 | |||
| 3 | export const providerKindSchema = z.enum(["codex", "claudeCode"]); | ||
| 4 | |||
| 5 | export const providerApprovalPolicySchema = z.enum([ | ||
| 6 | "untrusted", | ||
| 7 | "on-failure", | ||
| 8 | "on-request", | ||
| 9 | "never", | ||
| 10 | ]); | ||
| 11 | |||
| 12 | export const providerSandboxModeSchema = z.enum([ | ||
| 13 | "read-only", | ||
| 14 | "workspace-write", | ||
| 15 | "danger-full-access", | ||
| 16 | ]); | ||
| 17 | |||
| 18 | export const providerSessionStatusSchema = z.enum([ | ||
| 19 | "connecting", | ||
| 20 | "ready", | ||
| 21 | "running", | ||
| 22 | "error", | ||
| 23 | "closed", | ||
| 24 | ]); | ||
| 25 | |||
| 26 | export const providerSessionSchema = z.object({ | ||
| 27 | sessionId: z.string().min(1), | ||
| 28 | provider: providerKindSchema, | ||
| 29 | status: providerSessionStatusSchema, | ||
| 30 | cwd: z.string().min(1).optional(), | ||
| 31 | model: z.string().min(1).optional(), | ||
| 32 | threadId: z.string().min(1).optional(), | ||
| 33 | activeTurnId: z.string().min(1).optional(), | ||
| 34 | createdAt: z.string().datetime(), | ||
| 35 | updatedAt: z.string().datetime(), | ||
| 36 | lastError: z.string().min(1).optional(), | ||
| 37 | }); | ||
| 38 | |||
| 39 | export const providerSessionStartInputSchema = z.object({ | ||
| 40 | provider: providerKindSchema.default("codex"), | ||
| 41 | cwd: z.string().min(1).optional(), | ||
| 42 | model: z.string().trim().min(1).optional(), | ||
| 43 | approvalPolicy: providerApprovalPolicySchema.default("never"), | ||
| 44 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), | ||
| 45 | }); | ||
| 46 | |||
| 47 | export const providerSendTurnInputSchema = z.object({ | ||
| 48 | sessionId: z.string().min(1), | ||
| 49 | input: z.string().trim().min(1).max(120_000), | ||
| 50 | }); | ||
| 51 | |||
| 52 | export const providerTurnStartResultSchema = z.object({ | ||
| 53 | threadId: z.string().min(1), | ||
| 54 | turnId: z.string().min(1), | ||
| 55 | }); | ||
| 56 | |||
| 57 | export const providerInterruptTurnInputSchema = z.object({ | ||
| 58 | sessionId: z.string().min(1), | ||
| 59 | turnId: z.string().min(1).optional(), | ||
| 60 | }); | ||
| 61 | |||
| 62 | export const providerStopSessionInputSchema = z.object({ | ||
| 63 | sessionId: z.string().min(1), | ||
| 64 | }); | ||
| 65 | |||
| 66 | export const providerEventKindSchema = z.enum([ | ||
| 67 | "session", | ||
| 68 | "notification", | ||
| 69 | "request", | ||
| 70 | "error", | ||
| 71 | ]); | ||
| 72 | |||
| 73 | export const providerEventSchema = z.object({ | ||
| 74 | id: z.string().min(1), | ||
| 75 | kind: providerEventKindSchema, | ||
| 76 | provider: providerKindSchema, | ||
| 77 | sessionId: z.string().min(1), | ||
| 78 | createdAt: z.string().datetime(), | ||
| 79 | method: z.string().min(1), | ||
| 80 | message: z.string().min(1).optional(), | ||
| 81 | threadId: z.string().min(1).optional(), | ||
| 82 | turnId: z.string().min(1).optional(), | ||
| 83 | itemId: z.string().min(1).optional(), | ||
| 84 | textDelta: z.string().optional(), | ||
| 85 | payload: z.unknown().optional(), | ||
| 86 | }); | ||
| 87 | |||
| 88 | export type ProviderKind = z.infer<typeof providerKindSchema>; | ||
| 89 | export type ProviderApprovalPolicy = z.infer< | ||
| 90 | typeof providerApprovalPolicySchema | ||
| 91 | >; | ||
| 92 | export type ProviderSandboxMode = z.infer<typeof providerSandboxModeSchema>; | ||
| 93 | export type ProviderSessionStatus = z.infer<typeof providerSessionStatusSchema>; | ||
| 94 | export type ProviderSession = z.infer<typeof providerSessionSchema>; | ||
| 95 | export type ProviderSessionStartInput = z.input< | ||
| 96 | typeof providerSessionStartInputSchema | ||
| 97 | >; | ||
| 98 | export type ProviderSendTurnInput = z.input<typeof providerSendTurnInputSchema>; | ||
| 99 | export type ProviderTurnStartResult = z.infer< | ||
| 100 | typeof providerTurnStartResultSchema | ||
| 101 | >; | ||
| 102 | export type ProviderInterruptTurnInput = z.input< | ||
| 103 | typeof providerInterruptTurnInputSchema | ||
| 104 | >; | ||
| 105 | export type ProviderStopSessionInput = z.input< | ||
| 106 | typeof providerStopSessionInputSchema | ||
| 107 | >; | ||
| 108 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | ||
| 109 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | ||