authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 20:58:25-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-07 20:58:25-08:00
log548cd113b25e9e839e094c08cb4ce96366a9cb8b
treecf9b86aa70ccf138506b8cb89a341eaa441e1e98
parent13501e85f61f2b004c3dc2819ccfb237fe30e305
signature Commit is signed but in an unrecognized format.

model picker


12 files changed, 522 insertions(+), 19 deletions(-)

TODO.md created+10
...@@ -0,0 +1,10 @@
1- [ ] Folder picker for new projects
2- [ ] Projects should be persisted so they're still visible after closing
3- [ ] Remove model selection from project creation
4- [ ] First message vanishes when thread is created?? Awful ux
5- [ ] Show more clearly when thinking is happening
6
7## Bigger overhauls
8
9- [ ] Markdown rendering
10- [ ] Code block rendering
apps/desktop/src/codexAppServerManager.ts+247-2
...@@ -5,6 +5,7 @@ import readline from "node:readline";...@@ -5,6 +5,7 @@ import readline from "node:readline";
55
6import type {6import type {
7 ProviderEvent,7 ProviderEvent,
8 ProviderModel,
8 ProviderSendTurnInput,9 ProviderSendTurnInput,
9 ProviderSession,10 ProviderSession,
10 ProviderSessionStartInput,11 ProviderSessionStartInput,
...@@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
197 throw new Error("Session is missing a thread id.");198 throw new Error("Session is missing a thread id.");
198 }199 }
199200
200 const response = await this.sendRequest(context, "turn/start", {201 const turnStartParams: {
202 threadId: string;
203 input: Array<{ type: "text"; text: string; text_elements: [] }>;
204 model?: string;
205 } = {
201 threadId: context.session.threadId,206 threadId: context.session.threadId,
202 input: [207 input: [
203 {208 {
...@@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
206 text_elements: [],211 text_elements: [],
207 },212 },
208 ],213 ],
209 });214 };
215 if (input.model) {
216 turnStartParams.model = input.model;
217 }
218
219 const response = await this.sendRequest(
220 context,
221 "turn/start",
222 turnStartParams,
223 );
210224
211 const turn = this.readObject(this.readObject(response), "turn");225 const turn = this.readObject(this.readObject(response), "turn");
212 const turnId = this.readString(turn, "id");226 const turnId = this.readString(turn, "id");
...@@ -225,6 +239,177 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -225,6 +239,177 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
225 };239 };
226 }240 }
227241
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
228 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {413 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
229 const context = this.requireSession(sessionId);414 const context = this.requireSession(sessionId);
230 const effectiveTurnId = turnId ?? context.session.activeTurnId;415 const effectiveTurnId = turnId ?? context.session.activeTurnId;
...@@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
622 };807 };
623 }808 }
624809
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
625 private isServerRequest(value: unknown): value is JsonRpcRequest {870 private isServerRequest(value: unknown): value is JsonRpcRequest {
626 if (!value || typeof value !== "object") {871 if (!value || typeof value !== "object") {
627 return false;872 return false;
apps/desktop/src/main.ts+9
...@@ -139,6 +139,15 @@ function registerIpcHandlers(): void {...@@ -139,6 +139,15 @@ function registerIpcHandlers(): void {
139 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {139 ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => {
140 return providerManager.listSessions();140 return providerManager.listSessions();
141 });141 });
142
143 ipcMain.handle(
144 IPC_CHANNELS.providerModelList,
145 async (_event, payload: unknown) => {
146 return providerManager.listModels(
147 payload as Parameters<typeof providerManager.listModels>[0],
148 );
149 },
150 );
142}151}
143152
144async function runTerminalCommand(153async function runTerminalCommand(
apps/desktop/src/preload.ts+2
...@@ -41,6 +41,8 @@ const nativeApi: NativeApi = {...@@ -41,6 +41,8 @@ const nativeApi: NativeApi = {
41 stopSession: (input) =>41 stopSession: (input) =>
42 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),42 ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input),
43 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),43 listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList),
44 listModels: (input) =>
45 ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input),
44 onEvent: (callback) => {46 onEvent: (callback) => {
45 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>47 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>
46 callback(payload as Parameters<typeof callback>[0]);48 callback(payload as Parameters<typeof callback>[0]);
apps/desktop/src/providerManager.ts+14
...@@ -3,12 +3,15 @@ import { EventEmitter } from "node:events";...@@ -3,12 +3,15 @@ import { EventEmitter } from "node:events";
3import {3import {
4 type ProviderEvent,4 type ProviderEvent,
5 type ProviderInterruptTurnInput,5 type ProviderInterruptTurnInput,
6 type ProviderListModelsInput,
7 type ProviderModel,
6 type ProviderSendTurnInput,8 type ProviderSendTurnInput,
7 type ProviderSession,9 type ProviderSession,
8 type ProviderSessionStartInput,10 type ProviderSessionStartInput,
9 type ProviderStopSessionInput,11 type ProviderStopSessionInput,
10 type ProviderTurnStartResult,12 type ProviderTurnStartResult,
11 providerInterruptTurnInputSchema,13 providerInterruptTurnInputSchema,
14 providerListModelsInputSchema,
12 providerSendTurnInputSchema,15 providerSendTurnInputSchema,
13 providerSessionStartInputSchema,16 providerSessionStartInputSchema,
14 providerStopSessionInputSchema,17 providerStopSessionInputSchema,
...@@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {...@@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> {
63 return this.codex.listSessions();66 return this.codex.listSessions();
64 }67 }
6568
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
66 stopAll(): void {80 stopAll(): void {
67 this.codex.stopAll();81 this.codex.stopAll();
68 }82 }
apps/renderer/src/components/ChatView.tsx+185-11
...@@ -1,22 +1,54 @@...@@ -1,22 +1,54 @@
1import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";1import type { ProviderModel } from "@acme/contracts";
2import {
3 type FormEvent,
4 type KeyboardEvent,
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9} from "react";
210
3import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic";11import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic";
4import { useStore } from "../store";12import { useStore } from "../store";
513
14const DEFAULT_MODEL = "gpt-5.2-codex";
15
6export default function ChatView() {16export default function ChatView() {
7 const { state, dispatch } = useStore();17 const { state, dispatch } = useStore();
8 const api = useMemo(() => readNativeApi(), []);18 const api = useMemo(() => readNativeApi(), []);
9 const [prompt, setPrompt] = useState("");19 const [prompt, setPrompt] = useState("");
10 const [isSending, setIsSending] = useState(false);20 const [isSending, setIsSending] = useState(false);
11 const [isConnecting, setIsConnecting] = useState(false);21 const [isConnecting, setIsConnecting] = useState(false);
22 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[]>([]);
12 const messagesEndRef = useRef<HTMLDivElement>(null);26 const messagesEndRef = useRef<HTMLDivElement>(null);
13 const textareaRef = useRef<HTMLTextAreaElement>(null);27 const textareaRef = useRef<HTMLTextAreaElement>(null);
28 const modelMenuRef = useRef<HTMLDivElement>(null);
1429
15 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);30 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
16 const activeProject = state.projects.find(31 const activeProject = state.projects.find(
17 (p) => p.id === activeThread?.projectId,32 (p) => p.id === activeThread?.projectId,
18 );33 );
34 const selectedModel =
35 activeThread?.model || activeProject?.model || DEFAULT_MODEL;
19 const phase = derivePhase(activeThread?.session ?? null);36 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 });
49 }
50 return Array.from(deduped.values());
51 }, [availableModels, selectedModel]);
2052
21 // Auto-scroll on new messages53 // Auto-scroll on new messages
22 const messageCount = activeThread?.messages.length ?? 0;54 const messageCount = activeThread?.messages.length ?? 0;
...@@ -34,6 +66,67 @@ export default function ChatView() {...@@ -34,6 +66,67 @@ export default function ChatView() {
34 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;66 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
35 }, [prompt]);67 }, [prompt]);
3668
69 useEffect(() => {
70 if (!isModelMenuOpen) return;
71
72 const handleClickOutside = (event: MouseEvent) => {
73 if (!modelMenuRef.current) return;
74 if (
75 event.target instanceof Node &&
76 !modelMenuRef.current.contains(event.target)
77 ) {
78 setIsModelMenuOpen(false);
79 }
80 };
81
82 window.addEventListener("mousedown", handleClickOutside);
83 return () => {
84 window.removeEventListener("mousedown", handleClickOutside);
85 };
86 }, [isModelMenuOpen]);
87
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
37 const ensureSession = async (): Promise<boolean> => {130 const ensureSession = async (): Promise<boolean> => {
38 if (!api || !activeThread || !activeProject) return false;131 if (!api || !activeThread || !activeProject) return false;
39 if (activeThread.session && activeThread.session.status !== "closed")132 if (activeThread.session && activeThread.session.status !== "closed")
...@@ -44,7 +137,7 @@ export default function ChatView() {...@@ -44,7 +137,7 @@ export default function ChatView() {
44 const session = await api.providers.startSession({137 const session = await api.providers.startSession({
45 provider: "codex",138 provider: "codex",
46 cwd: activeProject.cwd || undefined,139 cwd: activeProject.cwd || undefined,
47 model: activeProject.model || undefined,140 model: selectedModel || undefined,
48 });141 });
49 dispatch({142 dispatch({
50 type: "UPDATE_SESSION",143 type: "UPDATE_SESSION",
...@@ -105,7 +198,11 @@ export default function ChatView() {...@@ -105,7 +198,11 @@ export default function ChatView() {
105198
106 setIsSending(true);199 setIsSending(true);
107 try {200 try {
108 await api.providers.sendTurn({ sessionId, input: trimmed });201 await api.providers.sendTurn({
202 sessionId,
203 input: trimmed,
204 model: selectedModel || undefined,
205 });
109 } catch (err) {206 } catch (err) {
110 dispatch({207 dispatch({
111 type: "SET_ERROR",208 type: "SET_ERROR",
...@@ -125,7 +222,17 @@ export default function ChatView() {...@@ -125,7 +222,17 @@ export default function ChatView() {
125 });222 });
126 };223 };
127224
128 const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {225 const onModelSelect = (model: string) => {
226 if (!activeThread) return;
227 dispatch({
228 type: "SET_THREAD_MODEL",
229 threadId: activeThread.id,
230 model,
231 });
232 setIsModelMenuOpen(false);
233 };
234
235 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
129 if (e.key === "Enter" && !e.shiftKey) {236 if (e.key === "Enter" && !e.shiftKey) {
130 e.preventDefault();237 e.preventDefault();
131 void onSend(e as unknown as FormEvent);238 void onSend(e as unknown as FormEvent);
...@@ -255,10 +362,10 @@ export default function ChatView() {...@@ -255,10 +362,10 @@ export default function ChatView() {
255 onSubmit={onSend}362 onSubmit={onSend}
256 className="mx-auto flex max-w-3xl items-end gap-2"363 className="mx-auto flex max-w-3xl items-end gap-2"
257 >364 >
258 <div className="relative flex-1">365 <div className="flex-1 rounded-2xl border border-white/[0.1] bg-[#121214] px-3 py-3">
259 <textarea366 <textarea
260 ref={textareaRef}367 ref={textareaRef}
261 className="w-full resize-none rounded-xl border border-white/[0.1] bg-white/[0.04] px-4 py-3 pr-12 font-mono text-sm text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/20 focus:outline-none"368 className="w-full resize-none bg-transparent px-1 pb-1 font-mono text-sm text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:outline-none"
262 rows={1}369 rows={1}
263 value={prompt}370 value={prompt}
264 onChange={(e) => setPrompt(e.target.value)}371 onChange={(e) => setPrompt(e.target.value)}
...@@ -270,11 +377,78 @@ export default function ChatView() {...@@ -270,11 +377,78 @@ export default function ChatView() {
270 }377 }
271 disabled={isSending || isConnecting}378 disabled={isSending || isConnecting}
272 />379 />
273 {activeProject && (380 <div className="mt-2 flex items-center justify-between gap-2">
274 <span className="absolute right-3 bottom-2 text-[9px] text-[#a0a0a0]/25">381 <div className="relative" ref={modelMenuRef}>
275 {activeProject.model}382 <button
276 </span>383 type="button"
277 )}384 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]"
385 onClick={() => setIsModelMenuOpen((open) => !open)}
386 >
387 <span className="max-w-[180px] truncate">
388 {selectedModel}
389 </span>
390 <span className="text-[10px] text-[#a0a0a0]/70">▼</span>
391 </button>
392 {isModelMenuOpen && (
393 <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">
394 <p className="px-2 py-1 text-[11px] text-[#a0a0a0]/70">
395 Select model
396 </p>
397 <div className="max-h-72 overflow-y-auto">
398 {modelOptions.map((model) => {
399 const isSelected = model.model === selectedModel;
400 return (
401 <button
402 key={model.id}
403 type="button"
404 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 ${
405 isSelected
406 ? "bg-white/[0.08] text-white"
407 : "text-[#d4d4d4] hover:bg-white/[0.05]"
408 }`}
409 onClick={() => onModelSelect(model.model)}
410 >
411 <span className="min-w-0">
412 <span className="block truncate text-sm">
413 {model.displayName}
414 </span>
415 {model.description && (
416 <span className="block pt-0.5 text-[10px] text-[#a0a0a0]/65">
417 {model.description}
418 </span>
419 )}
420 </span>
421 <span
422 className={`pt-0.5 text-sm ${
423 isSelected ? "text-white" : "text-transparent"
424 }`}
425 >
426 ✓
427 </span>
428 </button>
429 );
430 })}
431 </div>
432 {isLoadingModels && (
433 <p className="px-2 pt-2 text-[10px] text-[#a0a0a0]/60">
434 Refreshing available models...
435 </p>
436 )}
437 {modelLoadError && (
438 <p className="px-2 pt-2 text-[10px] text-amber-200/80">
439 Could not load full model list from Codex:{" "}
440 {modelLoadError}
441 </p>
442 )}
443 </div>
444 )}
445 </div>
446 {activeProject && (
447 <span className="text-[10px] text-[#a0a0a0]/35">
448 {activeProject.name}
449 </span>
450 )}
451 </div>
278 </div>452 </div>
279 {phase === "running" ? (453 {phase === "running" ? (
280 <button454 <button
apps/renderer/src/components/Sidebar.tsx+9-4
...@@ -2,6 +2,8 @@ import { useState } from "react";...@@ -2,6 +2,8 @@ import { useState } from "react";
2import { useStore } from "../store";2import { useStore } from "../store";
3import type { Project } from "../types";3import type { Project } from "../types";
44
5const DEFAULT_MODEL = "gpt-5.2-codex";
6
5function formatRelativeTime(iso: string): string {7function formatRelativeTime(iso: string): string {
6 const diff = Date.now() - new Date(iso).getTime();8 const diff = Date.now() - new Date(iso).getTime();
7 const minutes = Math.floor(diff / 60_000);9 const minutes = Math.floor(diff / 60_000);
...@@ -16,7 +18,7 @@ export default function Sidebar() {...@@ -16,7 +18,7 @@ export default function Sidebar() {
16 const { state, dispatch } = useStore();18 const { state, dispatch } = useStore();
17 const [addingProject, setAddingProject] = useState(false);19 const [addingProject, setAddingProject] = useState(false);
18 const [newCwd, setNewCwd] = useState("");20 const [newCwd, setNewCwd] = useState("");
19 const [newModel, setNewModel] = useState("gpt-5.1-codex");21 const [newModel, setNewModel] = useState(DEFAULT_MODEL);
2022
21 const handleAddProject = () => {23 const handleAddProject = () => {
22 const cwd = newCwd.trim();24 const cwd = newCwd.trim();
...@@ -26,12 +28,12 @@ export default function Sidebar() {...@@ -26,12 +28,12 @@ export default function Sidebar() {
26 id: crypto.randomUUID(),28 id: crypto.randomUUID(),
27 name,29 name,
28 cwd,30 cwd,
29 model: newModel.trim() || "gpt-5.1-codex",31 model: newModel.trim() || DEFAULT_MODEL,
30 expanded: true,32 expanded: true,
31 };33 };
32 dispatch({ type: "ADD_PROJECT", project });34 dispatch({ type: "ADD_PROJECT", project });
33 setNewCwd("");35 setNewCwd("");
34 setNewModel("gpt-5.1-codex");36 setNewModel(DEFAULT_MODEL);
35 setAddingProject(false);37 setAddingProject(false);
36 };38 };
3739
...@@ -42,6 +44,9 @@ export default function Sidebar() {...@@ -42,6 +44,9 @@ export default function Sidebar() {
42 id: crypto.randomUUID(),44 id: crypto.randomUUID(),
43 projectId,45 projectId,
44 title: "New thread",46 title: "New thread",
47 model:
48 state.projects.find((p) => p.id === projectId)?.model ??
49 DEFAULT_MODEL,
45 session: null,50 session: null,
46 messages: [],51 messages: [],
47 events: [],52 events: [],
...@@ -183,7 +188,7 @@ export default function Sidebar() {...@@ -183,7 +188,7 @@ export default function Sidebar() {
183 />188 />
184 <input189 <input
185 className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none"190 className="mb-2 w-full rounded-md border border-white/[0.1] bg-white/[0.04] px-2 py-1.5 font-mono text-xs text-[#e0e0e0] placeholder:text-[#a0a0a0]/30 focus:border-white/30 focus:outline-none"
186 placeholder="model (default: gpt-5.1-codex)"191 placeholder={`model (default: ${DEFAULT_MODEL})`}
187 value={newModel}192 value={newModel}
188 onChange={(e) => setNewModel(e.target.value)}193 onChange={(e) => setNewModel(e.target.value)}
189 onKeyDown={(e) => {194 onKeyDown={(e) => {
apps/renderer/src/store.ts+11-1
...@@ -27,7 +27,8 @@ type Action =...@@ -27,7 +27,8 @@ type Action =
27 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }27 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
28 | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }28 | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
29 | { type: "SET_ERROR"; threadId: string; error: string | null }29 | { type: "SET_ERROR"; threadId: string; error: string | null }
30 | { type: "SET_THREAD_TITLE"; threadId: string; title: string };30 | { type: "SET_THREAD_TITLE"; threadId: string; title: string }
31 | { type: "SET_THREAD_MODEL"; threadId: string; model: string };
3132
32// ── State ────────────────────────────────────────────────────────────33// ── State ────────────────────────────────────────────────────────────
3334
...@@ -160,6 +161,15 @@ function reducer(state: AppState, action: Action): AppState {...@@ -160,6 +161,15 @@ function reducer(state: AppState, action: Action): AppState {
160 })),161 })),
161 };162 };
162163
164 case "SET_THREAD_MODEL":
165 return {
166 ...state,
167 threads: updateThread(state.threads, action.threadId, (t) => ({
168 ...t,
169 model: action.model,
170 })),
171 };
172
163 default:173 default:
164 return state;174 return state;
165 }175 }
apps/renderer/src/types.ts+1
...@@ -22,6 +22,7 @@ export interface Thread {...@@ -22,6 +22,7 @@ export interface Thread {
22 id: string;22 id: string;
23 projectId: string;23 projectId: string;
24 title: string;24 title: string;
25 model: string;
25 session: ProviderSession | null;26 session: ProviderSession | null;
26 messages: ChatMessage[];27 messages: ChatMessage[];
27 events: ProviderEvent[];28 events: ProviderEvent[];
packages/contracts/src/ipc.ts+4
...@@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";...@@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
2import type {2import type {
3 ProviderEvent,3 ProviderEvent,
4 ProviderInterruptTurnInput,4 ProviderInterruptTurnInput,
5 ProviderListModelsInput,
6 ProviderModel,
5 ProviderSendTurnInput,7 ProviderSendTurnInput,
6 ProviderSession,8 ProviderSession,
7 ProviderSessionStartInput,9 ProviderSessionStartInput,
...@@ -27,6 +29,7 @@ export const IPC_CHANNELS = {...@@ -27,6 +29,7 @@ export const IPC_CHANNELS = {
27 providerTurnInterrupt: "provider:turn:interrupt",29 providerTurnInterrupt: "provider:turn:interrupt",
28 providerSessionStop: "provider:session:stop",30 providerSessionStop: "provider:session:stop",
29 providerSessionList: "provider:session:list",31 providerSessionList: "provider:session:list",
32 providerModelList: "provider:model:list",
30 providerEvent: "provider:event",33 providerEvent: "provider:event",
31} as const;34} as const;
3235
...@@ -57,6 +60,7 @@ export interface NativeApi {...@@ -57,6 +60,7 @@ export interface NativeApi {
57 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;60 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
58 stopSession: (input: ProviderStopSessionInput) => Promise<void>;61 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
59 listSessions: () => Promise<ProviderSession[]>;62 listSessions: () => Promise<ProviderSession[]>;
63 listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>;
60 onEvent: (callback: (event: ProviderEvent) => void) => () => void;64 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
61 };65 };
62}66}
packages/contracts/src/provider.test.ts+11-1
...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";...@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
3import {3import {
4 providerEventSchema,4 providerEventSchema,
5 providerListModelsInputSchema,
5 providerSendTurnInputSchema,6 providerSendTurnInputSchema,
6 providerSessionStartInputSchema,7 providerSessionStartInputSchema,
7} from "./provider";8} from "./provider";
...@@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => {...@@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => {
16});17});
1718
18describe("providerSendTurnInputSchema", () => {19describe("providerSendTurnInputSchema", () => {
19 it("trims input text", () => {20 it("trims input text and optional model", () => {
20 const parsed = providerSendTurnInputSchema.parse({21 const parsed = providerSendTurnInputSchema.parse({
21 sessionId: "sess_1",22 sessionId: "sess_1",
22 input: " summarize this repo ",23 input: " summarize this repo ",
24 model: " gpt-5.2-codex ",
23 });25 });
24 expect(parsed.input).toBe("summarize this repo");26 expect(parsed.input).toBe("summarize this repo");
27 expect(parsed.model).toBe("gpt-5.2-codex");
28 });
29});
30
31describe("providerListModelsInputSchema", () => {
32 it("defaults provider to codex", () => {
33 const parsed = providerListModelsInputSchema.parse({});
34 expect(parsed.provider).toBe("codex");
25 });35 });
26});36});
2737
packages/contracts/src/provider.ts+19
...@@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({...@@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({
47export const providerSendTurnInputSchema = z.object({47export const providerSendTurnInputSchema = z.object({
48 sessionId: z.string().min(1),48 sessionId: z.string().min(1),
49 input: z.string().trim().min(1).max(120_000),49 input: z.string().trim().min(1).max(120_000),
50 model: z.string().trim().min(1).optional(),
50});51});
5152
52export const providerTurnStartResultSchema = z.object({53export const providerTurnStartResultSchema = z.object({
...@@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({...@@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({
63 sessionId: z.string().min(1),64 sessionId: z.string().min(1),
64});65});
6566
67export 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
76export const providerListModelsInputSchema = z.object({
77 provider: providerKindSchema.default("codex"),
78 cwd: z.string().min(1).optional(),
79});
80
66export const providerEventKindSchema = z.enum([81export const providerEventKindSchema = z.enum([
67 "session",82 "session",
68 "notification",83 "notification",
...@@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input<...@@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input<
105export type ProviderStopSessionInput = z.input<120export type ProviderStopSessionInput = z.input<
106 typeof providerStopSessionInputSchema121 typeof providerStopSessionInputSchema
107>;122>;
123export type ProviderModel = z.infer<typeof providerModelSchema>;
124export type ProviderListModelsInput = z.input<
125 typeof providerListModelsInputSchema
126>;
108export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;127export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
109export type ProviderEvent = z.infer<typeof providerEventSchema>;128export type ProviderEvent = z.infer<typeof providerEventSchema>;