History

model picker

Theo Browne committed 9 months ago

Commit 548cd111 parent 13501e812 files · +522 −19Browse files

Unified · Split

12 files
  1. TODO.md +10 −0
  2. apps/desktop/src/codexAppServerManager.ts +247 −2
  3. apps/desktop/src/main.ts +9 −0
  4. apps/desktop/src/preload.ts +2 −0
  5. apps/desktop/src/providerManager.ts +14 −0
  6. apps/renderer/src/components/ChatView.tsx +185 −11
  7. apps/renderer/src/components/Sidebar.tsx +9 −4
  8. apps/renderer/src/store.ts +11 −1
  9. apps/renderer/src/types.ts +1 −0
  10. packages/contracts/src/ipc.ts +4 −0
  11. packages/contracts/src/provider.test.ts +11 −1
  12. packages/contracts/src/provider.ts +19 −0
TODO.md+10−0
@@ -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"; · 4 unmodified lines
55
66 import type {
77 ProviderEvent,
8+ ProviderModel,
89 ProviderSendTurnInput,
910 ProviderSession,
1011 ProviderSessionStartInput,
@@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 186 unmodified lines
197198 throw new Error("Session is missing a thread id.");
198199 }
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+ } = {
201206 threadId: context.session.threadId,
202207 input: [
203208 {
@@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 2 unmodified lines
206211 text_elements: [],
207212 },
208213 ],
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
211225 const turn = this.readObject(this.readObject(response), "turn");
212226 const turnId = this.readString(turn, "id");
@@ -225,6 +239,177 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 12 unmodified lines
225239 };
226240 }
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+
228413 async interruptTurn(sessionId: string, turnId?: string): Promise<void> {
229414 const context = this.requireSession(sessionId);
230415 const effectiveTurnId = turnId ?? context.session.activeTurnId;
@@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 391 unmodified lines
622807 };
623808 }
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+
625870 private isServerRequest(value: unknown): value is JsonRpcRequest {
626871 if (!value || typeof value !== "object") {
627872 return false;
apps/desktop/src/main.ts+9−0
@@ -139,6 +139,15 @@ 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+ );
142151 }
143152
144153 async function runTerminalCommand(
apps/desktop/src/preload.ts+2−0
@@ -41,6 +41,8 @@ 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),
4446 onEvent: (callback) => {
4547 const listener = (_event: Electron.IpcRendererEvent, payload: unknown) =>
4648 callback(payload as Parameters<typeof callback>[0]);
apps/desktop/src/providerManager.ts+14−0
@@ -3,12 +3,15 @@ import { EventEmitter } from "node:events"; · 2 unmodified lines
33 import {
44 type ProviderEvent,
55 type ProviderInterruptTurnInput,
6+ type ProviderListModelsInput,
7+ type ProviderModel,
68 type ProviderSendTurnInput,
79 type ProviderSession,
810 type ProviderSessionStartInput,
911 type ProviderStopSessionInput,
1012 type ProviderTurnStartResult,
1113 providerInterruptTurnInputSchema,
14+ providerListModelsInputSchema,
1215 providerSendTurnInputSchema,
1316 providerSessionStartInputSchema,
1417 providerStopSessionInputSchema,
@@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 48 unmodified lines
6366 return this.codex.listSessions();
6467 }
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+
6680 stopAll(): void {
6781 this.codex.stopAll();
6882 }
apps/renderer/src/components/ChatView.tsx+185−11
@@ -1,22 +1,54 @@
1−import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
1+import type { ProviderModel } from "@acme/contracts";
2+import {
3+ type FormEvent,
4+ type KeyboardEvent,
5+ useEffect,
6+ useMemo,
7+ useRef,
8+ useState,
9+} from "react";
210
311 import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic";
412 import { useStore } from "../store";
513
14+const DEFAULT_MODEL = "gpt-5.2-codex";
15+
616 export default function ChatView() {
717 const { state, dispatch } = useStore();
818 const api = useMemo(() => readNativeApi(), []);
919 const [prompt, setPrompt] = useState("");
1020 const [isSending, setIsSending] = useState(false);
1121 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[]>([]);
1226 const messagesEndRef = useRef<HTMLDivElement>(null);
1327 const textareaRef = useRef<HTMLTextAreaElement>(null);
28+ const modelMenuRef = useRef<HTMLDivElement>(null);
1429
1530 const activeThread = state.threads.find((t) => t.id === state.activeThreadId);
1631 const activeProject = state.projects.find(
1732 (p) => p.id === activeThread?.projectId,
1833 );
34+ const selectedModel =
35+ activeThread?.model || activeProject?.model || DEFAULT_MODEL;
1936 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
2153 // Auto-scroll on new messages
2254 const messageCount = activeThread?.messages.length ?? 0;
@@ -34,6 +66,67 @@ export default function ChatView() { · 11 unmodified lines
3466 ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`;
3567 }, [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+
37130 const ensureSession = async (): Promise<boolean> => {
38131 if (!api || !activeThread || !activeProject) return false;
39132 if (activeThread.session && activeThread.session.status !== "closed")
@@ -44,7 +137,7 @@ export default function ChatView() { · 4 unmodified lines
44137 const session = await api.providers.startSession({
45138 provider: "codex",
46139 cwd: activeProject.cwd || undefined,
47− model: activeProject.model || undefined,
140+ model: selectedModel || undefined,
48141 });
49142 dispatch({
50143 type: "UPDATE_SESSION",
@@ -105,7 +198,11 @@ export default function ChatView() { · 54 unmodified lines
105198
106199 setIsSending(true);
107200 try {
108− await api.providers.sendTurn({ sessionId, input: trimmed });
201+ await api.providers.sendTurn({
202+ sessionId,
203+ input: trimmed,
204+ model: selectedModel || undefined,
205+ });
109206 } catch (err) {
110207 dispatch({
111208 type: "SET_ERROR",
@@ -125,7 +222,17 @@ export default function ChatView() { · 13 unmodified lines
125222 });
126223 };
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>) => {
129236 if (e.key === "Enter" && !e.shiftKey) {
130237 e.preventDefault();
131238 void onSend(e as unknown as FormEvent);
@@ -255,10 +362,10 @@ export default function ChatView() { · 123 unmodified lines
255362 onSubmit={onSend}
256363 className="mx-auto flex max-w-3xl items-end gap-2"
257364 >
258− <div className="relative flex-1">
365+ <div className="flex-1 rounded-2xl border border-white/[0.1] bg-[#121214] px-3 py-3">
259366 <textarea
260367 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"
262369 rows={1}
263370 value={prompt}
264371 onChange={(e) => setPrompt(e.target.value)}
@@ -270,11 +377,78 @@ export default function ChatView() { · 5 unmodified lines
270377 }
271378 disabled={isSending || isConnecting}
272379 />
273− {activeProject && (
274− <span className="absolute right-3 bottom-2 text-[9px] text-[#a0a0a0]/25">
275− {activeProject.model}
276− </span>
277− )}
380+ <div className="mt-2 flex items-center justify-between gap-2">
381+ <div className="relative" ref={modelMenuRef}>
382+ <button
383+ type="button"
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>
278452 </div>
279453 {phase === "running" ? (
280454 <button
apps/renderer/src/components/Sidebar.tsx+9−4
@@ -2,6 +2,8 @@ import { useState } from "react"; · 1 unmodified line
22 import { useStore } from "../store";
33 import type { Project } from "../types";
44
5+const DEFAULT_MODEL = "gpt-5.2-codex";
6+
57 function formatRelativeTime(iso: string): string {
68 const diff = Date.now() - new Date(iso).getTime();
79 const minutes = Math.floor(diff / 60_000);
@@ -16,7 +18,7 @@ export default function Sidebar() { · 8 unmodified lines
1618 const { state, dispatch } = useStore();
1719 const [addingProject, setAddingProject] = useState(false);
1820 const [newCwd, setNewCwd] = useState("");
19− const [newModel, setNewModel] = useState("gpt-5.1-codex");
21+ const [newModel, setNewModel] = useState(DEFAULT_MODEL);
2022
2123 const handleAddProject = () => {
2224 const cwd = newCwd.trim();
@@ -26,12 +28,12 @@ export default function Sidebar() { · 3 unmodified lines
2628 id: crypto.randomUUID(),
2729 name,
2830 cwd,
29− model: newModel.trim() || "gpt-5.1-codex",
31+ model: newModel.trim() || DEFAULT_MODEL,
3032 expanded: true,
3133 };
3234 dispatch({ type: "ADD_PROJECT", project });
3335 setNewCwd("");
34− setNewModel("gpt-5.1-codex");
36+ setNewModel(DEFAULT_MODEL);
3537 setAddingProject(false);
3638 };
3739
@@ -42,6 +44,9 @@ export default function Sidebar() { · 4 unmodified lines
4244 id: crypto.randomUUID(),
4345 projectId,
4446 title: "New thread",
47+ model:
48+ state.projects.find((p) => p.id === projectId)?.model ??
49+ DEFAULT_MODEL,
4550 session: null,
4651 messages: [],
4752 events: [],
@@ -183,7 +188,7 @@ export default function Sidebar() { · 135 unmodified lines
183188 />
184189 <input
185190 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})`}
187192 value={newModel}
188193 onChange={(e) => setNewModel(e.target.value)}
189194 onKeyDown={(e) => {
apps/renderer/src/store.ts+11−1
@@ -27,7 +27,8 @@ type Action = · 26 unmodified lines
2727 | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession }
2828 | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string }
2929 | { 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
3233 // ── State ────────────────────────────────────────────────────────────
3334
@@ -160,6 +161,15 @@ function reducer(state: AppState, action: Action): AppState { · 126 unmodified lines
160161 })),
161162 };
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+
163173 default:
164174 return state;
165175 }
apps/renderer/src/types.ts+1−0
@@ -22,6 +22,7 @@ export interface Thread { · 21 unmodified lines
2222 id: string;
2323 projectId: string;
2424 title: string;
25+ model: string;
2526 session: ProviderSession | null;
2627 messages: ChatMessage[];
2728 events: ProviderEvent[];
packages/contracts/src/ipc.ts+4−0
@@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; · 1 unmodified line
22 import type {
33 ProviderEvent,
44 ProviderInterruptTurnInput,
5+ ProviderListModelsInput,
6+ ProviderModel,
57 ProviderSendTurnInput,
68 ProviderSession,
79 ProviderSessionStartInput,
@@ -27,6 +29,7 @@ export const IPC_CHANNELS = { · 19 unmodified lines
2729 providerTurnInterrupt: "provider:turn:interrupt",
2830 providerSessionStop: "provider:session:stop",
2931 providerSessionList: "provider:session:list",
32+ providerModelList: "provider:model:list",
3033 providerEvent: "provider:event",
3134 } as const;
3235
@@ -57,6 +60,7 @@ export interface NativeApi { · 24 unmodified lines
5760 interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>;
5861 stopSession: (input: ProviderStopSessionInput) => Promise<void>;
5962 listSessions: () => Promise<ProviderSession[]>;
63+ listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>;
6064 onEvent: (callback: (event: ProviderEvent) => void) => () => void;
6165 };
6266 }
packages/contracts/src/provider.test.ts+11−1
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line
22
33 import {
44 providerEventSchema,
5+ providerListModelsInputSchema,
56 providerSendTurnInputSchema,
67 providerSessionStartInputSchema,
78 } from "./provider";
@@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => { · 8 unmodified lines
1617 });
1718
1819 describe("providerSendTurnInputSchema", () => {
19− it("trims input text", () => {
20+ it("trims input text and optional model", () => {
2021 const parsed = providerSendTurnInputSchema.parse({
2122 sessionId: "sess_1",
2223 input: " summarize this repo ",
24+ model: " gpt-5.2-codex ",
2325 });
2426 expect(parsed.input).toBe("summarize this repo");
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");
2535 });
2636 });
2737
packages/contracts/src/provider.ts+19−0
@@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({ · 46 unmodified lines
4747 export const providerSendTurnInputSchema = z.object({
4848 sessionId: z.string().min(1),
4949 input: z.string().trim().min(1).max(120_000),
50+ model: z.string().trim().min(1).optional(),
5051 });
5152
5253 export const providerTurnStartResultSchema = z.object({
@@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({ · 10 unmodified lines
6364 sessionId: z.string().min(1),
6465 });
6566
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+
6681 export const providerEventKindSchema = z.enum([
6782 "session",
6883 "notification",
@@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input< · 36 unmodified lines
105120 export type ProviderStopSessionInput = z.input<
106121 typeof providerStopSessionInputSchema
107122 >;
123+export type ProviderModel = z.infer<typeof providerModelSchema>;
124+export type ProviderListModelsInput = z.input<
125+ typeof providerListModelsInputSchema
126+>;
108127 export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
109128 export type ProviderEvent = z.infer<typeof providerEventSchema>;