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";
55
66import type {
77 ProviderEvent,
8 ProviderModel,
89 ProviderSendTurnInput,
910 ProviderSession,
1011 ProviderSessionStartInput,
......@@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
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
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
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
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
......@@ -139,6 +139,15 @@ function registerIpcHandlers(): void {
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
144153async function runTerminalCommand(
apps/desktop/src/preload.ts+2
......@@ -41,6 +41,8 @@ const nativeApi: NativeApi = {
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
......@@ -3,12 +3,15 @@ import { EventEmitter } from "node:events";
33import {
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> {
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 @@
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
311import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic";
412import { useStore } from "../store";
513
14const DEFAULT_MODEL = "gpt-5.2-codex";
15
616export 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() {
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() {
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() {
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() {
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() {
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() {
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";
22import { useStore } from "../store";
33import type { Project } from "../types";
44
5const DEFAULT_MODEL = "gpt-5.2-codex";
6
57function 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() {
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() {
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() {
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() {
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 =
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 {
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
......@@ -22,6 +22,7 @@ export interface Thread {
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
......@@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent";
22import type {
33 ProviderEvent,
44 ProviderInterruptTurnInput,
5 ProviderListModelsInput,
6 ProviderModel,
57 ProviderSendTurnInput,
68 ProviderSession,
79 ProviderSessionStartInput,
......@@ -27,6 +29,7 @@ export const IPC_CHANNELS = {
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 {
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";
22
33import {
44 providerEventSchema,
5 providerListModelsInputSchema,
56 providerSendTurnInputSchema,
67 providerSessionStartInputSchema,
78} from "./provider";
......@@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => {
1617});
1718
1819describe("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
31describe("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
......@@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({
4747export 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
5253export const providerTurnStartResultSchema = z.object({
......@@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({
6364 sessionId: z.string().min(1),
6465});
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
6681export const providerEventKindSchema = z.enum([
6782 "session",
6883 "notification",
......@@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input<
105120export type ProviderStopSessionInput = z.input<
106121 typeof providerStopSessionInputSchema
107122>;
123export type ProviderModel = z.infer<typeof providerModelSchema>;
124export type ProviderListModelsInput = z.input<
125 typeof providerListModelsInputSchema
126>;
108127export type ProviderEventKind = z.infer<typeof providerEventKindSchema>;
109128export type ProviderEvent = z.infer<typeof providerEventSchema>;