| author | |
| committer | |
| log | fdb654de45d8bcac6bfee3d79199fb9bdcde844d |
| tree | dde4839a5ee64e7941ba836e4b4677d28bfb7a13 |
| parent | 733c4633bfafd9b96208f8f39480b5f7ccb49677 |
| signature | Commit is signed but in an unrecognized format. |
16 files changed, 695 insertions(+), 458 deletions(-)
.agents/skills/btca-cli/SKILL.md created+20| ... | @@ -0,0 +1,20 @@ | ||
| 1 | --- | ||
| 2 | name: btca-cli | ||
| 3 | description: btca is a cli for getting information from other git repositories by querying them with natural language. Spawns an external subagent to explore external codebases (i.e. a github repo) and answer questions about them. Great when integrating complex libraries, frameworks, or tools. | ||
| 4 | --- | ||
| 5 | |||
| 6 | btca is a cli for asking questions about various folders, primarily git repositories. It's built to give you the exact information you need without bloating your context window. btca uses agents under the hood to answer your question, so prompt it the way you would prompt a tool similar to yourself. | ||
| 7 | |||
| 8 | For example, assuming you have a resource named `codex`, you can ask questions like: | ||
| 9 | |||
| 10 | ```bash | ||
| 11 | btca ask -r codex -q "What is the return format for Codex App Server responses? Are there any edge cases worth knowing?" | ||
| 12 | ``` | ||
| 13 | |||
| 14 | Your prompt may include specific names for resources to use, if they do then you should try those first. If you need to check available resources, you can use the `btca resources` command. | ||
| 15 | |||
| 16 | New resources can be created if the ones you need do not yet exist. | ||
| 17 | |||
| 18 | ```bash | ||
| 19 | btca add -n svelte-dev https://github.com/sveltejs/svelte.dev | ||
| 20 | ``` | ||
.agents/skills/btca-cli/agents/openai.yaml created+3| ... | @@ -0,0 +1,3 @@ | ||
| 1 | interface: | ||
| 2 | display_name: "BTCA CLI" | ||
| 3 | short_description: "Help with BTCA CLI setup and usage workflows" | ||
apps/desktop/src/codexAppServerManager.test.ts+22-1| ... | @@ -1,6 +1,9 @@ | ... | @@ -1,6 +1,9 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { classifyCodexStderrLine } from "./codexAppServerManager"; | 3 | import { |
| 4 | classifyCodexStderrLine, | ||
| 5 | normalizeCodexModelSlug, | ||
| 6 | } from "./codexAppServerManager"; | ||
| 4 | 7 | ||
| 5 | describe("classifyCodexStderrLine", () => { | 8 | describe("classifyCodexStderrLine", () => { |
| 6 | it("ignores empty lines", () => { | 9 | it("ignores empty lines", () => { |
| ... | @@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => { | ... | @@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => { |
| 34 | }); | 37 | }); |
| 35 | }); | 38 | }); |
| 36 | }); | 39 | }); |
| 40 | |||
| 41 | describe("normalizeCodexModelSlug", () => { | ||
| 42 | it("maps 5.3 aliases to gpt-5.3-codex", () => { | ||
| 43 | expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); | ||
| 44 | expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | ||
| 45 | }); | ||
| 46 | |||
| 47 | it("prefers codex id when model differs", () => { | ||
| 48 | expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe( | ||
| 49 | "gpt-5.3-codex", | ||
| 50 | ); | ||
| 51 | }); | ||
| 52 | |||
| 53 | it("keeps non-aliased models as-is", () => { | ||
| 54 | expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 55 | expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); | ||
| 56 | }); | ||
| 57 | }); |
apps/desktop/src/codexAppServerManager.ts+37-236| ... | @@ -5,7 +5,6 @@ import readline from "node:readline"; | ... | @@ -5,7 +5,6 @@ import readline from "node:readline"; |
| 5 | 5 | ||
| 6 | import type { | 6 | import type { |
| 7 | ProviderEvent, | 7 | ProviderEvent, |
| 8 | ProviderModel, | ||
| 9 | ProviderSendTurnInput, | 8 | ProviderSendTurnInput, |
| 10 | ProviderSession, | 9 | ProviderSession, |
| 11 | ProviderSessionStartInput, | 10 | ProviderSessionStartInput, |
| ... | @@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ | ... | @@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ |
| 60 | "state db missing rollout path for thread", | 59 | "state db missing rollout path for thread", |
| 61 | "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", | 60 | "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", |
| 62 | ]; | 61 | ]; |
| 62 | const MODEL_SLUG_ALIASES: Record<string, string> = { | ||
| 63 | "5.3": "gpt-5.3-codex", | ||
| 64 | "gpt-5.3": "gpt-5.3-codex", | ||
| 65 | }; | ||
| 66 | |||
| 67 | export function normalizeCodexModelSlug( | ||
| 68 | model: string | undefined | null, | ||
| 69 | preferredId?: string, | ||
| 70 | ): string | undefined { | ||
| 71 | if (typeof model !== "string") { | ||
| 72 | return undefined; | ||
| 73 | } | ||
| 74 | |||
| 75 | const trimmed = model.trim(); | ||
| 76 | if (!trimmed) { | ||
| 77 | return undefined; | ||
| 78 | } | ||
| 79 | |||
| 80 | if ( | ||
| 81 | preferredId && | ||
| 82 | preferredId.endsWith("-codex") && | ||
| 83 | preferredId !== trimmed | ||
| 84 | ) { | ||
| 85 | return preferredId; | ||
| 86 | } | ||
| 87 | |||
| 88 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | ||
| 89 | } | ||
| 63 | 90 | ||
| 64 | export function classifyCodexStderrLine( | 91 | export function classifyCodexStderrLine( |
| 65 | rawLine: string, | 92 | rawLine: string, |
| ... | @@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 104 | sessionId, | 131 | sessionId, |
| 105 | provider: "codex", | 132 | provider: "codex", |
| 106 | status: "connecting", | 133 | status: "connecting", |
| 107 | model: input.model, | 134 | model: normalizeCodexModelSlug(input.model), |
| 108 | cwd: input.cwd, | 135 | cwd: input.cwd, |
| 109 | createdAt: now, | 136 | createdAt: now, |
| 110 | updatedAt: now, | 137 | updatedAt: now, |
| ... | @@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 150 | this.writeMessage(context, { method: "initialized" }); | 177 | this.writeMessage(context, { method: "initialized" }); |
| 151 | 178 | ||
| 152 | const threadStart = await this.sendRequest(context, "thread/start", { | 179 | const threadStart = await this.sendRequest(context, "thread/start", { |
| 153 | model: input.model ?? null, | 180 | model: null, |
| 154 | cwd: input.cwd ?? null, | 181 | cwd: input.cwd ?? null, |
| 155 | approvalPolicy: input.approvalPolicy, | 182 | approvalPolicy: input.approvalPolicy, |
| 156 | sandbox: input.sandboxMode, | 183 | sandbox: input.sandboxMode, |
| ... | @@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 202 | threadId: string; | 229 | threadId: string; |
| 203 | input: Array<{ type: "text"; text: string; text_elements: [] }>; | 230 | input: Array<{ type: "text"; text: string; text_elements: [] }>; |
| 204 | model?: string; | 231 | model?: string; |
| 232 | effort?: string; | ||
| 205 | } = { | 233 | } = { |
| 206 | threadId: context.session.threadId, | 234 | threadId: context.session.threadId, |
| 207 | input: [ | 235 | input: [ |
| ... | @@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 212 | }, | 240 | }, |
| 213 | ], | 241 | ], |
| 214 | }; | 242 | }; |
| 215 | if (input.model) { | 243 | const normalizedModel = normalizeCodexModelSlug(input.model); |
| 216 | turnStartParams.model = input.model; | 244 | if (normalizedModel) { |
| 245 | turnStartParams.model = normalizedModel; | ||
| 246 | } | ||
| 247 | if (input.effort) { | ||
| 248 | turnStartParams.effort = input.effort; | ||
| 217 | } | 249 | } |
| 218 | 250 | ||
| 219 | const response = await this.sendRequest( | 251 | const response = await this.sendRequest( |
| ... | @@ -239,177 +271,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -239,177 +271,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 239 | }; | 271 | }; |
| 240 | } | 272 | } |
| 241 | 273 | ||
| 242 | async listModels(cwd?: string): Promise<ProviderModel[]> { | ||
| 243 | const child = spawn("codex", ["app-server"], { | ||
| 244 | cwd, | ||
| 245 | env: process.env, | ||
| 246 | stdio: ["pipe", "pipe", "pipe"], | ||
| 247 | }); | ||
| 248 | const output = readline.createInterface({ input: child.stdout }); | ||
| 249 | const pending = new Map<PendingRequestKey, PendingRequest>(); | ||
| 250 | let nextRequestId = 1; | ||
| 251 | let exited = false; | ||
| 252 | let exitError: Error | null = null; | ||
| 253 | |||
| 254 | const cleanup = () => { | ||
| 255 | for (const request of pending.values()) { | ||
| 256 | clearTimeout(request.timeout); | ||
| 257 | } | ||
| 258 | pending.clear(); | ||
| 259 | output.close(); | ||
| 260 | if (!child.killed) { | ||
| 261 | child.kill(); | ||
| 262 | } | ||
| 263 | }; | ||
| 264 | |||
| 265 | const writeMessage = (message: unknown) => { | ||
| 266 | if (!child.stdin.writable) { | ||
| 267 | throw new Error("Cannot write to codex app-server stdin."); | ||
| 268 | } | ||
| 269 | child.stdin.write(`${JSON.stringify(message)}\n`); | ||
| 270 | }; | ||
| 271 | |||
| 272 | const sendRequest = async <TResponse>( | ||
| 273 | method: string, | ||
| 274 | params: unknown, | ||
| 275 | timeoutMs = 20_000, | ||
| 276 | ): Promise<TResponse> => { | ||
| 277 | if (exited) { | ||
| 278 | throw ( | ||
| 279 | exitError ?? | ||
| 280 | new Error("codex app-server exited before request could be sent.") | ||
| 281 | ); | ||
| 282 | } | ||
| 283 | |||
| 284 | const id = nextRequestId; | ||
| 285 | nextRequestId += 1; | ||
| 286 | |||
| 287 | const result = await new Promise<unknown>((resolve, reject) => { | ||
| 288 | const timeout = setTimeout(() => { | ||
| 289 | pending.delete(String(id)); | ||
| 290 | reject(new Error(`Timed out waiting for ${method}.`)); | ||
| 291 | }, timeoutMs); | ||
| 292 | |||
| 293 | pending.set(String(id), { | ||
| 294 | timeout, | ||
| 295 | resolve, | ||
| 296 | reject, | ||
| 297 | method, | ||
| 298 | }); | ||
| 299 | writeMessage({ id, method, params }); | ||
| 300 | }); | ||
| 301 | |||
| 302 | return result as TResponse; | ||
| 303 | }; | ||
| 304 | |||
| 305 | output.on("line", (line) => { | ||
| 306 | let parsed: unknown; | ||
| 307 | try { | ||
| 308 | parsed = JSON.parse(line); | ||
| 309 | } catch { | ||
| 310 | return; | ||
| 311 | } | ||
| 312 | |||
| 313 | if (this.isResponse(parsed)) { | ||
| 314 | const key = String(parsed.id); | ||
| 315 | const request = pending.get(key); | ||
| 316 | if (!request) { | ||
| 317 | return; | ||
| 318 | } | ||
| 319 | |||
| 320 | clearTimeout(request.timeout); | ||
| 321 | pending.delete(key); | ||
| 322 | |||
| 323 | if (parsed.error?.message) { | ||
| 324 | request.reject( | ||
| 325 | new Error( | ||
| 326 | `${request.method} failed: ${String(parsed.error.message)}`, | ||
| 327 | ), | ||
| 328 | ); | ||
| 329 | return; | ||
| 330 | } | ||
| 331 | |||
| 332 | request.resolve(parsed.result); | ||
| 333 | return; | ||
| 334 | } | ||
| 335 | |||
| 336 | if (this.isServerRequest(parsed)) { | ||
| 337 | writeMessage({ | ||
| 338 | id: parsed.id, | ||
| 339 | error: { | ||
| 340 | code: -32601, | ||
| 341 | message: `Unsupported server request: ${parsed.method}`, | ||
| 342 | }, | ||
| 343 | }); | ||
| 344 | } | ||
| 345 | }); | ||
| 346 | |||
| 347 | child.on("error", (error) => { | ||
| 348 | exited = true; | ||
| 349 | exitError = error; | ||
| 350 | for (const request of pending.values()) { | ||
| 351 | clearTimeout(request.timeout); | ||
| 352 | request.reject(error); | ||
| 353 | } | ||
| 354 | pending.clear(); | ||
| 355 | }); | ||
| 356 | |||
| 357 | child.on("exit", (code, signal) => { | ||
| 358 | exited = true; | ||
| 359 | if (!exitError && code !== 0) { | ||
| 360 | exitError = new Error( | ||
| 361 | `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, | ||
| 362 | ); | ||
| 363 | } | ||
| 364 | for (const request of pending.values()) { | ||
| 365 | clearTimeout(request.timeout); | ||
| 366 | request.reject( | ||
| 367 | exitError ?? | ||
| 368 | new Error("codex app-server exited before request completed."), | ||
| 369 | ); | ||
| 370 | } | ||
| 371 | pending.clear(); | ||
| 372 | }); | ||
| 373 | |||
| 374 | try { | ||
| 375 | await sendRequest("initialize", { | ||
| 376 | clientInfo: { | ||
| 377 | name: "codething_desktop", | ||
| 378 | title: "CodeThing Desktop", | ||
| 379 | version: "0.1.0", | ||
| 380 | }, | ||
| 381 | capabilities: { | ||
| 382 | experimentalApi: false, | ||
| 383 | }, | ||
| 384 | }); | ||
| 385 | |||
| 386 | writeMessage({ method: "initialized" }); | ||
| 387 | |||
| 388 | const deduped = new Map<string, ProviderModel>(); | ||
| 389 | let cursor: string | null = null; | ||
| 390 | |||
| 391 | while (true) { | ||
| 392 | const response = await sendRequest<unknown>("model/list", { | ||
| 393 | cursor, | ||
| 394 | limit: 100, | ||
| 395 | }); | ||
| 396 | const parsed = this.parseModelListResponse(response); | ||
| 397 | for (const model of parsed.models) { | ||
| 398 | deduped.set(model.model, model); | ||
| 399 | } | ||
| 400 | |||
| 401 | if (!parsed.nextCursor) { | ||
| 402 | break; | ||
| 403 | } | ||
| 404 | cursor = parsed.nextCursor; | ||
| 405 | } | ||
| 406 | |||
| 407 | return Array.from(deduped.values()); | ||
| 408 | } finally { | ||
| 409 | cleanup(); | ||
| 410 | } | ||
| 411 | } | ||
| 412 | |||
| 413 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { | 274 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { |
| 414 | const context = this.requireSession(sessionId); | 275 | const context = this.requireSession(sessionId); |
| 415 | const effectiveTurnId = turnId ?? context.session.activeTurnId; | 276 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| ... | @@ -807,66 +668,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve | ... | @@ -807,66 +668,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 807 | }; | 668 | }; |
| 808 | } | 669 | } |
| 809 | 670 | ||
| 810 | private parseModelListResponse(response: unknown): { | ||
| 811 | models: ProviderModel[]; | ||
| 812 | nextCursor: string | null; | ||
| 813 | } { | ||
| 814 | const object = this.readObject(response); | ||
| 815 | if (!object) { | ||
| 816 | throw new Error("model/list response was not an object."); | ||
| 817 | } | ||
| 818 | |||
| 819 | const data = object.data; | ||
| 820 | if (!Array.isArray(data)) { | ||
| 821 | throw new Error("model/list response missing data array."); | ||
| 822 | } | ||
| 823 | |||
| 824 | const models: ProviderModel[] = []; | ||
| 825 | for (const entry of data) { | ||
| 826 | const item = this.readObject(entry); | ||
| 827 | if (!item) { | ||
| 828 | continue; | ||
| 829 | } | ||
| 830 | |||
| 831 | const model = this.readString(item, "model"); | ||
| 832 | if (!model) { | ||
| 833 | continue; | ||
| 834 | } | ||
| 835 | |||
| 836 | const id = this.readString(item, "id") ?? model; | ||
| 837 | const displayName = this.readString(item, "displayName") ?? model; | ||
| 838 | const description = this.readString(item, "description"); | ||
| 839 | const isDefault = | ||
| 840 | typeof item.isDefault === "boolean" ? item.isDefault : undefined; | ||
| 841 | const rawUpgrade = item.upgrade; | ||
| 842 | const upgrade = | ||
| 843 | typeof rawUpgrade === "string" | ||
| 844 | ? rawUpgrade | ||
| 845 | : rawUpgrade === null | ||
| 846 | ? null | ||
| 847 | : undefined; | ||
| 848 | |||
| 849 | models.push({ | ||
| 850 | id, | ||
| 851 | model, | ||
| 852 | displayName, | ||
| 853 | ...(description ? { description } : {}), | ||
| 854 | ...(isDefault === undefined ? {} : { isDefault }), | ||
| 855 | ...(upgrade === undefined ? {} : { upgrade }), | ||
| 856 | }); | ||
| 857 | } | ||
| 858 | |||
| 859 | const rawCursor = object.nextCursor; | ||
| 860 | const nextCursor = | ||
| 861 | typeof rawCursor === "string" | ||
| 862 | ? rawCursor | ||
| 863 | : rawCursor === null || rawCursor === undefined | ||
| 864 | ? null | ||
| 865 | : null; | ||
| 866 | |||
| 867 | return { models, nextCursor }; | ||
| 868 | } | ||
| 869 | |||
| 870 | private isServerRequest(value: unknown): value is JsonRpcRequest { | 671 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 871 | if (!value || typeof value !== "object") { | 672 | if (!value || typeof value !== "object") { |
| 872 | return false; | 673 | return false; |
apps/desktop/src/main.ts-9| ... | @@ -139,15 +139,6 @@ function registerIpcHandlers(): void { | ... | @@ -139,15 +139,6 @@ function registerIpcHandlers(): void { |
| 139 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { | 139 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { |
| 140 | return providerManager.listSessions(); | 140 | return providerManager.listSessions(); |
| 141 | }); | 141 | }); |
| 142 | |||
| 143 | ipcMain.handle( | ||
| 144 | IPC_CHANNELS.providerModelList, | ||
| 145 | async (_event, payload: unknown) => { | ||
| 146 | return providerManager.listModels( | ||
| 147 | payload as Parameters<typeof providerManager.listModels>[0], | ||
| 148 | ); | ||
| 149 | }, | ||
| 150 | ); | ||
| 151 | } | 142 | } |
| 152 | 143 | ||
| 153 | async function runTerminalCommand( | 144 | async function runTerminalCommand( |
apps/desktop/src/preload.ts-2| ... | @@ -41,8 +41,6 @@ const nativeApi: NativeApi = { | ... | @@ -41,8 +41,6 @@ const nativeApi: NativeApi = { |
| 41 | stopSession: (input) => | 41 | stopSession: (input) => |
| 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), | 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), |
| 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), | 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), |
| 44 | listModels: (input) => | ||
| 45 | ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input), | ||
| 46 | onEvent: (callback) => { | 44 | onEvent: (callback) => { |
| 47 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => | 45 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => |
| 48 | callback(payload as Parameters<typeof callback>[0]); | 46 | callback(payload as Parameters<typeof callback>[0]); |
apps/desktop/src/providerManager.ts-14| ... | @@ -3,15 +3,12 @@ import { EventEmitter } from "node:events"; | ... | @@ -3,15 +3,12 @@ import { EventEmitter } from "node:events"; |
| 3 | import { | 3 | import { |
| 4 | type ProviderEvent, | 4 | type ProviderEvent, |
| 5 | type ProviderInterruptTurnInput, | 5 | type ProviderInterruptTurnInput, |
| 6 | type ProviderListModelsInput, | ||
| 7 | type ProviderModel, | ||
| 8 | type ProviderSendTurnInput, | 6 | type ProviderSendTurnInput, |
| 9 | type ProviderSession, | 7 | type ProviderSession, |
| 10 | type ProviderSessionStartInput, | 8 | type ProviderSessionStartInput, |
| 11 | type ProviderStopSessionInput, | 9 | type ProviderStopSessionInput, |
| 12 | type ProviderTurnStartResult, | 10 | type ProviderTurnStartResult, |
| 13 | providerInterruptTurnInputSchema, | 11 | providerInterruptTurnInputSchema, |
| 14 | providerListModelsInputSchema, | ||
| 15 | providerSendTurnInputSchema, | 12 | providerSendTurnInputSchema, |
| 16 | providerSessionStartInputSchema, | 13 | providerSessionStartInputSchema, |
| 17 | providerStopSessionInputSchema, | 14 | providerStopSessionInputSchema, |
| ... | @@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { | ... | @@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 66 | return this.codex.listSessions(); | 63 | return this.codex.listSessions(); |
| 67 | } | 64 | } |
| 68 | 65 | ||
| 69 | async listModels( | ||
| 70 | raw: ProviderListModelsInput = {}, | ||
| 71 | ): Promise<ProviderModel[]> { | ||
| 72 | const input = providerListModelsInputSchema.parse(raw); | ||
| 73 | if (input.provider !== "codex") { | ||
| 74 | throw new Error(`Provider '${input.provider}' is not implemented yet.`); | ||
| 75 | } | ||
| 76 | |||
| 77 | return this.codex.listModels(input.cwd); | ||
| 78 | } | ||
| 79 | |||
| 80 | stopAll(): void { | 66 | stopAll(): void { |
| 81 | this.codex.stopAll(); | 67 | this.codex.stopAll(); |
| 82 | } | 68 | } |
apps/renderer/src/components/ChatView.tsx+236-148| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { ProviderModel } from "@acme/contracts"; | ||
| 2 | import { | 1 | import { |
| 3 | type FormEvent, | 2 | type FormEvent, |
| 4 | type KeyboardEvent, | 3 | type KeyboardEvent, |
| ... | @@ -8,10 +7,40 @@ import { | ... | @@ -8,10 +7,40 @@ import { |
| 8 | useState, | 7 | useState, |
| 9 | } from "react"; | 8 | } from "react"; |
| 10 | 9 | ||
| 11 | import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; | 10 | import { |
| 11 | derivePhase, | ||
| 12 | deriveWorkLogEntries, | ||
| 13 | formatElapsed, | ||
| 14 | formatTimestamp, | ||
| 15 | readNativeApi, | ||
| 16 | } from "../session-logic"; | ||
| 17 | import { | ||
| 18 | DEFAULT_MODEL, | ||
| 19 | DEFAULT_REASONING, | ||
| 20 | MODEL_OPTIONS, | ||
| 21 | REASONING_OPTIONS, | ||
| 22 | resolveModelSlug, | ||
| 23 | } from "../model-logic"; | ||
| 12 | import { useStore } from "../store"; | 24 | import { useStore } from "../store"; |
| 13 | 25 | ||
| 14 | const DEFAULT_MODEL = "gpt-5.2-codex"; | 26 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 27 | if (!duration) return formatTimestamp(createdAt); | ||
| 28 | return `${formatTimestamp(createdAt)} • ${duration}`; | ||
| 29 | } | ||
| 30 | |||
| 31 | function statusLabel(phase: string): string { | ||
| 32 | if (phase === "running") return "Thinking / working"; | ||
| 33 | if (phase === "connecting") return "Connecting"; | ||
| 34 | if (phase === "ready") return "Ready"; | ||
| 35 | return "Disconnected"; | ||
| 36 | } | ||
| 37 | |||
| 38 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | ||
| 39 | if (tone === "thinking") return "text-sky-100"; | ||
| 40 | if (tone === "tool") return "text-emerald-100"; | ||
| 41 | if (tone === "error") return "text-rose-100"; | ||
| 42 | return "text-[#d8d8d8]"; | ||
| 43 | } | ||
| 15 | 44 | ||
| 16 | export default function ChatView() { | 45 | export default function ChatView() { |
| 17 | const { state, dispatch } = useStore(); | 46 | const { state, dispatch } = useStore(); |
| ... | @@ -20,9 +49,10 @@ export default function ChatView() { | ... | @@ -20,9 +49,10 @@ export default function ChatView() { |
| 20 | const [isSending, setIsSending] = useState(false); | 49 | const [isSending, setIsSending] = useState(false); |
| 21 | const [isConnecting, setIsConnecting] = useState(false); | 50 | const [isConnecting, setIsConnecting] = useState(false); |
| 22 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | 51 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 23 | const [isLoadingModels, setIsLoadingModels] = useState(false); | 52 | const [selectedEffort, setSelectedEffort] = useState<string>( |
| 24 | const [modelLoadError, setModelLoadError] = useState<string | null>(null); | 53 | DEFAULT_REASONING, |
| 25 | const [availableModels, setAvailableModels] = useState<ProviderModel[]>([]); | 54 | ); |
| 55 | const [nowTick, setNowTick] = useState(() => Date.now()); | ||
| 26 | const messagesEndRef = useRef<HTMLDivElement>(null); | 56 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 27 | const textareaRef = useRef<HTMLTextAreaElement>(null); | 57 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 28 | const modelMenuRef = useRef<HTMLDivElement>(null); | 58 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| ... | @@ -31,31 +61,42 @@ export default function ChatView() { | ... | @@ -31,31 +61,42 @@ export default function ChatView() { |
| 31 | const activeProject = state.projects.find( | 61 | const activeProject = state.projects.find( |
| 32 | (p) => p.id === activeThread?.projectId, | 62 | (p) => p.id === activeThread?.projectId, |
| 33 | ); | 63 | ); |
| 34 | const selectedModel = | 64 | const selectedModel = resolveModelSlug( |
| 35 | activeThread?.model || activeProject?.model || DEFAULT_MODEL; | 65 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, |
| 66 | ); | ||
| 36 | const phase = derivePhase(activeThread?.session ?? null); | 67 | const phase = derivePhase(activeThread?.session ?? null); |
| 37 | const modelOptions = useMemo(() => { | 68 | const isWorking = phase === "running" || isSending || isConnecting; |
| 38 | const deduped = new Map<string, ProviderModel>(); | 69 | const activeTurnId = activeThread?.session?.activeTurnId; |
| 39 | for (const model of availableModels) { | 70 | const nowIso = new Date(nowTick).toISOString(); |
| 40 | deduped.set(model.model, model); | 71 | const modelOptions = MODEL_OPTIONS; |
| 41 | } | 72 | const workLogEntries = useMemo( |
| 42 | if (!deduped.has(selectedModel)) { | 73 | () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), |
| 43 | deduped.set(selectedModel, { | 74 | [activeThread?.events, activeTurnId], |
| 44 | id: selectedModel, | 75 | ); |
| 45 | model: selectedModel, | 76 | const assistantCompletionByItemId = useMemo(() => { |
| 46 | displayName: selectedModel, | 77 | const map = new Map<string, string>(); |
| 47 | description: "Current model for this thread.", | 78 | const ordered = [...(activeThread?.events ?? [])].reverse(); |
| 48 | }); | 79 | for (const event of ordered) { |
| 80 | if (event.method !== "item/completed") continue; | ||
| 81 | if (!event.itemId) continue; | ||
| 82 | map.set(event.itemId, event.createdAt); | ||
| 49 | } | 83 | } |
| 50 | return Array.from(deduped.values()); | 84 | return map; |
| 51 | }, [availableModels, selectedModel]); | 85 | }, [activeThread?.events]); |
| 86 | const recentWorkLogEntries = workLogEntries.slice(-12); | ||
| 52 | 87 | ||
| 53 | // Auto-scroll on new messages | 88 | // Auto-scroll on new messages |
| 54 | const messageCount = activeThread?.messages.length ?? 0; | 89 | const messageCount = activeThread?.messages.length ?? 0; |
| 90 | const workLogCount = recentWorkLogEntries.length; | ||
| 55 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change | 91 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change |
| 56 | useEffect(() => { | 92 | useEffect(() => { |
| 57 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | 93 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| 58 | }, [messageCount]); | 94 | }, [messageCount]); |
| 95 | // biome-ignore lint/correctness/useExhaustiveDependencies: auto-scroll while active work-log events stream in | ||
| 96 | useEffect(() => { | ||
| 97 | if (phase !== "running") return; | ||
| 98 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); | ||
| 99 | }, [phase, workLogCount]); | ||
| 59 | 100 | ||
| 60 | // Auto-resize textarea | 101 | // Auto-resize textarea |
| 61 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change | 102 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change |
| ... | @@ -66,6 +107,16 @@ export default function ChatView() { | ... | @@ -66,6 +107,16 @@ export default function ChatView() { |
| 66 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; | 107 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; |
| 67 | }, [prompt]); | 108 | }, [prompt]); |
| 68 | 109 | ||
| 110 | useEffect(() => { | ||
| 111 | if (phase !== "running") return; | ||
| 112 | const timer = window.setInterval(() => { | ||
| 113 | setNowTick(Date.now()); | ||
| 114 | }, 250); | ||
| 115 | return () => { | ||
| 116 | window.clearInterval(timer); | ||
| 117 | }; | ||
| 118 | }, [phase]); | ||
| 119 | |||
| 69 | useEffect(() => { | 120 | useEffect(() => { |
| 70 | if (!isModelMenuOpen) return; | 121 | if (!isModelMenuOpen) return; |
| 71 | 122 | ||
| ... | @@ -85,48 +136,6 @@ export default function ChatView() { | ... | @@ -85,48 +136,6 @@ export default function ChatView() { |
| 85 | }; | 136 | }; |
| 86 | }, [isModelMenuOpen]); | 137 | }, [isModelMenuOpen]); |
| 87 | 138 | ||
| 88 | useEffect(() => { | ||
| 89 | if (!api || !state.activeThreadId) return; | ||
| 90 | |||
| 91 | let cancelled = false; | ||
| 92 | |||
| 93 | const loadModels = async () => { | ||
| 94 | setIsLoadingModels(true); | ||
| 95 | setModelLoadError(null); | ||
| 96 | try { | ||
| 97 | const models = await api.providers.listModels( | ||
| 98 | activeProject?.cwd ? { cwd: activeProject.cwd } : undefined, | ||
| 99 | ); | ||
| 100 | if (cancelled) return; | ||
| 101 | |||
| 102 | const sorted = [...models].sort((a, b) => { | ||
| 103 | if ((a.isDefault ?? false) && !(b.isDefault ?? false)) return -1; | ||
| 104 | if ((b.isDefault ?? false) && !(a.isDefault ?? false)) return 1; | ||
| 105 | return a.displayName.localeCompare(b.displayName); | ||
| 106 | }); | ||
| 107 | setAvailableModels(sorted); | ||
| 108 | } catch (err) { | ||
| 109 | if (cancelled) return; | ||
| 110 | setAvailableModels([]); | ||
| 111 | setModelLoadError( | ||
| 112 | err instanceof Error | ||
| 113 | ? err.message | ||
| 114 | : "Could not load available models from Codex.", | ||
| 115 | ); | ||
| 116 | } finally { | ||
| 117 | if (!cancelled) { | ||
| 118 | setIsLoadingModels(false); | ||
| 119 | } | ||
| 120 | } | ||
| 121 | }; | ||
| 122 | |||
| 123 | void loadModels(); | ||
| 124 | |||
| 125 | return () => { | ||
| 126 | cancelled = true; | ||
| 127 | }; | ||
| 128 | }, [api, activeProject?.cwd, state.activeThreadId]); | ||
| 129 | |||
| 130 | const ensureSession = async (): Promise<string | null> => { | 139 | const ensureSession = async (): Promise<string | null> => { |
| 131 | if (!api || !activeThread || !activeProject) return null; | 140 | if (!api || !activeThread || !activeProject) return null; |
| 132 | if (activeThread.session && activeThread.session.status !== "closed") { | 141 | if (activeThread.session && activeThread.session.status !== "closed") { |
| ... | @@ -197,6 +206,7 @@ export default function ChatView() { | ... | @@ -197,6 +206,7 @@ export default function ChatView() { |
| 197 | sessionId, | 206 | sessionId, |
| 198 | input: trimmed, | 207 | input: trimmed, |
| 199 | model: selectedModel || undefined, | 208 | model: selectedModel || undefined, |
| 209 | effort: selectedEffort || undefined, | ||
| 200 | }); | 210 | }); |
| 201 | } catch (err) { | 211 | } catch (err) { |
| 202 | dispatch({ | 212 | dispatch({ |
| ... | @@ -222,7 +232,7 @@ export default function ChatView() { | ... | @@ -222,7 +232,7 @@ export default function ChatView() { |
| 222 | dispatch({ | 232 | dispatch({ |
| 223 | type: "SET_THREAD_MODEL", | 233 | type: "SET_THREAD_MODEL", |
| 224 | threadId: activeThread.id, | 234 | threadId: activeThread.id, |
| 225 | model, | 235 | model: resolveModelSlug(model), |
| 226 | }); | 236 | }); |
| 227 | setIsModelMenuOpen(false); | 237 | setIsModelMenuOpen(false); |
| 228 | }; | 238 | }; |
| ... | @@ -266,19 +276,38 @@ export default function ChatView() { | ... | @@ -266,19 +276,38 @@ export default function ChatView() { |
| 266 | </div> | 276 | </div> |
| 267 | <div className="flex items-center gap-3"> | 277 | <div className="flex items-center gap-3"> |
| 268 | {/* Status indicator */} | 278 | {/* Status indicator */} |
| 269 | <div className="flex items-center gap-1.5"> | 279 | <div |
| 270 | <div | 280 | className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] ${ |
| 271 | className={`h-2 w-2 rounded-full ${ | 281 | phase === "running" |
| 272 | phase === "running" | 282 | ? "border-sky-400/35 bg-sky-500/[0.08] text-sky-100" |
| 273 | ? "animate-pulse bg-emerald-400" | 283 | : phase === "connecting" |
| 284 | ? "border-amber-400/35 bg-amber-500/[0.08] text-amber-100" | ||
| 274 | : phase === "ready" | 285 | : phase === "ready" |
| 275 | ? "bg-emerald-400" | 286 | ? "border-emerald-400/35 bg-emerald-500/[0.08] text-emerald-100" |
| 287 | : "border-white/[0.08] bg-white/[0.04] text-[#a0a0a0]/70" | ||
| 288 | }`} | ||
| 289 | > | ||
| 290 | <span className="relative inline-flex h-2.5 w-2.5"> | ||
| 291 | {(phase === "running" || phase === "connecting") && ( | ||
| 292 | <span | ||
| 293 | className={`absolute inline-flex h-full w-full animate-ping rounded-full ${ | ||
| 294 | phase === "running" ? "bg-sky-300/70" : "bg-amber-300/70" | ||
| 295 | }`} | ||
| 296 | /> | ||
| 297 | )} | ||
| 298 | <span | ||
| 299 | className={`relative inline-flex h-2.5 w-2.5 rounded-full ${ | ||
| 300 | phase === "running" | ||
| 301 | ? "bg-sky-200" | ||
| 276 | : phase === "connecting" | 302 | : phase === "connecting" |
| 277 | ? "animate-pulse bg-amber-400" | 303 | ? "bg-amber-200" |
| 278 | : "bg-[#a0a0a0]/30" | 304 | : phase === "ready" |
| 279 | }`} | 305 | ? "bg-emerald-200" |
| 280 | /> | 306 | : "bg-[#a0a0a0]/40" |
| 281 | <span className="text-[10px] text-[#a0a0a0]/50">{phase}</span> | 307 | }`} |
| 308 | /> | ||
| 309 | </span> | ||
| 310 | <span>{statusLabel(phase)}</span> | ||
| 282 | </div> | 311 | </div> |
| 283 | {/* Diff toggle */} | 312 | {/* Diff toggle */} |
| 284 | <button | 313 | <button |
| ... | @@ -304,7 +333,7 @@ export default function ChatView() { | ... | @@ -304,7 +333,7 @@ export default function ChatView() { |
| 304 | 333 | ||
| 305 | {/* Messages */} | 334 | {/* Messages */} |
| 306 | <div className="flex-1 overflow-y-auto px-5 py-4"> | 335 | <div className="flex-1 overflow-y-auto px-5 py-4"> |
| 307 | {activeThread.messages.length === 0 ? ( | 336 | {activeThread.messages.length === 0 && !isWorking ? ( |
| 308 | <div className="flex h-full items-center justify-center"> | 337 | <div className="flex h-full items-center justify-center"> |
| 309 | <p className="text-sm text-[#a0a0a0]/30"> | 338 | <p className="text-sm text-[#a0a0a0]/30"> |
| 310 | Send a message to start the conversation. | 339 | Send a message to start the conversation. |
| ... | @@ -312,6 +341,53 @@ export default function ChatView() { | ... | @@ -312,6 +341,53 @@ export default function ChatView() { |
| 312 | </div> | 341 | </div> |
| 313 | ) : ( | 342 | ) : ( |
| 314 | <div className="mx-auto max-w-3xl space-y-4"> | 343 | <div className="mx-auto max-w-3xl space-y-4"> |
| 344 | {isWorking && ( | ||
| 345 | <div className="rounded-2xl border border-sky-400/20 bg-sky-500/[0.06] px-3 py-2.5"> | ||
| 346 | <div className="flex items-center justify-between gap-2"> | ||
| 347 | <div className="inline-flex items-center gap-2 text-xs text-sky-100"> | ||
| 348 | <span className="relative inline-flex h-2.5 w-2.5"> | ||
| 349 | <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-300/70" /> | ||
| 350 | <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-sky-200" /> | ||
| 351 | </span> | ||
| 352 | <span>Model is working</span> | ||
| 353 | </div> | ||
| 354 | <span className="text-[10px] text-sky-100/70"> | ||
| 355 | {recentWorkLogEntries.length} event | ||
| 356 | {recentWorkLogEntries.length === 1 ? "" : "s"} | ||
| 357 | </span> | ||
| 358 | </div> | ||
| 359 | {recentWorkLogEntries.length === 0 ? ( | ||
| 360 | <p className="pt-2 text-[11px] text-sky-100/70"> | ||
| 361 | Waiting for tool/preamble updates... | ||
| 362 | </p> | ||
| 363 | ) : ( | ||
| 364 | <div className="mt-2 max-h-48 space-y-1.5 overflow-y-auto pr-1"> | ||
| 365 | {recentWorkLogEntries.map((entry) => ( | ||
| 366 | <div | ||
| 367 | key={entry.id} | ||
| 368 | className="rounded-lg border border-white/[0.08] bg-black/20 px-2 py-1.5" | ||
| 369 | > | ||
| 370 | <div className="flex items-start justify-between gap-2"> | ||
| 371 | <p | ||
| 372 | className={`text-[11px] ${workToneClass(entry.tone)}`} | ||
| 373 | > | ||
| 374 | {entry.label} | ||
| 375 | </p> | ||
| 376 | <span className="shrink-0 text-[10px] text-[#a0a0a0]/60"> | ||
| 377 | {formatTimestamp(entry.createdAt)} | ||
| 378 | </span> | ||
| 379 | </div> | ||
| 380 | {entry.detail && ( | ||
| 381 | <p className="pt-0.5 font-mono text-[11px] text-[#d0d0d0]/75"> | ||
| 382 | {entry.detail} | ||
| 383 | </p> | ||
| 384 | )} | ||
| 385 | </div> | ||
| 386 | ))} | ||
| 387 | </div> | ||
| 388 | )} | ||
| 389 | </div> | ||
| 390 | )} | ||
| 315 | {activeThread.messages.map((msg) => ( | 391 | {activeThread.messages.map((msg) => ( |
| 316 | <div key={msg.id}> | 392 | <div key={msg.id}> |
| 317 | {msg.role === "user" ? ( | 393 | {msg.role === "user" ? ( |
| ... | @@ -331,17 +407,28 @@ export default function ChatView() { | ... | @@ -331,17 +407,28 @@ export default function ChatView() { |
| 331 | {msg.text || (msg.streaming ? "" : "(empty response)")} | 407 | {msg.text || (msg.streaming ? "" : "(empty response)")} |
| 332 | </pre> | 408 | </pre> |
| 333 | {msg.streaming && ( | 409 | {msg.streaming && ( |
| 334 | <span className="inline-flex gap-1 pt-1"> | 410 | <div className="pt-1.5"> |
| 335 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50" /> | 411 | <span className="inline-flex items-center gap-2 rounded-full border border-sky-400/25 bg-sky-500/[0.08] px-2 py-0.5 text-[10px] text-sky-100/90"> |
| 336 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:150ms]" /> | 412 | <span className="inline-flex gap-1"> |
| 337 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:300ms]" /> | 413 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" /> |
| 338 | </span> | 414 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" /> |
| 339 | )} | 415 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" /> |
| 340 | {!msg.streaming && ( | 416 | </span> |
| 341 | <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30"> | 417 | <span>Thinking</span> |
| 342 | {formatTimestamp(msg.createdAt)} | 418 | </span> |
| 343 | </p> | 419 | </div> |
| 344 | )} | 420 | )} |
| 421 | <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30"> | ||
| 422 | {formatMessageMeta( | ||
| 423 | msg.createdAt, | ||
| 424 | msg.streaming | ||
| 425 | ? formatElapsed(msg.createdAt, nowIso) | ||
| 426 | : formatElapsed( | ||
| 427 | msg.createdAt, | ||
| 428 | assistantCompletionByItemId.get(msg.id), | ||
| 429 | ), | ||
| 430 | )} | ||
| 431 | </p> | ||
| 345 | </div> | 432 | </div> |
| 346 | )} | 433 | )} |
| 347 | </div> | 434 | </div> |
| ... | @@ -373,70 +460,71 @@ export default function ChatView() { | ... | @@ -373,70 +460,71 @@ export default function ChatView() { |
| 373 | disabled={isSending || isConnecting} | 460 | disabled={isSending || isConnecting} |
| 374 | /> | 461 | /> |
| 375 | <div className="mt-2 flex items-center justify-between gap-2"> | 462 | <div className="mt-2 flex items-center justify-between gap-2"> |
| 376 | <div className="relative" ref={modelMenuRef}> | 463 | <div className="flex items-center gap-2"> |
| 377 | <button | 464 | <div className="relative" ref={modelMenuRef}> |
| 378 | type="button" | 465 | <button |
| 379 | className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.08]" | 466 | type="button" |
| 380 | onClick={() => setIsModelMenuOpen((open) => !open)} | 467 | className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8] transition-colors duration-150 hover:bg-white/[0.08]" |
| 381 | > | 468 | onClick={() => setIsModelMenuOpen((open) => !open)} |
| 382 | <span className="max-w-[180px] truncate"> | 469 | > |
| 383 | {selectedModel} | 470 | <span className="max-w-[180px] truncate font-mono"> |
| 384 | </span> | 471 | {selectedModel} |
| 385 | <span className="text-[10px] text-[#a0a0a0]/70">â–¼</span> | 472 | </span> |
| 386 | </button> | 473 | <span className="text-[10px] text-[#a0a0a0]/70">â–¼</span> |
| 387 | {isModelMenuOpen && ( | 474 | </button> |
| 388 | <div className="absolute bottom-full left-0 z-20 mb-2 w-[340px] rounded-2xl border border-white/[0.1] bg-[#1b1b1d]/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur"> | 475 | {isModelMenuOpen && ( |
| 389 | <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70"> | 476 | <div className="absolute bottom-full left-0 z-20 mb-2 w-[320px] rounded-2xl border border-white/[0.1] bg-[#1b1b1d]/95 p-2 shadow-[0_16px_40px_rgba(0,0,0,0.55)] backdrop-blur"> |
| 390 | Select model | 477 | <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70"> |
| 391 | </p> | 478 | Select model |
| 392 | <div className="max-h-72 overflow-y-auto"> | 479 | </p> |
| 393 | {modelOptions.map((model) => { | 480 | <div className="max-h-72 overflow-y-auto"> |
| 394 | const isSelected = model.model === selectedModel; | 481 | {modelOptions.map((model) => { |
| 395 | return ( | 482 | const isSelected = model === selectedModel; |
| 396 | <button | 483 | return ( |
| 397 | key={model.id} | 484 | <button |
| 398 | type="button" | 485 | key={model} |
| 399 | className={`mb-0.5 flex w-full items-start justify-between gap-2 rounded-xl px-2 py-2 text-left transition-colors duration-150 ${ | 486 | type="button" |
| 400 | isSelected | 487 | className={`mb-0.5 flex w-full items-center justify-between gap-2 rounded-xl px-2 py-2 text-left font-mono text-sm transition-colors duration-150 ${ |
| 401 | ? "bg-white/[0.08] text-white" | 488 | isSelected |
| 402 | : "text-[#d4d4d4] hover:bg-white/[0.05]" | 489 | ? "bg-white/[0.08] text-white" |
| 403 | }`} | 490 | : "text-[#d4d4d4] hover:bg-white/[0.05]" |
| 404 | onClick={() => onModelSelect(model.model)} | ||
| 405 | > | ||
| 406 | <span className="min-w-0"> | ||
| 407 | <span className="block truncate text-sm"> | ||
| 408 | {model.displayName} | ||
| 409 | </span> | ||
| 410 | {model.description && ( | ||
| 411 | <span className="block pt-0.5 text-[10px] text-[#a0a0a0]/65"> | ||
| 412 | {model.description} | ||
| 413 | </span> | ||
| 414 | )} | ||
| 415 | </span> | ||
| 416 | <span | ||
| 417 | className={`pt-0.5 text-sm ${ | ||
| 418 | isSelected ? "text-white" : "text-transparent" | ||
| 419 | }`} | 491 | }`} |
| 492 | onClick={() => onModelSelect(model)} | ||
| 420 | > | 493 | > |
| 421 | ✓ | 494 | <span className="truncate">{model}</span> |
| 422 | </span> | 495 | <span |
| 423 | </button> | 496 | className={`pt-0.5 text-sm ${ |
| 424 | ); | 497 | isSelected ? "text-white" : "text-transparent" |
| 425 | })} | 498 | }`} |
| 499 | > | ||
| 500 | ✓ | ||
| 501 | </span> | ||
| 502 | </button> | ||
| 503 | ); | ||
| 504 | })} | ||
| 505 | </div> | ||
| 426 | </div> | 506 | </div> |
| 427 | {isLoadingModels && ( | 507 | )} |
| 428 | <p className="px-2 pt-2 text-[10px] text-[#a0a0a0]/60"> | 508 | </div> |
| 429 | Refreshing available models... | 509 | <label |
| 430 | </p> | 510 | className="inline-flex items-center gap-2 rounded-full border border-white/[0.1] bg-white/[0.04] px-3 py-1.5 text-xs text-[#d8d8d8]" |
| 431 | )} | 511 | htmlFor="reasoning-effort" |
| 432 | {modelLoadError && ( | 512 | > |
| 433 | <p className="px-2 pt-2 text-[10px] text-amber-200/80"> | 513 | <span>Reasoning</span> |
| 434 | Could not load full model list from Codex:{" "} | 514 | <select |
| 435 | {modelLoadError} | 515 | id="reasoning-effort" |
| 436 | </p> | 516 | className="bg-transparent font-mono text-xs text-[#d8d8d8] outline-none" |
| 437 | )} | 517 | value={selectedEffort} |
| 438 | </div> | 518 | onChange={(event) => setSelectedEffort(event.target.value)} |
| 439 | )} | 519 | > |
| 520 | {REASONING_OPTIONS.map((effort) => ( | ||
| 521 | <option key={effort} value={effort} className="bg-[#1b1b1d]"> | ||
| 522 | {effort} | ||
| 523 | {effort === DEFAULT_REASONING ? " (default)" : ""} | ||
| 524 | </option> | ||
| 525 | ))} | ||
| 526 | </select> | ||
| 527 | </label> | ||
| 440 | </div> | 528 | </div> |
| 441 | {activeProject && ( | 529 | {activeProject && ( |
| 442 | <span className="text-[10px] text-[#a0a0a0]/35"> | 530 | <span className="text-[10px] text-[#a0a0a0]/35"> |
apps/renderer/src/components/Sidebar.tsx+11-6| ... | @@ -1,9 +1,8 @@ | ... | @@ -1,9 +1,8 @@ |
| 1 | import { useState } from "react"; | 1 | import { useState } from "react"; |
| 2 | import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic"; | ||
| 2 | import { useStore } from "../store"; | 3 | import { useStore } from "../store"; |
| 3 | import type { Project } from "../types"; | 4 | import type { Project } from "../types"; |
| 4 | 5 | ||
| 5 | const DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 6 | |||
| 7 | function formatRelativeTime(iso: string): string { | 6 | function formatRelativeTime(iso: string): string { |
| 8 | const diff = Date.now() - new Date(iso).getTime(); | 7 | const diff = Date.now() - new Date(iso).getTime(); |
| 9 | const minutes = Math.floor(diff / 60_000); | 8 | const minutes = Math.floor(diff / 60_000); |
| ... | @@ -24,11 +23,12 @@ export default function Sidebar() { | ... | @@ -24,11 +23,12 @@ export default function Sidebar() { |
| 24 | const cwd = newCwd.trim(); | 23 | const cwd = newCwd.trim(); |
| 25 | if (!cwd) return; | 24 | if (!cwd) return; |
| 26 | const name = cwd.split("/").filter(Boolean).pop() ?? "project"; | 25 | const name = cwd.split("/").filter(Boolean).pop() ?? "project"; |
| 26 | const normalizedModel = resolveModelSlug(newModel); | ||
| 27 | const project: Project = { | 27 | const project: Project = { |
| 28 | id: crypto.randomUUID(), | 28 | id: crypto.randomUUID(), |
| 29 | name, | 29 | name, |
| 30 | cwd, | 30 | cwd, |
| 31 | model: newModel.trim() || DEFAULT_MODEL, | 31 | model: normalizedModel, |
| 32 | expanded: true, | 32 | expanded: true, |
| 33 | }; | 33 | }; |
| 34 | dispatch({ type: "ADD_PROJECT", project }); | 34 | dispatch({ type: "ADD_PROJECT", project }); |
| ... | @@ -186,16 +186,21 @@ export default function Sidebar() { | ... | @@ -186,16 +186,21 @@ export default function Sidebar() { |
| 186 | if (e.key === "Escape") setAddingProject(false); | 186 | if (e.key === "Escape") setAddingProject(false); |
| 187 | }} | 187 | }} |
| 188 | /> | 188 | /> |
| 189 | <input | 189 | <select |
| 190 | className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none" | 190 | className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none" |
| 191 | placeholder={`model (default: ${DEFAULT_MODEL})`} | ||
| 192 | value={newModel} | 191 | value={newModel} |
| 193 | onChange={(e) => setNewModel(e.target.value)} | 192 | onChange={(e) => setNewModel(e.target.value)} |
| 194 | onKeyDown={(e) => { | 193 | onKeyDown={(e) => { |
| 195 | if (e.key === "Enter") handleAddProject(); | 194 | if (e.key === "Enter") handleAddProject(); |
| 196 | if (e.key === "Escape") setAddingProject(false); | 195 | if (e.key === "Escape") setAddingProject(false); |
| 197 | }} | 196 | }} |
| 198 | /> | 197 | > |
| 198 | {MODEL_OPTIONS.map((model) => ( | ||
| 199 | <option key={model} value={model} className="bg-[#141414]"> | ||
| 200 | {model} | ||
| 201 | </option> | ||
| 202 | ))} | ||
| 203 | </select> | ||
| 199 | <div className="flex gap-2"> | 204 | <div className="flex gap-2"> |
| 200 | <button | 205 | <button |
| 201 | type="button" | 206 | type="button" |
apps/renderer/src/model-logic.test.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | import { DEFAULT_MODEL, normalizeModelSlug, resolveModelSlug } from "./model-logic"; | ||
| 3 | |||
| 4 | describe("normalizeModelSlug", () => { | ||
| 5 | it("maps 5.3 aliases to canonical codex slug", () => { | ||
| 6 | expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); | ||
| 7 | expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); | ||
| 8 | }); | ||
| 9 | |||
| 10 | it("preserves non-aliased slugs", () => { | ||
| 11 | expect(normalizeModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 12 | expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 13 | }); | ||
| 14 | |||
| 15 | it("returns null for empty input", () => { | ||
| 16 | expect(normalizeModelSlug("")).toBeNull(); | ||
| 17 | expect(normalizeModelSlug(" ")).toBeNull(); | ||
| 18 | expect(normalizeModelSlug(null)).toBeNull(); | ||
| 19 | expect(normalizeModelSlug(undefined)).toBeNull(); | ||
| 20 | }); | ||
| 21 | }); | ||
| 22 | |||
| 23 | describe("resolveModelSlug", () => { | ||
| 24 | it("falls back to default for unknown models", () => { | ||
| 25 | expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL); | ||
| 26 | }); | ||
| 27 | |||
| 28 | it("resolves supported hard-coded models", () => { | ||
| 29 | expect(resolveModelSlug("gpt-5.3-codex")).toBe("gpt-5.3-codex"); | ||
| 30 | expect(resolveModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); | ||
| 31 | expect(resolveModelSlug("gpt-5.2")).toBe("gpt-5.2"); | ||
| 32 | }); | ||
| 33 | }); | ||
apps/renderer/src/model-logic.ts created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | export const MODEL_OPTIONS = [ | ||
| 2 | "gpt-5.3-codex", | ||
| 3 | "gpt-5.2-codex", | ||
| 4 | "gpt-5.2", | ||
| 5 | ] as const; | ||
| 6 | |||
| 7 | export const DEFAULT_MODEL = "gpt-5.3-codex"; | ||
| 8 | |||
| 9 | export const REASONING_OPTIONS = ["xhigh", "high", "medium", "low"] as const; | ||
| 10 | export const DEFAULT_REASONING = "high"; | ||
| 11 | |||
| 12 | const MODEL_SLUG_ALIASES: Record<string, string> = { | ||
| 13 | "5.3": "gpt-5.3-codex", | ||
| 14 | "gpt-5.3": "gpt-5.3-codex", | ||
| 15 | }; | ||
| 16 | |||
| 17 | export function normalizeModelSlug(model: string | null | undefined): string | null { | ||
| 18 | if (typeof model !== "string") { | ||
| 19 | return null; | ||
| 20 | } | ||
| 21 | |||
| 22 | const trimmed = model.trim(); | ||
| 23 | if (!trimmed) { | ||
| 24 | return null; | ||
| 25 | } | ||
| 26 | |||
| 27 | return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function resolveModelSlug(model: string | null | undefined): string { | ||
| 31 | const normalized = normalizeModelSlug(model); | ||
| 32 | if (!normalized) { | ||
| 33 | return DEFAULT_MODEL; | ||
| 34 | } | ||
| 35 | |||
| 36 | return MODEL_OPTIONS.includes(normalized as (typeof MODEL_OPTIONS)[number]) | ||
| 37 | ? normalized | ||
| 38 | : DEFAULT_MODEL; | ||
| 39 | } | ||
apps/renderer/src/session-logic.ts+241| ... | @@ -36,6 +36,247 @@ export function formatTimestamp(isoDate: string): string { | ... | @@ -36,6 +36,247 @@ export function formatTimestamp(isoDate: string): string { |
| 36 | }).format(new Date(isoDate)); | 36 | }).format(new Date(isoDate)); |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | export function formatDuration(durationMs: number): string { | ||
| 40 | if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; | ||
| 41 | if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; | ||
| 42 | if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; | ||
| 43 | if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; | ||
| 44 | |||
| 45 | const minutes = Math.floor(durationMs / 60_000); | ||
| 46 | const seconds = Math.round((durationMs % 60_000) / 1_000); | ||
| 47 | if (seconds === 0) return `${minutes}m`; | ||
| 48 | if (seconds === 60) return `${minutes + 1}m`; | ||
| 49 | return `${minutes}m ${seconds}s`; | ||
| 50 | } | ||
| 51 | |||
| 52 | export function formatElapsed( | ||
| 53 | startIso: string, | ||
| 54 | endIso: string | undefined, | ||
| 55 | ): string | null { | ||
| 56 | if (!endIso) return null; | ||
| 57 | |||
| 58 | const startedAt = Date.parse(startIso); | ||
| 59 | const endedAt = Date.parse(endIso); | ||
| 60 | if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { | ||
| 61 | return null; | ||
| 62 | } | ||
| 63 | |||
| 64 | return formatDuration(endedAt - startedAt); | ||
| 65 | } | ||
| 66 | |||
| 67 | export interface WorkLogEntry { | ||
| 68 | id: string; | ||
| 69 | createdAt: string; | ||
| 70 | label: string; | ||
| 71 | detail?: string; | ||
| 72 | tone: "thinking" | "tool" | "info" | "error"; | ||
| 73 | } | ||
| 74 | |||
| 75 | function truncateLine(value: string, limit = 140): string { | ||
| 76 | if (value.length <= limit) return value; | ||
| 77 | return `${value.slice(0, limit - 1)}…`; | ||
| 78 | } | ||
| 79 | |||
| 80 | function normalizeItemType(raw: string | undefined): string { | ||
| 81 | if (!raw) return "item"; | ||
| 82 | return raw.replace(/[_-]/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); | ||
| 83 | } | ||
| 84 | |||
| 85 | function itemTypeMeta(type: string): { | ||
| 86 | label: string; | ||
| 87 | tone: WorkLogEntry["tone"]; | ||
| 88 | } { | ||
| 89 | if ( | ||
| 90 | type.includes("preamble") || | ||
| 91 | type.includes("reasoning") || | ||
| 92 | type.includes("thought") | ||
| 93 | ) { | ||
| 94 | return { label: "Preamble", tone: "thinking" }; | ||
| 95 | } | ||
| 96 | if ( | ||
| 97 | type.includes("tool") || | ||
| 98 | type.includes("command") || | ||
| 99 | type.includes("file change") | ||
| 100 | ) { | ||
| 101 | return { label: "Tool call", tone: "tool" }; | ||
| 102 | } | ||
| 103 | if (type.includes("agent message")) { | ||
| 104 | return { label: "Drafting response", tone: "thinking" }; | ||
| 105 | } | ||
| 106 | return { label: "Work item", tone: "info" }; | ||
| 107 | } | ||
| 108 | |||
| 109 | function extractDetail( | ||
| 110 | payload: Record<string, unknown> | undefined, | ||
| 111 | item: Record<string, unknown> | undefined, | ||
| 112 | ): string | undefined { | ||
| 113 | const candidates = [ | ||
| 114 | asString(item?.title), | ||
| 115 | asString(item?.summary), | ||
| 116 | asString(item?.text), | ||
| 117 | asString(item?.command), | ||
| 118 | asString(payload?.message), | ||
| 119 | asString(payload?.prompt), | ||
| 120 | asString(payload?.command), | ||
| 121 | ]; | ||
| 122 | |||
| 123 | for (const candidate of candidates) { | ||
| 124 | if (candidate && candidate.trim().length > 0) { | ||
| 125 | return truncateLine(candidate.trim()); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | return undefined; | ||
| 129 | } | ||
| 130 | |||
| 131 | function entryFromItemLifecycle(event: ProviderEvent): WorkLogEntry | null { | ||
| 132 | const payload = asObject(event.payload); | ||
| 133 | const item = asObject(payload?.item); | ||
| 134 | const normalizedType = normalizeItemType(asString(item?.type)); | ||
| 135 | const meta = itemTypeMeta(normalizedType); | ||
| 136 | const isStarted = event.method === "item/started"; | ||
| 137 | const isCompleted = event.method === "item/completed"; | ||
| 138 | if (!isStarted && !isCompleted) { | ||
| 139 | return null; | ||
| 140 | } | ||
| 141 | |||
| 142 | const label = isCompleted ? `${meta.label} complete` : meta.label; | ||
| 143 | const detail = extractDetail(payload, item); | ||
| 144 | return { | ||
| 145 | id: event.id, | ||
| 146 | createdAt: event.createdAt, | ||
| 147 | label, | ||
| 148 | ...(detail ? { detail } : {}), | ||
| 149 | tone: meta.tone, | ||
| 150 | }; | ||
| 151 | } | ||
| 152 | |||
| 153 | function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { | ||
| 154 | if (event.kind !== "request") return null; | ||
| 155 | |||
| 156 | if (event.method.includes("commandExecution")) { | ||
| 157 | return { | ||
| 158 | id: event.id, | ||
| 159 | createdAt: event.createdAt, | ||
| 160 | label: "Command approval requested", | ||
| 161 | tone: "tool", | ||
| 162 | }; | ||
| 163 | } | ||
| 164 | |||
| 165 | if (event.method.includes("fileChange")) { | ||
| 166 | return { | ||
| 167 | id: event.id, | ||
| 168 | createdAt: event.createdAt, | ||
| 169 | label: "File-change approval requested", | ||
| 170 | tone: "tool", | ||
| 171 | }; | ||
| 172 | } | ||
| 173 | |||
| 174 | if (event.method.includes("requestUserInput")) { | ||
| 175 | return { | ||
| 176 | id: event.id, | ||
| 177 | createdAt: event.createdAt, | ||
| 178 | label: "Tool requested user input", | ||
| 179 | tone: "tool", | ||
| 180 | }; | ||
| 181 | } | ||
| 182 | |||
| 183 | return { | ||
| 184 | id: event.id, | ||
| 185 | createdAt: event.createdAt, | ||
| 186 | label: `Request: ${event.method}`, | ||
| 187 | tone: "info", | ||
| 188 | }; | ||
| 189 | } | ||
| 190 | |||
| 191 | function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { | ||
| 192 | if (event.kind !== "notification") return null; | ||
| 193 | if (event.method === "item/agentMessage/delta") return null; | ||
| 194 | if (event.method === "turn/started" || event.method === "turn/completed") { | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | if (event.method === "thread/started") return null; | ||
| 198 | |||
| 199 | const lifecycleEntry = entryFromItemLifecycle(event); | ||
| 200 | if (lifecycleEntry) return lifecycleEntry; | ||
| 201 | |||
| 202 | if (event.method.startsWith("item/")) { | ||
| 203 | return { | ||
| 204 | id: event.id, | ||
| 205 | createdAt: event.createdAt, | ||
| 206 | label: `Event: ${event.method.replace(/^item\//, "")}`, | ||
| 207 | tone: "info", | ||
| 208 | }; | ||
| 209 | } | ||
| 210 | |||
| 211 | return null; | ||
| 212 | } | ||
| 213 | |||
| 214 | function entryFromError(event: ProviderEvent): WorkLogEntry | null { | ||
| 215 | if (event.kind !== "error") return null; | ||
| 216 | |||
| 217 | return { | ||
| 218 | id: event.id, | ||
| 219 | createdAt: event.createdAt, | ||
| 220 | label: "Runtime error", | ||
| 221 | ...(event.message ? { detail: truncateLine(event.message) } : {}), | ||
| 222 | tone: "error", | ||
| 223 | }; | ||
| 224 | } | ||
| 225 | |||
| 226 | export function deriveWorkLogEntries( | ||
| 227 | events: ProviderEvent[], | ||
| 228 | turnId: string | undefined, | ||
| 229 | ): WorkLogEntry[] { | ||
| 230 | const ordered = [...events].reverse(); | ||
| 231 | const entries: WorkLogEntry[] = []; | ||
| 232 | const turnStartedAtIso = turnId | ||
| 233 | ? ordered.find((event) => { | ||
| 234 | if (event.method !== "turn/started") return false; | ||
| 235 | const payload = asObject(event.payload); | ||
| 236 | const turn = asObject(payload?.turn); | ||
| 237 | const startedTurnId = event.turnId ?? asString(turn?.id); | ||
| 238 | return startedTurnId === turnId; | ||
| 239 | })?.createdAt | ||
| 240 | : undefined; | ||
| 241 | const turnStartedAt = turnStartedAtIso | ||
| 242 | ? Date.parse(turnStartedAtIso) | ||
| 243 | : Number.NaN; | ||
| 244 | |||
| 245 | for (const event of ordered) { | ||
| 246 | if (turnId) { | ||
| 247 | if (event.turnId && event.turnId !== turnId) { | ||
| 248 | continue; | ||
| 249 | } | ||
| 250 | |||
| 251 | if (!event.turnId && !Number.isNaN(turnStartedAt)) { | ||
| 252 | const eventAt = Date.parse(event.createdAt); | ||
| 253 | if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { | ||
| 254 | continue; | ||
| 255 | } | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | const fromRequest = entryFromRequest(event); | ||
| 260 | if (fromRequest) { | ||
| 261 | entries.push(fromRequest); | ||
| 262 | continue; | ||
| 263 | } | ||
| 264 | |||
| 265 | const fromNotification = entryFromNotification(event); | ||
| 266 | if (fromNotification) { | ||
| 267 | entries.push(fromNotification); | ||
| 268 | continue; | ||
| 269 | } | ||
| 270 | |||
| 271 | const fromError = entryFromError(event); | ||
| 272 | if (fromError) { | ||
| 273 | entries.push(fromError); | ||
| 274 | } | ||
| 275 | } | ||
| 276 | |||
| 277 | return entries; | ||
| 278 | } | ||
| 279 | |||
| 39 | export function derivePhase(session: ProviderSession | null): SessionPhase { | 280 | export function derivePhase(session: ProviderSession | null): SessionPhase { |
| 40 | if (!session || session.status === "closed") return "disconnected"; | 281 | if (!session || session.status === "closed") return "disconnected"; |
| 41 | if (session.status === "connecting") return "connecting"; | 282 | if (session.status === "connecting") return "connecting"; |
apps/renderer/src/store.ts+49-11| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | } from "react"; | 9 | } from "react"; |
| 10 | 10 | ||
| 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; | 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 12 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | ||
| 12 | import { applyEventToMessages, evolveSession } from "./session-logic"; | 13 | import { applyEventToMessages, evolveSession } from "./session-logic"; |
| 13 | import type { Project, Thread } from "./types"; | 14 | import type { Project, Thread } from "./types"; |
| 14 | 15 | ||
| ... | @@ -40,7 +41,9 @@ export interface AppState { | ... | @@ -40,7 +41,9 @@ export interface AppState { |
| 40 | diffOpen: boolean; | 41 | diffOpen: boolean; |
| 41 | } | 42 | } |
| 42 | 43 | ||
| 43 | const PERSISTED_STATE_KEY = "codething:renderer-state:v1"; | 44 | const PERSISTED_STATE_KEY = "codething:renderer-state:v2"; |
| 45 | const LEGACY_PERSISTED_STATE_KEY = "codething:renderer-state:v1"; | ||
| 46 | const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; | ||
| 44 | 47 | ||
| 45 | const initialState: AppState = { | 48 | const initialState: AppState = { |
| 46 | projects: [], | 49 | projects: [], |
| ... | @@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] { | ... | @@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] { |
| 86 | const id = asString(project.id); | 89 | const id = asString(project.id); |
| 87 | const name = asString(project.name); | 90 | const name = asString(project.name); |
| 88 | const cwd = asString(project.cwd); | 91 | const cwd = asString(project.cwd); |
| 89 | const model = asString(project.model); | 92 | const rawModel = asString(project.model); |
| 93 | const model = resolveModelSlug(rawModel); | ||
| 90 | const expanded = project.expanded; | 94 | const expanded = project.expanded; |
| 91 | if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null; | 95 | if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null; |
| 92 | 96 | ||
| ... | @@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] { | ... | @@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] { |
| 101 | .filter((project): project is Project => project !== null); | 105 | .filter((project): project is Project => project !== null); |
| 102 | } | 106 | } |
| 103 | 107 | ||
| 108 | function migrateLegacyDefaultModel(model: string): string { | ||
| 109 | return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; | ||
| 110 | } | ||
| 111 | |||
| 104 | function sanitizeMessages(input: unknown): Thread["messages"] { | 112 | function sanitizeMessages(input: unknown): Thread["messages"] { |
| 105 | if (!Array.isArray(input)) return []; | 113 | if (!Array.isArray(input)) return []; |
| 106 | 114 | ||
| ... | @@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] { | ... | @@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] { |
| 149 | const id = asString(thread.id); | 157 | const id = asString(thread.id); |
| 150 | const projectId = asString(thread.projectId); | 158 | const projectId = asString(thread.projectId); |
| 151 | const title = asString(thread.title); | 159 | const title = asString(thread.title); |
| 152 | const model = asString(thread.model); | 160 | const rawModel = asString(thread.model); |
| 161 | const model = resolveModelSlug(rawModel); | ||
| 153 | const createdAt = asString(thread.createdAt); | 162 | const createdAt = asString(thread.createdAt); |
| 154 | if (!id || !projectId || !title || !model || !createdAt) return null; | 163 | if (!id || !projectId || !title || !model || !createdAt) return null; |
| 155 | 164 | ||
| ... | @@ -173,17 +182,30 @@ function readPersistedState(): AppState { | ... | @@ -173,17 +182,30 @@ function readPersistedState(): AppState { |
| 173 | if (typeof window === "undefined") return initialState; | 182 | if (typeof window === "undefined") return initialState; |
| 174 | 183 | ||
| 175 | try { | 184 | try { |
| 176 | const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); | 185 | const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); |
| 186 | const rawLegacy = window.localStorage.getItem(LEGACY_PERSISTED_STATE_KEY); | ||
| 187 | const raw = rawCurrent ?? rawLegacy; | ||
| 177 | if (!raw) return initialState; | 188 | if (!raw) return initialState; |
| 189 | const isLegacyPayload = !rawCurrent && Boolean(rawLegacy); | ||
| 178 | 190 | ||
| 179 | const parsed = asObject(JSON.parse(raw)); | 191 | const parsed = asObject(JSON.parse(raw)); |
| 180 | if (!parsed) return initialState; | 192 | if (!parsed) return initialState; |
| 181 | 193 | ||
| 182 | const projects = sanitizeProjects(parsed.projects); | 194 | const projects = sanitizeProjects(parsed.projects).map((project) => ({ |
| 195 | ...project, | ||
| 196 | model: isLegacyPayload | ||
| 197 | ? migrateLegacyDefaultModel(project.model) | ||
| 198 | : project.model, | ||
| 199 | })); | ||
| 183 | const projectIds = new Set(projects.map((project) => project.id)); | 200 | const projectIds = new Set(projects.map((project) => project.id)); |
| 184 | const threads = sanitizeThreads(parsed.threads).filter((thread) => | 201 | const threads = sanitizeThreads(parsed.threads) |
| 185 | projectIds.has(thread.projectId), | 202 | .map((thread) => ({ |
| 186 | ); | 203 | ...thread, |
| 204 | model: isLegacyPayload | ||
| 205 | ? migrateLegacyDefaultModel(thread.model) | ||
| 206 | : thread.model, | ||
| 207 | })) | ||
| 208 | .filter((thread) => projectIds.has(thread.projectId)); | ||
| 187 | const activeThreadId = asString(parsed.activeThreadId); | 209 | const activeThreadId = asString(parsed.activeThreadId); |
| 188 | const hasActiveThread = Boolean( | 210 | const hasActiveThread = Boolean( |
| 189 | activeThreadId && threads.some((thread) => thread.id === activeThreadId), | 211 | activeThreadId && threads.some((thread) => thread.id === activeThreadId), |
| ... | @@ -227,6 +249,7 @@ function persistState(state: AppState): void { | ... | @@ -227,6 +249,7 @@ function persistState(state: AppState): void { |
| 227 | PERSISTED_STATE_KEY, | 249 | PERSISTED_STATE_KEY, |
| 228 | JSON.stringify(toPersistedState(state)), | 250 | JSON.stringify(toPersistedState(state)), |
| 229 | ); | 251 | ); |
| 252 | window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY); | ||
| 230 | } catch { | 253 | } catch { |
| 231 | // Ignore quota/storage errors to avoid breaking chat UX. | 254 | // Ignore quota/storage errors to avoid breaking chat UX. |
| 232 | } | 255 | } |
| ... | @@ -252,7 +275,16 @@ function findThreadBySessionId( | ... | @@ -252,7 +275,16 @@ function findThreadBySessionId( |
| 252 | function reducer(state: AppState, action: Action): AppState { | 275 | function reducer(state: AppState, action: Action): AppState { |
| 253 | switch (action.type) { | 276 | switch (action.type) { |
| 254 | case "ADD_PROJECT": | 277 | case "ADD_PROJECT": |
| 255 | return { ...state, projects: [...state.projects, action.project] }; | 278 | return { |
| 279 | ...state, | ||
| 280 | projects: [ | ||
| 281 | ...state.projects, | ||
| 282 | { | ||
| 283 | ...action.project, | ||
| 284 | model: resolveModelSlug(action.project.model), | ||
| 285 | }, | ||
| 286 | ], | ||
| 287 | }; | ||
| 256 | 288 | ||
| 257 | case "TOGGLE_PROJECT": | 289 | case "TOGGLE_PROJECT": |
| 258 | return { | 290 | return { |
| ... | @@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState { | ... | @@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState { |
| 265 | case "ADD_THREAD": | 297 | case "ADD_THREAD": |
| 266 | return { | 298 | return { |
| 267 | ...state, | 299 | ...state, |
| 268 | threads: [...state.threads, action.thread], | 300 | threads: [ |
| 301 | ...state.threads, | ||
| 302 | { | ||
| 303 | ...action.thread, | ||
| 304 | model: resolveModelSlug(action.thread.model), | ||
| 305 | }, | ||
| 306 | ], | ||
| 269 | activeThreadId: action.thread.id, | 307 | activeThreadId: action.thread.id, |
| 270 | }; | 308 | }; |
| 271 | 309 | ||
| ... | @@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState { | ... | @@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState { |
| 349 | ...state, | 387 | ...state, |
| 350 | threads: updateThread(state.threads, action.threadId, (t) => ({ | 388 | threads: updateThread(state.threads, action.threadId, (t) => ({ |
| 351 | ...t, | 389 | ...t, |
| 352 | model: action.model, | 390 | model: resolveModelSlug(action.model), |
| 353 | })), | 391 | })), |
| 354 | }; | 392 | }; |
| 355 | 393 |
packages/contracts/src/ipc.ts-4| ... | @@ -2,8 +2,6 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; | ... | @@ -2,8 +2,6 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; |
| 2 | import type { | 2 | import type { |
| 3 | ProviderEvent, | 3 | ProviderEvent, |
| 4 | ProviderInterruptTurnInput, | 4 | ProviderInterruptTurnInput, |
| 5 | ProviderListModelsInput, | ||
| 6 | ProviderModel, | ||
| 7 | ProviderSendTurnInput, | 5 | ProviderSendTurnInput, |
| 8 | ProviderSession, | 6 | ProviderSession, |
| 9 | ProviderSessionStartInput, | 7 | ProviderSessionStartInput, |
| ... | @@ -29,7 +27,6 @@ export const IPC_CHANNELS = { | ... | @@ -29,7 +27,6 @@ export const IPC_CHANNELS = { |
| 29 | providerTurnInterrupt: "provider:turn:interrupt", | 27 | providerTurnInterrupt: "provider:turn:interrupt", |
| 30 | providerSessionStop: "provider:session:stop", | 28 | providerSessionStop: "provider:session:stop", |
| 31 | providerSessionList: "provider:session:list", | 29 | providerSessionList: "provider:session:list", |
| 32 | providerModelList: "provider:model:list", | ||
| 33 | providerEvent: "provider:event", | 30 | providerEvent: "provider:event", |
| 34 | } as const; | 31 | } as const; |
| 35 | 32 | ||
| ... | @@ -60,7 +57,6 @@ export interface NativeApi { | ... | @@ -60,7 +57,6 @@ export interface NativeApi { |
| 60 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; | 57 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 61 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; | 58 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 62 | listSessions: () => Promise<ProviderSession[]>; | 59 | listSessions: () => Promise<ProviderSession[]>; |
| 63 | listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>; | ||
| 64 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; | 60 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
| 65 | }; | 61 | }; |
| 66 | } | 62 | } |
packages/contracts/src/provider.test.ts+3-9| ... | @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; | ... | @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | providerEventSchema, | 4 | providerEventSchema, |
| 5 | providerListModelsInputSchema, | ||
| 6 | providerSendTurnInputSchema, | 5 | providerSendTurnInputSchema, |
| 7 | providerSessionStartInputSchema, | 6 | providerSessionStartInputSchema, |
| 8 | } from "./provider"; | 7 | } from "./provider"; |
| ... | @@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => { | ... | @@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => { |
| 17 | }); | 16 | }); |
| 18 | 17 | ||
| 19 | describe("providerSendTurnInputSchema", () => { | 18 | describe("providerSendTurnInputSchema", () => { |
| 20 | it("trims input text and optional model", () => { | 19 | it("trims input text and optional model/effort", () => { |
| 21 | const parsed = providerSendTurnInputSchema.parse({ | 20 | const parsed = providerSendTurnInputSchema.parse({ |
| 22 | sessionId: "sess_1", | 21 | sessionId: "sess_1", |
| 23 | input: " summarize this repo ", | 22 | input: " summarize this repo ", |
| 24 | model: " gpt-5.2-codex ", | 23 | model: " gpt-5.2-codex ", |
| 24 | effort: " high ", | ||
| 25 | }); | 25 | }); |
| 26 | expect(parsed.input).toBe("summarize this repo"); | 26 | expect(parsed.input).toBe("summarize this repo"); |
| 27 | expect(parsed.model).toBe("gpt-5.2-codex"); | 27 | expect(parsed.model).toBe("gpt-5.2-codex"); |
| 28 | }); | 28 | expect(parsed.effort).toBe("high"); |
| 29 | }); | ||
| 30 | |||
| 31 | describe("providerListModelsInputSchema", () => { | ||
| 32 | it("defaults provider to codex", () => { | ||
| 33 | const parsed = providerListModelsInputSchema.parse({}); | ||
| 34 | expect(parsed.provider).toBe("codex"); | ||
| 35 | }); | 29 | }); |
| 36 | }); | 30 | }); |
| 37 | 31 |
packages/contracts/src/provider.ts+1-18| ... | @@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({ | ... | @@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({ |
| 48 | sessionId: z.string().min(1), | 48 | sessionId: z.string().min(1), |
| 49 | input: z.string().trim().min(1).max(120_000), | 49 | input: z.string().trim().min(1).max(120_000), |
| 50 | model: z.string().trim().min(1).optional(), | 50 | model: z.string().trim().min(1).optional(), |
| 51 | effort: z.string().trim().min(1).optional(), | ||
| 51 | }); | 52 | }); |
| 52 | 53 | ||
| 53 | export const providerTurnStartResultSchema = z.object({ | 54 | export const providerTurnStartResultSchema = z.object({ |
| ... | @@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({ | ... | @@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({ |
| 64 | sessionId: z.string().min(1), | 65 | sessionId: z.string().min(1), |
| 65 | }); | 66 | }); |
| 66 | 67 | ||
| 67 | export const providerModelSchema = z.object({ | ||
| 68 | id: z.string().min(1), | ||
| 69 | model: z.string().min(1), | ||
| 70 | displayName: z.string().min(1), | ||
| 71 | description: z.string().min(1).optional(), | ||
| 72 | isDefault: z.boolean().optional(), | ||
| 73 | upgrade: z.string().min(1).nullable().optional(), | ||
| 74 | }); | ||
| 75 | |||
| 76 | export const providerListModelsInputSchema = z.object({ | ||
| 77 | provider: providerKindSchema.default("codex"), | ||
| 78 | cwd: z.string().min(1).optional(), | ||
| 79 | }); | ||
| 80 | |||
| 81 | export const providerEventKindSchema = z.enum([ | 68 | export const providerEventKindSchema = z.enum([ |
| 82 | "session", | 69 | "session", |
| 83 | "notification", | 70 | "notification", |
| ... | @@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input< | ... | @@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input< |
| 120 | export type ProviderStopSessionInput = z.input< | 107 | export type ProviderStopSessionInput = z.input< |
| 121 | typeof providerStopSessionInputSchema | 108 | typeof providerStopSessionInputSchema |
| 122 | >; | 109 | >; |
| 123 | export type ProviderModel = z.infer<typeof providerModelSchema>; | ||
| 124 | export type ProviderListModelsInput = z.input< | ||
| 125 | typeof providerListModelsInputSchema | ||
| 126 | >; | ||
| 127 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; | 110 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 128 | export type ProviderEvent = z.infer<typeof providerEventSchema>; | 111 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |