| 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 | 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 | 8 | describe("classifyCodexStderrLine", () => { |
| 6 | 9 | it("ignores empty lines", () => { |
| ... | ... | @@ -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 | 5 | |
| 6 | 6 | import type { |
| 7 | 7 | ProviderEvent, |
| 8 | ProviderModel, | |
| 9 | 8 | ProviderSendTurnInput, |
| 10 | 9 | ProviderSession, |
| 11 | 10 | ProviderSessionStartInput, |
| ... | ... | @@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ |
| 60 | 59 | "state db missing rollout path for thread", |
| 61 | 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 | 91 | export function classifyCodexStderrLine( |
| 65 | 92 | rawLine: string, |
| ... | ... | @@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 104 | 131 | sessionId, |
| 105 | 132 | provider: "codex", |
| 106 | 133 | status: "connecting", |
| 107 | model: input.model, | |
| 134 | model: normalizeCodexModelSlug(input.model), | |
| 108 | 135 | cwd: input.cwd, |
| 109 | 136 | createdAt: now, |
| 110 | 137 | updatedAt: now, |
| ... | ... | @@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 150 | 177 | this.writeMessage(context, { method: "initialized" }); |
| 151 | 178 | |
| 152 | 179 | const threadStart = await this.sendRequest(context, "thread/start", { |
| 153 | model: input.model ?? null, | |
| 180 | model: null, | |
| 154 | 181 | cwd: input.cwd ?? null, |
| 155 | 182 | approvalPolicy: input.approvalPolicy, |
| 156 | 183 | sandbox: input.sandboxMode, |
| ... | ... | @@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 202 | 229 | threadId: string; |
| 203 | 230 | input: Array<{ type: "text"; text: string; text_elements: [] }>; |
| 204 | 231 | model?: string; |
| 232 | effort?: string; | |
| 205 | 233 | } = { |
| 206 | 234 | threadId: context.session.threadId, |
| 207 | 235 | input: [ |
| ... | ... | @@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 212 | 240 | }, |
| 213 | 241 | ], |
| 214 | 242 | }; |
| 215 | if (input.model) { | |
| 216 | turnStartParams.model = input.model; | |
| 243 | const normalizedModel = normalizeCodexModelSlug(input.model); | |
| 244 | if (normalizedModel) { | |
| 245 | turnStartParams.model = normalizedModel; | |
| 246 | } | |
| 247 | if (input.effort) { | |
| 248 | turnStartParams.effort = input.effort; | |
| 217 | 249 | } |
| 218 | 250 | |
| 219 | 251 | const response = await this.sendRequest( |
| ... | ... | @@ -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 | 274 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { |
| 414 | 275 | const context = this.requireSession(sessionId); |
| 415 | 276 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| ... | ... | @@ -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 | 671 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 871 | 672 | if (!value || typeof value !== "object") { |
| 872 | 673 | return false; |
apps/desktop/src/main.ts-9| ... | ... | @@ -139,15 +139,6 @@ function registerIpcHandlers(): void { |
| 139 | 139 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { |
| 140 | 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 | 144 | async function runTerminalCommand( |
apps/desktop/src/preload.ts-2| ... | ... | @@ -41,8 +41,6 @@ const nativeApi: NativeApi = { |
| 41 | 41 | stopSession: (input) => |
| 42 | 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), |
| 43 | 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), |
| 44 | listModels: (input) => | |
| 45 | ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input), | |
| 46 | 44 | onEvent: (callback) => { |
| 47 | 45 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => |
| 48 | 46 | callback(payload as Parameters<typeof callback>[0]); |
apps/desktop/src/providerManager.ts-14| ... | ... | @@ -3,15 +3,12 @@ import { EventEmitter } from "node:events"; |
| 3 | 3 | import { |
| 4 | 4 | type ProviderEvent, |
| 5 | 5 | type ProviderInterruptTurnInput, |
| 6 | type ProviderListModelsInput, | |
| 7 | type ProviderModel, | |
| 8 | 6 | type ProviderSendTurnInput, |
| 9 | 7 | type ProviderSession, |
| 10 | 8 | type ProviderSessionStartInput, |
| 11 | 9 | type ProviderStopSessionInput, |
| 12 | 10 | type ProviderTurnStartResult, |
| 13 | 11 | providerInterruptTurnInputSchema, |
| 14 | providerListModelsInputSchema, | |
| 15 | 12 | providerSendTurnInputSchema, |
| 16 | 13 | providerSessionStartInputSchema, |
| 17 | 14 | providerStopSessionInputSchema, |
| ... | ... | @@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { |
| 66 | 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 | 66 | stopAll(): void { |
| 81 | 67 | this.codex.stopAll(); |
| 82 | 68 | } |
apps/renderer/src/components/ChatView.tsx+236-148| ... | ... | @@ -1,4 +1,3 @@ |
| 1 | import type { ProviderModel } from "@acme/contracts"; | |
| 2 | 1 | import { |
| 3 | 2 | type FormEvent, |
| 4 | 3 | type KeyboardEvent, |
| ... | ... | @@ -8,10 +7,40 @@ import { |
| 8 | 7 | useState, |
| 9 | 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 | 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 | 45 | export default function ChatView() { |
| 17 | 46 | const { state, dispatch } = useStore(); |
| ... | ... | @@ -20,9 +49,10 @@ export default function ChatView() { |
| 20 | 49 | const [isSending, setIsSending] = useState(false); |
| 21 | 50 | const [isConnecting, setIsConnecting] = useState(false); |
| 22 | 51 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 23 | const [isLoadingModels, setIsLoadingModels] = useState(false); | |
| 24 | const [modelLoadError, setModelLoadError] = useState<string | null>(null); | |
| 25 | const [availableModels, setAvailableModels] = useState<ProviderModel[]>([]); | |
| 52 | const [selectedEffort, setSelectedEffort] = useState<string>( | |
| 53 | DEFAULT_REASONING, | |
| 54 | ); | |
| 55 | const [nowTick, setNowTick] = useState(() => Date.now()); | |
| 26 | 56 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 27 | 57 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 28 | 58 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| ... | ... | @@ -31,31 +61,42 @@ export default function ChatView() { |
| 31 | 61 | const activeProject = state.projects.find( |
| 32 | 62 | (p) => p.id === activeThread?.projectId, |
| 33 | 63 | ); |
| 34 | const selectedModel = | |
| 35 | activeThread?.model || activeProject?.model || DEFAULT_MODEL; | |
| 64 | const selectedModel = resolveModelSlug( | |
| 65 | activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, | |
| 66 | ); | |
| 36 | 67 | const phase = derivePhase(activeThread?.session ?? null); |
| 37 | const modelOptions = useMemo(() => { | |
| 38 | const deduped = new Map<string, ProviderModel>(); | |
| 39 | for (const model of availableModels) { | |
| 40 | deduped.set(model.model, model); | |
| 41 | } | |
| 42 | if (!deduped.has(selectedModel)) { | |
| 43 | deduped.set(selectedModel, { | |
| 44 | id: selectedModel, | |
| 45 | model: selectedModel, | |
| 46 | displayName: selectedModel, | |
| 47 | description: "Current model for this thread.", | |
| 48 | }); | |
| 68 | const isWorking = phase === "running" || isSending || isConnecting; | |
| 69 | const activeTurnId = activeThread?.session?.activeTurnId; | |
| 70 | const nowIso = new Date(nowTick).toISOString(); | |
| 71 | const modelOptions = MODEL_OPTIONS; | |
| 72 | const workLogEntries = useMemo( | |
| 73 | () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), | |
| 74 | [activeThread?.events, activeTurnId], | |
| 75 | ); | |
| 76 | const assistantCompletionByItemId = useMemo(() => { | |
| 77 | const map = new Map<string, string>(); | |
| 78 | const ordered = [...(activeThread?.events ?? [])].reverse(); | |
| 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()); | |
| 51 | }, [availableModels, selectedModel]); | |
| 84 | return map; | |
| 85 | }, [activeThread?.events]); | |
| 86 | const recentWorkLogEntries = workLogEntries.slice(-12); | |
| 52 | 87 | |
| 53 | 88 | // Auto-scroll on new messages |
| 54 | 89 | const messageCount = activeThread?.messages.length ?? 0; |
| 90 | const workLogCount = recentWorkLogEntries.length; | |
| 55 | 91 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change |
| 56 | 92 | useEffect(() => { |
| 57 | 93 | messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); |
| 58 | 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 | 101 | // Auto-resize textarea |
| 61 | 102 | // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change |
| ... | ... | @@ -66,6 +107,16 @@ export default function ChatView() { |
| 66 | 107 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; |
| 67 | 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 | 120 | useEffect(() => { |
| 70 | 121 | if (!isModelMenuOpen) return; |
| 71 | 122 | |
| ... | ... | @@ -85,48 +136,6 @@ export default function ChatView() { |
| 85 | 136 | }; |
| 86 | 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 | 139 | const ensureSession = async (): Promise<string | null> => { |
| 131 | 140 | if (!api || !activeThread || !activeProject) return null; |
| 132 | 141 | if (activeThread.session && activeThread.session.status !== "closed") { |
| ... | ... | @@ -197,6 +206,7 @@ export default function ChatView() { |
| 197 | 206 | sessionId, |
| 198 | 207 | input: trimmed, |
| 199 | 208 | model: selectedModel || undefined, |
| 209 | effort: selectedEffort || undefined, | |
| 200 | 210 | }); |
| 201 | 211 | } catch (err) { |
| 202 | 212 | dispatch({ |
| ... | ... | @@ -222,7 +232,7 @@ export default function ChatView() { |
| 222 | 232 | dispatch({ |
| 223 | 233 | type: "SET_THREAD_MODEL", |
| 224 | 234 | threadId: activeThread.id, |
| 225 | model, | |
| 235 | model: resolveModelSlug(model), | |
| 226 | 236 | }); |
| 227 | 237 | setIsModelMenuOpen(false); |
| 228 | 238 | }; |
| ... | ... | @@ -266,19 +276,38 @@ export default function ChatView() { |
| 266 | 276 | </div> |
| 267 | 277 | <div className="flex items-center gap-3"> |
| 268 | 278 | {/* Status indicator */} |
| 269 | <div className="flex items-center gap-1.5"> | |
| 270 | <div | |
| 271 | className={`h-2 w-2 rounded-full ${ | |
| 272 | phase === "running" | |
| 273 | ? "animate-pulse bg-emerald-400" | |
| 279 | <div | |
| 280 | className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] ${ | |
| 281 | phase === "running" | |
| 282 | ? "border-sky-400/35 bg-sky-500/[0.08] text-sky-100" | |
| 283 | : phase === "connecting" | |
| 284 | ? "border-amber-400/35 bg-amber-500/[0.08] text-amber-100" | |
| 274 | 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 | 302 | : phase === "connecting" |
| 277 | ? "animate-pulse bg-amber-400" | |
| 278 | : "bg-[#a0a0a0]/30" | |
| 279 | }`} | |
| 280 | /> | |
| 281 | <span className="text-[10px] text-[#a0a0a0]/50">{phase}</span> | |
| 303 | ? "bg-amber-200" | |
| 304 | : phase === "ready" | |
| 305 | ? "bg-emerald-200" | |
| 306 | : "bg-[#a0a0a0]/40" | |
| 307 | }`} | |
| 308 | /> | |
| 309 | </span> | |
| 310 | <span>{statusLabel(phase)}</span> | |
| 282 | 311 | </div> |
| 283 | 312 | {/* Diff toggle */} |
| 284 | 313 | <button |
| ... | ... | @@ -304,7 +333,7 @@ export default function ChatView() { |
| 304 | 333 | |
| 305 | 334 | {/* Messages */} |
| 306 | 335 | <div className="flex-1 overflow-y-auto px-5 py-4"> |
| 307 | {activeThread.messages.length === 0 ? ( | |
| 336 | {activeThread.messages.length === 0 && !isWorking ? ( | |
| 308 | 337 | <div className="flex h-full items-center justify-center"> |
| 309 | 338 | <p className="text-sm text-[#a0a0a0]/30"> |
| 310 | 339 | Send a message to start the conversation. |
| ... | ... | @@ -312,6 +341,53 @@ export default function ChatView() { |
| 312 | 341 | </div> |
| 313 | 342 | ) : ( |
| 314 | 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 | 391 | {activeThread.messages.map((msg) => ( |
| 316 | 392 | <div key={msg.id}> |
| 317 | 393 | {msg.role === "user" ? ( |
| ... | ... | @@ -331,17 +407,28 @@ export default function ChatView() { |
| 331 | 407 | {msg.text || (msg.streaming ? "" : "(empty response)")} |
| 332 | 408 | </pre> |
| 333 | 409 | {msg.streaming && ( |
| 334 | <span className="inline-flex gap-1 pt-1"> | |
| 335 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50" /> | |
| 336 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:150ms]" /> | |
| 337 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-white/50 [animation-delay:300ms]" /> | |
| 338 | </span> | |
| 339 | )} | |
| 340 | {!msg.streaming && ( | |
| 341 | <p className="mt-1.5 text-[10px] text-[#a0a0a0]/30"> | |
| 342 | {formatTimestamp(msg.createdAt)} | |
| 343 | </p> | |
| 410 | <div className="pt-1.5"> | |
| 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"> | |
| 412 | <span className="inline-flex gap-1"> | |
| 413 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80" /> | |
| 414 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:150ms]" /> | |
| 415 | <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-100/80 [animation-delay:300ms]" /> | |
| 416 | </span> | |
| 417 | <span>Thinking</span> | |
| 418 | </span> | |
| 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 | 432 | </div> |
| 346 | 433 | )} |
| 347 | 434 | </div> |
| ... | ... | @@ -373,70 +460,71 @@ export default function ChatView() { |
| 373 | 460 | disabled={isSending || isConnecting} |
| 374 | 461 | /> |
| 375 | 462 | <div className="mt-2 flex items-center justify-between gap-2"> |
| 376 | <div className="relative" ref={modelMenuRef}> | |
| 377 | <button | |
| 378 | type="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]" | |
| 380 | onClick={() => setIsModelMenuOpen((open) => !open)} | |
| 381 | > | |
| 382 | <span className="max-w-[180px] truncate"> | |
| 383 | {selectedModel} | |
| 384 | </span> | |
| 385 | <span className="text-[10px] text-[#a0a0a0]/70">▼</span> | |
| 386 | </button> | |
| 387 | {isModelMenuOpen && ( | |
| 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"> | |
| 389 | <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70"> | |
| 390 | Select model | |
| 391 | </p> | |
| 392 | <div className="max-h-72 overflow-y-auto"> | |
| 393 | {modelOptions.map((model) => { | |
| 394 | const isSelected = model.model === selectedModel; | |
| 395 | return ( | |
| 396 | <button | |
| 397 | key={model.id} | |
| 398 | type="button" | |
| 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 ${ | |
| 400 | isSelected | |
| 401 | ? "bg-white/[0.08] text-white" | |
| 402 | : "text-[#d4d4d4] hover:bg-white/[0.05]" | |
| 403 | }`} | |
| 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" | |
| 463 | <div className="flex items-center gap-2"> | |
| 464 | <div className="relative" ref={modelMenuRef}> | |
| 465 | <button | |
| 466 | type="button" | |
| 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]" | |
| 468 | onClick={() => setIsModelMenuOpen((open) => !open)} | |
| 469 | > | |
| 470 | <span className="max-w-[180px] truncate font-mono"> | |
| 471 | {selectedModel} | |
| 472 | </span> | |
| 473 | <span className="text-[10px] text-[#a0a0a0]/70">▼</span> | |
| 474 | </button> | |
| 475 | {isModelMenuOpen && ( | |
| 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"> | |
| 477 | <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70"> | |
| 478 | Select model | |
| 479 | </p> | |
| 480 | <div className="max-h-72 overflow-y-auto"> | |
| 481 | {modelOptions.map((model) => { | |
| 482 | const isSelected = model === selectedModel; | |
| 483 | return ( | |
| 484 | <button | |
| 485 | key={model} | |
| 486 | type="button" | |
| 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 ${ | |
| 488 | isSelected | |
| 489 | ? "bg-white/[0.08] text-white" | |
| 490 | : "text-[#d4d4d4] hover:bg-white/[0.05]" | |
| 419 | 491 | }`} |
| 492 | onClick={() => onModelSelect(model)} | |
| 420 | 493 | > |
| 421 | ✓ | |
| 422 | </span> | |
| 423 | </button> | |
| 424 | ); | |
| 425 | })} | |
| 494 | <span className="truncate">{model}</span> | |
| 495 | <span | |
| 496 | className={`pt-0.5 text-sm ${ | |
| 497 | isSelected ? "text-white" : "text-transparent" | |
| 498 | }`} | |
| 499 | > | |
| 500 | ✓ | |
| 501 | </span> | |
| 502 | </button> | |
| 503 | ); | |
| 504 | })} | |
| 505 | </div> | |
| 426 | 506 | </div> |
| 427 | {isLoadingModels && ( | |
| 428 | <p className="px-2 pt-2 text-[10px] text-[#a0a0a0]/60"> | |
| 429 | Refreshing available models... | |
| 430 | </p> | |
| 431 | )} | |
| 432 | {modelLoadError && ( | |
| 433 | <p className="px-2 pt-2 text-[10px] text-amber-200/80"> | |
| 434 | Could not load full model list from Codex:{" "} | |
| 435 | {modelLoadError} | |
| 436 | </p> | |
| 437 | )} | |
| 438 | </div> | |
| 439 | )} | |
| 507 | )} | |
| 508 | </div> | |
| 509 | <label | |
| 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]" | |
| 511 | htmlFor="reasoning-effort" | |
| 512 | > | |
| 513 | <span>Reasoning</span> | |
| 514 | <select | |
| 515 | id="reasoning-effort" | |
| 516 | className="bg-transparent font-mono text-xs text-[#d8d8d8] outline-none" | |
| 517 | value={selectedEffort} | |
| 518 | onChange={(event) => setSelectedEffort(event.target.value)} | |
| 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 | 528 | </div> |
| 441 | 529 | {activeProject && ( |
| 442 | 530 | <span className="text-[10px] text-[#a0a0a0]/35"> |
apps/renderer/src/components/Sidebar.tsx+11-6| ... | ... | @@ -1,9 +1,8 @@ |
| 1 | 1 | import { useState } from "react"; |
| 2 | import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic"; | |
| 2 | 3 | import { useStore } from "../store"; |
| 3 | 4 | import type { Project } from "../types"; |
| 4 | 5 | |
| 5 | const DEFAULT_MODEL = "gpt-5.2-codex"; | |
| 6 | ||
| 7 | 6 | function formatRelativeTime(iso: string): string { |
| 8 | 7 | const diff = Date.now() - new Date(iso).getTime(); |
| 9 | 8 | const minutes = Math.floor(diff / 60_000); |
| ... | ... | @@ -24,11 +23,12 @@ export default function Sidebar() { |
| 24 | 23 | const cwd = newCwd.trim(); |
| 25 | 24 | if (!cwd) return; |
| 26 | 25 | const name = cwd.split("/").filter(Boolean).pop() ?? "project"; |
| 26 | const normalizedModel = resolveModelSlug(newModel); | |
| 27 | 27 | const project: Project = { |
| 28 | 28 | id: crypto.randomUUID(), |
| 29 | 29 | name, |
| 30 | 30 | cwd, |
| 31 | model: newModel.trim() || DEFAULT_MODEL, | |
| 31 | model: normalizedModel, | |
| 32 | 32 | expanded: true, |
| 33 | 33 | }; |
| 34 | 34 | dispatch({ type: "ADD_PROJECT", project }); |
| ... | ... | @@ -186,16 +186,21 @@ export default function Sidebar() { |
| 186 | 186 | if (e.key === "Escape") setAddingProject(false); |
| 187 | 187 | }} |
| 188 | 188 | /> |
| 189 | <input | |
| 189 | <select | |
| 190 | 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 | 191 | value={newModel} |
| 193 | 192 | onChange={(e) => setNewModel(e.target.value)} |
| 194 | 193 | onKeyDown={(e) => { |
| 195 | 194 | if (e.key === "Enter") handleAddProject(); |
| 196 | 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 | 204 | <div className="flex gap-2"> |
| 200 | 205 | <button |
| 201 | 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 | 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 | 280 | export function derivePhase(session: ProviderSession | null): SessionPhase { |
| 40 | 281 | if (!session || session.status === "closed") return "disconnected"; |
| 41 | 282 | if (session.status === "connecting") return "connecting"; |
apps/renderer/src/store.ts+49-11| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | } from "react"; |
| 10 | 10 | |
| 11 | 11 | import type { ProviderEvent, ProviderSession } from "@acme/contracts"; |
| 12 | import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; | |
| 12 | 13 | import { applyEventToMessages, evolveSession } from "./session-logic"; |
| 13 | 14 | import type { Project, Thread } from "./types"; |
| 14 | 15 | |
| ... | ... | @@ -40,7 +41,9 @@ export interface AppState { |
| 40 | 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 | 48 | const initialState: AppState = { |
| 46 | 49 | projects: [], |
| ... | ... | @@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] { |
| 86 | 89 | const id = asString(project.id); |
| 87 | 90 | const name = asString(project.name); |
| 88 | 91 | const cwd = asString(project.cwd); |
| 89 | const model = asString(project.model); | |
| 92 | const rawModel = asString(project.model); | |
| 93 | const model = resolveModelSlug(rawModel); | |
| 90 | 94 | const expanded = project.expanded; |
| 91 | 95 | if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null; |
| 92 | 96 | |
| ... | ... | @@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] { |
| 101 | 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 | 112 | function sanitizeMessages(input: unknown): Thread["messages"] { |
| 105 | 113 | if (!Array.isArray(input)) return []; |
| 106 | 114 | |
| ... | ... | @@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] { |
| 149 | 157 | const id = asString(thread.id); |
| 150 | 158 | const projectId = asString(thread.projectId); |
| 151 | 159 | const title = asString(thread.title); |
| 152 | const model = asString(thread.model); | |
| 160 | const rawModel = asString(thread.model); | |
| 161 | const model = resolveModelSlug(rawModel); | |
| 153 | 162 | const createdAt = asString(thread.createdAt); |
| 154 | 163 | if (!id || !projectId || !title || !model || !createdAt) return null; |
| 155 | 164 | |
| ... | ... | @@ -173,17 +182,30 @@ function readPersistedState(): AppState { |
| 173 | 182 | if (typeof window === "undefined") return initialState; |
| 174 | 183 | |
| 175 | 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 | 188 | if (!raw) return initialState; |
| 189 | const isLegacyPayload = !rawCurrent && Boolean(rawLegacy); | |
| 178 | 190 | |
| 179 | 191 | const parsed = asObject(JSON.parse(raw)); |
| 180 | 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 | 200 | const projectIds = new Set(projects.map((project) => project.id)); |
| 184 | const threads = sanitizeThreads(parsed.threads).filter((thread) => | |
| 185 | projectIds.has(thread.projectId), | |
| 186 | ); | |
| 201 | const threads = sanitizeThreads(parsed.threads) | |
| 202 | .map((thread) => ({ | |
| 203 | ...thread, | |
| 204 | model: isLegacyPayload | |
| 205 | ? migrateLegacyDefaultModel(thread.model) | |
| 206 | : thread.model, | |
| 207 | })) | |
| 208 | .filter((thread) => projectIds.has(thread.projectId)); | |
| 187 | 209 | const activeThreadId = asString(parsed.activeThreadId); |
| 188 | 210 | const hasActiveThread = Boolean( |
| 189 | 211 | activeThreadId && threads.some((thread) => thread.id === activeThreadId), |
| ... | ... | @@ -227,6 +249,7 @@ function persistState(state: AppState): void { |
| 227 | 249 | PERSISTED_STATE_KEY, |
| 228 | 250 | JSON.stringify(toPersistedState(state)), |
| 229 | 251 | ); |
| 252 | window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY); | |
| 230 | 253 | } catch { |
| 231 | 254 | // Ignore quota/storage errors to avoid breaking chat UX. |
| 232 | 255 | } |
| ... | ... | @@ -252,7 +275,16 @@ function findThreadBySessionId( |
| 252 | 275 | function reducer(state: AppState, action: Action): AppState { |
| 253 | 276 | switch (action.type) { |
| 254 | 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 | 289 | case "TOGGLE_PROJECT": |
| 258 | 290 | return { |
| ... | ... | @@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState { |
| 265 | 297 | case "ADD_THREAD": |
| 266 | 298 | return { |
| 267 | 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 | 307 | activeThreadId: action.thread.id, |
| 270 | 308 | }; |
| 271 | 309 | |
| ... | ... | @@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState { |
| 349 | 387 | ...state, |
| 350 | 388 | threads: updateThread(state.threads, action.threadId, (t) => ({ |
| 351 | 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 | 2 | import type { |
| 3 | 3 | ProviderEvent, |
| 4 | 4 | ProviderInterruptTurnInput, |
| 5 | ProviderListModelsInput, | |
| 6 | ProviderModel, | |
| 7 | 5 | ProviderSendTurnInput, |
| 8 | 6 | ProviderSession, |
| 9 | 7 | ProviderSessionStartInput, |
| ... | ... | @@ -29,7 +27,6 @@ export const IPC_CHANNELS = { |
| 29 | 27 | providerTurnInterrupt: "provider:turn:interrupt", |
| 30 | 28 | providerSessionStop: "provider:session:stop", |
| 31 | 29 | providerSessionList: "provider:session:list", |
| 32 | providerModelList: "provider:model:list", | |
| 33 | 30 | providerEvent: "provider:event", |
| 34 | 31 | } as const; |
| 35 | 32 | |
| ... | ... | @@ -60,7 +57,6 @@ export interface NativeApi { |
| 60 | 57 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 61 | 58 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 62 | 59 | listSessions: () => Promise<ProviderSession[]>; |
| 63 | listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>; | |
| 64 | 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 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | providerEventSchema, |
| 5 | providerListModelsInputSchema, | |
| 6 | 5 | providerSendTurnInputSchema, |
| 7 | 6 | providerSessionStartInputSchema, |
| 8 | 7 | } from "./provider"; |
| ... | ... | @@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => { |
| 17 | 16 | }); |
| 18 | 17 | |
| 19 | 18 | describe("providerSendTurnInputSchema", () => { |
| 20 | it("trims input text and optional model", () => { | |
| 19 | it("trims input text and optional model/effort", () => { | |
| 21 | 20 | const parsed = providerSendTurnInputSchema.parse({ |
| 22 | 21 | sessionId: "sess_1", |
| 23 | 22 | input: " summarize this repo ", |
| 24 | 23 | model: " gpt-5.2-codex ", |
| 24 | effort: " high ", | |
| 25 | 25 | }); |
| 26 | 26 | expect(parsed.input).toBe("summarize this repo"); |
| 27 | 27 | expect(parsed.model).toBe("gpt-5.2-codex"); |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | describe("providerListModelsInputSchema", () => { | |
| 32 | it("defaults provider to codex", () => { | |
| 33 | const parsed = providerListModelsInputSchema.parse({}); | |
| 34 | expect(parsed.provider).toBe("codex"); | |
| 28 | expect(parsed.effort).toBe("high"); | |
| 35 | 29 | }); |
| 36 | 30 | }); |
| 37 | 31 |
packages/contracts/src/provider.ts+1-18| ... | ... | @@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({ |
| 48 | 48 | sessionId: z.string().min(1), |
| 49 | 49 | input: z.string().trim().min(1).max(120_000), |
| 50 | 50 | model: z.string().trim().min(1).optional(), |
| 51 | effort: z.string().trim().min(1).optional(), | |
| 51 | 52 | }); |
| 52 | 53 | |
| 53 | 54 | export const providerTurnStartResultSchema = z.object({ |
| ... | ... | @@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({ |
| 64 | 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 | 68 | export const providerEventKindSchema = z.enum([ |
| 82 | 69 | "session", |
| 83 | 70 | "notification", |
| ... | ... | @@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input< |
| 120 | 107 | export type ProviderStopSessionInput = z.input< |
| 121 | 108 | typeof providerStopSessionInputSchema |
| 122 | 109 | >; |
| 123 | export type ProviderModel = z.infer<typeof providerModelSchema>; | |
| 124 | export type ProviderListModelsInput = z.input< | |
| 125 | typeof providerListModelsInputSchema | |
| 126 | >; | |
| 127 | 110 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 128 | 111 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |