History

progress

Theo Browne committed 9 months ago

Commit fdb654d1 parent 733c46316 files · +695 −458Browse files

Unified · Split

16 files
  1. .agents/skills/btca-cli/SKILL.md +20 −0
  2. .agents/skills/btca-cli/agents/openai.yaml +3 −0
  3. apps/desktop/src/codexAppServerManager.test.ts +22 −1
  4. apps/desktop/src/codexAppServerManager.ts +37 −236
  5. apps/desktop/src/main.ts +0 −9
  6. apps/desktop/src/preload.ts +0 −2
  7. apps/desktop/src/providerManager.ts +0 −14
  8. apps/renderer/src/components/ChatView.tsx +236 −148
  9. apps/renderer/src/components/Sidebar.tsx +11 −6
  10. apps/renderer/src/model-logic.test.ts +33 −0
  11. apps/renderer/src/model-logic.ts +39 −0
  12. apps/renderer/src/session-logic.ts +241 −0
  13. apps/renderer/src/store.ts +49 −11
  14. packages/contracts/src/ipc.ts +0 −4
  15. packages/contracts/src/provider.test.ts +3 −9
  16. packages/contracts/src/provider.ts +1 −18
.agents/skills/btca-cli/SKILL.md+20−0
@@ -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+3−0
@@ -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 @@
11 import { describe, expect, it } from "vitest";
22
3−import { classifyCodexStderrLine } from "./codexAppServerManager";
3+import {
4+ classifyCodexStderrLine,
5+ normalizeCodexModelSlug,
6+} from "./codexAppServerManager";
47
58 describe("classifyCodexStderrLine", () => {
69 it("ignores empty lines", () => {
@@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => { · 27 unmodified lines
3437 });
3538 });
3639 });
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"; · 4 unmodified lines
55
66 import type {
77 ProviderEvent,
8− ProviderModel,
98 ProviderSendTurnInput,
109 ProviderSession,
1110 ProviderSessionStartInput,
@@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ · 48 unmodified lines
6059 "state db missing rollout path for thread",
6160 "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back",
6261 ];
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+}
6390
6491 export function classifyCodexStderrLine(
6592 rawLine: string,
@@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 38 unmodified lines
104131 sessionId,
105132 provider: "codex",
106133 status: "connecting",
107− model: input.model,
134+ model: normalizeCodexModelSlug(input.model),
108135 cwd: input.cwd,
109136 createdAt: now,
110137 updatedAt: now,
@@ -150,7 +177,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 39 unmodified lines
150177 this.writeMessage(context, { method: "initialized" });
151178
152179 const threadStart = await this.sendRequest(context, "thread/start", {
153− model: input.model ?? null,
180+ model: null,
154181 cwd: input.cwd ?? null,
155182 approvalPolicy: input.approvalPolicy,
156183 sandbox: input.sandboxMode,
@@ -202,6 +229,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 45 unmodified lines
202229 threadId: string;
203230 input: Array<{ type: "text"; text: string; text_elements: [] }>;
204231 model?: string;
232+ effort?: string;
205233 } = {
206234 threadId: context.session.threadId,
207235 input: [
@@ -212,8 +240,12 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines
212240 },
213241 ],
214242 };
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;
217249 }
218250
219251 const response = await this.sendRequest(
@@ -239,177 +271,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 19 unmodified lines
239271 };
240272 }
241273
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−
413274 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
414275 const context = this.requireSession(sessionId);
415276 const effectiveTurnId = turnId ?? context.session.activeTurnId;
@@ -807,66 +668,6 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 391 unmodified lines
807668 };
808669 }
809670
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−
870671 private isServerRequest(value: unknown): value is JsonRpcRequest {
871672 if (!value || typeof value !== "object") {
872673 return false;
apps/desktop/src/main.ts+0−9
@@ -139,15 +139,6 @@ function registerIpcHandlers(): void { · 138 unmodified lines
139139 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {
140140 return providerManager.listSessions();
141141 });
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− );
151142 }
152143
153144 async function runTerminalCommand(
apps/desktop/src/preload.ts+0−2
@@ -41,8 +41,6 @@ const nativeApi: NativeApi = { · 40 unmodified lines
4141 stopSession: (input) =>
4242 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),
4343 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),
44− listModels: (input) =>
45− ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input),
4644 onEvent: (callback) => {
4745 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>
4846 callback(payload as Parameters<typeof callback>[0]);
apps/desktop/src/providerManager.ts+0−14
@@ -3,15 +3,12 @@ import { EventEmitter } from "node:events"; · 2 unmodified lines
33 import {
44 type ProviderEvent,
55 type ProviderInterruptTurnInput,
6− type ProviderListModelsInput,
7− type ProviderModel,
86 type ProviderSendTurnInput,
97 type ProviderSession,
108 type ProviderSessionStartInput,
119 type ProviderStopSessionInput,
1210 type ProviderTurnStartResult,
1311 providerInterruptTurnInputSchema,
14− providerListModelsInputSchema,
1512 providerSendTurnInputSchema,
1613 providerSessionStartInputSchema,
1714 providerStopSessionInputSchema,
@@ -66,17 +63,6 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 48 unmodified lines
6663 return this.codex.listSessions();
6764 }
6865
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−
8066 stopAll(): void {
8167 this.codex.stopAll();
8268 }
apps/renderer/src/components/ChatView.tsx+236−148
@@ -1,4 +1,3 @@
1−import type { ProviderModel } from "@acme/contracts";
21 import {
32 type FormEvent,
43 type KeyboardEvent,
@@ -8,10 +7,40 @@ import { · 3 unmodified lines
87 useState,
98 } from "react";
109
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";
1224 import { useStore } from "../store";
1325
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+}
1544
1645 export default function ChatView() {
1746 const { state, dispatch } = useStore();
@@ -20,9 +49,10 @@ export default function ChatView() { · 2 unmodified lines
2049 const [isSending, setIsSending] = useState(false);
2150 const [isConnecting, setIsConnecting] = useState(false);
2251 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());
2656 const messagesEndRef = useRef<HTMLDivElement>(null);
2757 const textareaRef = useRef<HTMLTextAreaElement>(null);
2858 const modelMenuRef = useRef<HTMLDivElement>(null);
@@ -31,31 +61,42 @@ export default function ChatView() { · 2 unmodified lines
3161 const activeProject = state.projects.find(
3262 (p) => p.id === activeThread?.projectId,
3363 );
34− const selectedModel =
35− activeThread?.model || activeProject?.model || DEFAULT_MODEL;
64+ const selectedModel = resolveModelSlug(
65+ activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL,
66+ );
3667 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);
4983 }
50− return Array.from(deduped.values());
51− }, [availableModels, selectedModel]);
84+ return map;
85+ }, [activeThread?.events]);
86+ const recentWorkLogEntries = workLogEntries.slice(-12);
5287
5388 // Auto-scroll on new messages
5489 const messageCount = activeThread?.messages.length ?? 0;
90+ const workLogCount = recentWorkLogEntries.length;
5591 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change
5692 useEffect(() => {
5793 messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
5894 }, [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]);
59100
60101 // Auto-resize textarea
61102 // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change
@@ -66,6 +107,16 @@ export default function ChatView() { · 4 unmodified lines
66107 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
67108 }, [prompt]);
68109
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+
69120 useEffect(() => {
70121 if (!isModelMenuOpen) return;
71122
@@ -85,48 +136,6 @@ export default function ChatView() { · 13 unmodified lines
85136 };
86137 }, [isModelMenuOpen]);
87138
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−
130139 const ensureSession = async (): Promise<string | null> => {
131140 if (!api || !activeThread || !activeProject) return null;
132141 if (activeThread.session && activeThread.session.status !== "closed") {
@@ -197,6 +206,7 @@ export default function ChatView() { · 64 unmodified lines
197206 sessionId,
198207 input: trimmed,
199208 model: selectedModel || undefined,
209+ effort: selectedEffort || undefined,
200210 });
201211 } catch (err) {
202212 dispatch({
@@ -222,7 +232,7 @@ export default function ChatView() { · 19 unmodified lines
222232 dispatch({
223233 type: "SET_THREAD_MODEL",
224234 threadId: activeThread.id,
225− model,
235+ model: resolveModelSlug(model),
226236 });
227237 setIsModelMenuOpen(false);
228238 };
@@ -266,19 +276,38 @@ export default function ChatView() { · 37 unmodified lines
266276 </div>
267277 <div className="flex items-center gap-3">
268278 {/* 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"
274285 : 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"
276302 : 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>
282311 </div>
283312 {/* Diff toggle */}
284313 <button
@@ -304,7 +333,7 @@ export default function ChatView() { · 19 unmodified lines
304333
305334 {/* Messages */}
306335 <div className="flex-1 overflow-y-auto px-5 py-4">
307− {activeThread.messages.length === 0 ? (
336+ {activeThread.messages.length === 0 && !isWorking ? (
308337 <div className="flex h-full items-center justify-center">
309338 <p className="text-sm text-[#a0a0a0]/30">
310339 Send a message to start the conversation.
@@ -312,6 +341,53 @@ export default function ChatView() { · 1 unmodified line
312341 </div>
313342 ) : (
314343 <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+ )}
315391 {activeThread.messages.map((msg) => (
316392 <div key={msg.id}>
317393 {msg.role === "user" ? (
@@ -331,17 +407,28 @@ export default function ChatView() { · 13 unmodified lines
331407 {msg.text || (msg.streaming ? "" : "(empty response)")}
332408 </pre>
333409 {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>
344420 )}
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>
345432 </div>
346433 )}
347434 </div>
@@ -373,70 +460,71 @@ export default function ChatView() { · 25 unmodified lines
373460 disabled={isSending || isConnecting}
374461 />
375462 <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]"
419491 }`}
492+ onClick={() => onModelSelect(model)}
420493 >
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>
426506 </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>
440528 </div>
441529 {activeProject && (
442530 <span className="text-[10px] text-[#a0a0a0]/35">
apps/renderer/src/components/Sidebar.tsx+11−6
@@ -1,9 +1,8 @@
11 import { useState } from "react";
2+import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic";
23 import { useStore } from "../store";
34 import type { Project } from "../types";
45
5−const DEFAULT_MODEL = "gpt-5.2-codex";
6−
76 function formatRelativeTime(iso: string): string {
87 const diff = Date.now() - new Date(iso).getTime();
98 const minutes = Math.floor(diff / 60_000);
@@ -24,11 +23,12 @@ export default function Sidebar() { · 14 unmodified lines
2423 const cwd = newCwd.trim();
2524 if (!cwd) return;
2625 const name = cwd.split("/").filter(Boolean).pop() ?? "project";
26+ const normalizedModel = resolveModelSlug(newModel);
2727 const project: Project = {
2828 id: crypto.randomUUID(),
2929 name,
3030 cwd,
31− model: newModel.trim() || DEFAULT_MODEL,
31+ model: normalizedModel,
3232 expanded: true,
3333 };
3434 dispatch({ type: "ADD_PROJECT", project });
@@ -186,16 +186,21 @@ export default function Sidebar() { · 151 unmodified lines
186186 if (e.key === "Escape") setAddingProject(false);
187187 }}
188188 />
189− <input
189+ <select
190190 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})`}
192191 value={newModel}
193192 onChange={(e) => setNewModel(e.target.value)}
194193 onKeyDown={(e) => {
195194 if (e.key === "Enter") handleAddProject();
196195 if (e.key === "Escape") setAddingProject(false);
197196 }}
198− />
197+ >
198+ {MODEL_OPTIONS.map((model) => (
199+ <option key={model} value={model} className="bg-[#141414]">
200+ {model}
201+ </option>
202+ ))}
203+ </select>
199204 <div className="flex gap-2">
200205 <button
201206 type="button"
apps/renderer/src/model-logic.test.ts+33−0
@@ -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+39−0
@@ -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−0
@@ -36,6 +36,247 @@ export function formatTimestamp(isoDate: string): string { · 35 unmodified lines
3636 }).format(new Date(isoDate));
3737 }
3838
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+
39280 export function derivePhase(session: ProviderSession | null): SessionPhase {
40281 if (!session || session.status === "closed") return "disconnected";
41282 if (session.status === "connecting") return "connecting";
apps/renderer/src/store.ts+49−11
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 } from "react";
1010
1111 import type { ProviderEvent, ProviderSession } from "@acme/contracts";
12+import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic";
1213 import { applyEventToMessages, evolveSession } from "./session-logic";
1314 import type { Project, Thread } from "./types";
1415
@@ -40,7 +41,9 @@ export interface AppState { · 25 unmodified lines
4041 diffOpen: boolean;
4142 }
4243
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";
4447
4548 const initialState: AppState = {
4649 projects: [],
@@ -86,7 +89,8 @@ function sanitizeProjects(input: unknown): Project[] { · 39 unmodified lines
8689 const id = asString(project.id);
8790 const name = asString(project.name);
8891 const cwd = asString(project.cwd);
89− const model = asString(project.model);
92+ const rawModel = asString(project.model);
93+ const model = resolveModelSlug(rawModel);
9094 const expanded = project.expanded;
9195 if (!id || !name || !cwd || !model || !isBoolean(expanded)) return null;
9296
@@ -101,6 +105,10 @@ function sanitizeProjects(input: unknown): Project[] { · 8 unmodified lines
101105 .filter((project): project is Project => project !== null);
102106 }
103107
108+function migrateLegacyDefaultModel(model: string): string {
109+ return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model;
110+}
111+
104112 function sanitizeMessages(input: unknown): Thread["messages"] {
105113 if (!Array.isArray(input)) return [];
106114
@@ -149,7 +157,8 @@ function sanitizeThreads(input: unknown): AppState["threads"] { · 42 unmodified lines
149157 const id = asString(thread.id);
150158 const projectId = asString(thread.projectId);
151159 const title = asString(thread.title);
152− const model = asString(thread.model);
160+ const rawModel = asString(thread.model);
161+ const model = resolveModelSlug(rawModel);
153162 const createdAt = asString(thread.createdAt);
154163 if (!id || !projectId || !title || !model || !createdAt) return null;
155164
@@ -173,17 +182,30 @@ function readPersistedState(): AppState { · 17 unmodified lines
173182 if (typeof window === "undefined") return initialState;
174183
175184 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;
177188 if (!raw) return initialState;
189+ const isLegacyPayload = !rawCurrent && Boolean(rawLegacy);
178190
179191 const parsed = asObject(JSON.parse(raw));
180192 if (!parsed) return initialState;
181193
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+ }));
183200 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));
187209 const activeThreadId = asString(parsed.activeThreadId);
188210 const hasActiveThread = Boolean(
189211 activeThreadId && threads.some((thread) => thread.id === activeThreadId),
@@ -227,6 +249,7 @@ function persistState(state: AppState): void { · 37 unmodified lines
227249 PERSISTED_STATE_KEY,
228250 JSON.stringify(toPersistedState(state)),
229251 );
252+ window.localStorage.removeItem(LEGACY_PERSISTED_STATE_KEY);
230253 } catch {
231254 // Ignore quota/storage errors to avoid breaking chat UX.
232255 }
@@ -252,7 +275,16 @@ function findThreadBySessionId( · 19 unmodified lines
252275 function reducer(state: AppState, action: Action): AppState {
253276 switch (action.type) {
254277 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+ };
256288
257289 case "TOGGLE_PROJECT":
258290 return {
@@ -265,7 +297,13 @@ function reducer(state: AppState, action: Action): AppState { · 6 unmodified lines
265297 case "ADD_THREAD":
266298 return {
267299 ...state,
268− threads: [...state.threads, action.thread],
300+ threads: [
301+ ...state.threads,
302+ {
303+ ...action.thread,
304+ model: resolveModelSlug(action.thread.model),
305+ },
306+ ],
269307 activeThreadId: action.thread.id,
270308 };
271309
@@ -349,7 +387,7 @@ function reducer(state: AppState, action: Action): AppState { · 77 unmodified lines
349387 ...state,
350388 threads: updateThread(state.threads, action.threadId, (t) => ({
351389 ...t,
352− model: action.model,
390+ model: resolveModelSlug(action.model),
353391 })),
354392 };
355393
packages/contracts/src/ipc.ts+0−4
@@ -2,8 +2,6 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; · 1 unmodified line
22 import type {
33 ProviderEvent,
44 ProviderInterruptTurnInput,
5− ProviderListModelsInput,
6− ProviderModel,
75 ProviderSendTurnInput,
86 ProviderSession,
97 ProviderSessionStartInput,
@@ -29,7 +27,6 @@ export const IPC_CHANNELS = { · 19 unmodified lines
2927 providerTurnInterrupt: "provider:turn:interrupt",
3028 providerSessionStop: "provider:session:stop",
3129 providerSessionList: "provider:session:list",
32− providerModelList: "provider:model:list",
3330 providerEvent: "provider:event",
3431 } as const;
3532
@@ -60,7 +57,6 @@ export interface NativeApi { · 24 unmodified lines
6057 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
6158 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
6259 listSessions: () => Promise<ProviderSession[]>;
63− listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>;
6460 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
6561 };
6662 }
packages/contracts/src/provider.test.ts+3−9
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line
22
33 import {
44 providerEventSchema,
5− providerListModelsInputSchema,
65 providerSendTurnInputSchema,
76 providerSessionStartInputSchema,
87 } from "./provider";
@@ -17,21 +16,16 @@ describe("providerSessionStartInputSchema", () => { · 8 unmodified lines
1716 });
1817
1918 describe("providerSendTurnInputSchema", () => {
20− it("trims input text and optional model", () => {
19+ it("trims input text and optional model/effort", () => {
2120 const parsed = providerSendTurnInputSchema.parse({
2221 sessionId: "sess_1",
2322 input: " summarize this repo ",
2423 model: " gpt-5.2-codex ",
24+ effort: " high ",
2525 });
2626 expect(parsed.input).toBe("summarize this repo");
2727 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");
3529 });
3630 });
3731
packages/contracts/src/provider.ts+1−18
@@ -48,6 +48,7 @@ export const providerSendTurnInputSchema = z.object({ · 47 unmodified lines
4848 sessionId: z.string().min(1),
4949 input: z.string().trim().min(1).max(120_000),
5050 model: z.string().trim().min(1).optional(),
51+ effort: z.string().trim().min(1).optional(),
5152 });
5253
5354 export const providerTurnStartResultSchema = z.object({
@@ -64,20 +65,6 @@ export const providerStopSessionInputSchema = z.object({ · 10 unmodified lines
6465 sessionId: z.string().min(1),
6566 });
6667
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−
8168 export const providerEventKindSchema = z.enum([
8269 "session",
8370 "notification",
@@ -120,9 +107,5 @@ export type ProviderInterruptTurnInput = z.input< · 36 unmodified lines
120107 export type ProviderStopSessionInput = z.input<
121108 typeof providerStopSessionInputSchema
122109 >;
123−export type ProviderModel = z.infer<typeof providerModelSchema>;
124−export type ProviderListModelsInput = z.input<
125− typeof providerListModelsInputSchema
126−>;
127110 export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
128111 export type ProviderEvent = z.infer<typeof providerEventSchema>;