Unified · Split
| @@ -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 | |
| @@ -5,6 +5,7 @@ import readline from "node:readline"; · 4 unmodified lines | ||
| 5 | 5 | |
| 6 | 6 | import type { |
| 7 | 7 | ProviderEvent, |
| 8 | + ProviderModel, | |
| 8 | 9 | ProviderSendTurnInput, |
| 9 | 10 | ProviderSession, |
| 10 | 11 | ProviderSessionStartInput, |
| @@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 186 unmodified lines | ||
| 197 | 198 | throw new Error("Session is missing a thread id."); |
| 198 | 199 | } |
| 199 | 200 | |
| 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 | 206 | threadId: context.session.threadId, |
| 202 | 207 | input: [ |
| 203 | 208 | { |
| @@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 2 unmodified lines | ||
| 206 | 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 | + ); | |
| 210 | 224 | |
| 211 | 225 | const turn = this.readObject(this.readObject(response), "turn"); |
| 212 | 226 | const turnId = this.readString(turn, "id"); |
| @@ -225,6 +239,177 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 12 unmodified lines | ||
| 225 | 239 | }; |
| 226 | 240 | } |
| 227 | 241 | |
| 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 | 413 | async interruptTurn(sessionId: string, turnId?: string): Promise<void> { |
| 229 | 414 | const context = this.requireSession(sessionId); |
| 230 | 415 | const effectiveTurnId = turnId ?? context.session.activeTurnId; |
| @@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 391 unmodified lines | ||
| 622 | 807 | }; |
| 623 | 808 | } |
| 624 | 809 | |
| 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 | 870 | private isServerRequest(value: unknown): value is JsonRpcRequest { |
| 626 | 871 | if (!value || typeof value !== "object") { |
| 627 | 872 | return false; |
| @@ -139,6 +139,15 @@ function registerIpcHandlers(): void { · 138 unmodified lines | ||
| 139 | 139 | ipcMain.handle(IPC_CHANNELS.providerSessionList, async () => { |
| 140 | 140 | return providerManager.listSessions(); |
| 141 | 141 | }); |
| 142 | + | |
| 143 | + ipcMain.handle( | |
| 144 | + IPC_CHANNELS.providerModelList, | |
| 145 | + async (_event, payload: unknown) => { | |
| 146 | + return providerManager.listModels( | |
| 147 | + payload as Parameters<typeof providerManager.listModels>[0], | |
| 148 | + ); | |
| 149 | + }, | |
| 150 | + ); | |
| 142 | 151 | } |
| 143 | 152 | |
| 144 | 153 | async function runTerminalCommand( |
| @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { · 40 unmodified lines | ||
| 41 | 41 | stopSession: (input) => |
| 42 | 42 | ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), |
| 43 | 43 | listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), |
| 44 | + listModels: (input) => | |
| 45 | + ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input), | |
| 44 | 46 | onEvent: (callback) => { |
| 45 | 47 | const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => |
| 46 | 48 | callback(payload as Parameters<typeof callback>[0]); |
| @@ -3,12 +3,15 @@ import { EventEmitter } from "node:events"; · 2 unmodified lines | ||
| 3 | 3 | import { |
| 4 | 4 | type ProviderEvent, |
| 5 | 5 | type ProviderInterruptTurnInput, |
| 6 | + type ProviderListModelsInput, | |
| 7 | + type ProviderModel, | |
| 6 | 8 | type ProviderSendTurnInput, |
| 7 | 9 | type ProviderSession, |
| 8 | 10 | type ProviderSessionStartInput, |
| 9 | 11 | type ProviderStopSessionInput, |
| 10 | 12 | type ProviderTurnStartResult, |
| 11 | 13 | providerInterruptTurnInputSchema, |
| 14 | + providerListModelsInputSchema, | |
| 12 | 15 | providerSendTurnInputSchema, |
| 13 | 16 | providerSessionStartInputSchema, |
| 14 | 17 | providerStopSessionInputSchema, |
| @@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter<ProviderManagerEvents> { · 48 unmodified lines | ||
| 63 | 66 | return this.codex.listSessions(); |
| 64 | 67 | } |
| 65 | 68 | |
| 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 | 80 | stopAll(): void { |
| 67 | 81 | this.codex.stopAll(); |
| 68 | 82 | } |
| @@ -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"; | |
| 2 | 10 | |
| 3 | 11 | import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; |
| 4 | 12 | import { useStore } from "../store"; |
| 5 | 13 | |
| 14 | +const DEFAULT_MODEL = "gpt-5.2-codex"; | |
| 15 | + | |
| 6 | 16 | export default function ChatView() { |
| 7 | 17 | const { state, dispatch } = useStore(); |
| 8 | 18 | const api = useMemo(() => readNativeApi(), []); |
| 9 | 19 | const [prompt, setPrompt] = useState(""); |
| 10 | 20 | const [isSending, setIsSending] = useState(false); |
| 11 | 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 | 26 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 13 | 27 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 28 | + const modelMenuRef = useRef<HTMLDivElement>(null); | |
| 14 | 29 | |
| 15 | 30 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 16 | 31 | const activeProject = state.projects.find( |
| 17 | 32 | (p) => p.id === activeThread?.projectId, |
| 18 | 33 | ); |
| 34 | + const selectedModel = | |
| 35 | + activeThread?.model || activeProject?.model || DEFAULT_MODEL; | |
| 19 | 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]); | |
| 20 | 52 | |
| 21 | 53 | // Auto-scroll on new messages |
| 22 | 54 | const messageCount = activeThread?.messages.length ?? 0; |
| @@ -34,6 +66,67 @@ export default function ChatView() { · 11 unmodified lines | ||
| 34 | 66 | ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; |
| 35 | 67 | }, [prompt]); |
| 36 | 68 | |
| 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 | 130 | const ensureSession = async (): Promise<boolean> => { |
| 38 | 131 | if (!api || !activeThread || !activeProject) return false; |
| 39 | 132 | if (activeThread.session && activeThread.session.status !== "closed") |
| @@ -44,7 +137,7 @@ export default function ChatView() { · 4 unmodified lines | ||
| 44 | 137 | const session = await api.providers.startSession({ |
| 45 | 138 | provider: "codex", |
| 46 | 139 | cwd: activeProject.cwd || undefined, |
| 47 | − model: activeProject.model || undefined, | |
| 140 | + model: selectedModel || undefined, | |
| 48 | 141 | }); |
| 49 | 142 | dispatch({ |
| 50 | 143 | type: "UPDATE_SESSION", |
| @@ -105,7 +198,11 @@ export default function ChatView() { · 54 unmodified lines | ||
| 105 | 198 | |
| 106 | 199 | setIsSending(true); |
| 107 | 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 | 206 | } catch (err) { |
| 110 | 207 | dispatch({ |
| 111 | 208 | type: "SET_ERROR", |
| @@ -125,7 +222,17 @@ export default function ChatView() { · 13 unmodified lines | ||
| 125 | 222 | }); |
| 126 | 223 | }; |
| 127 | 224 | |
| 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 | 236 | if (e.key === "Enter" && !e.shiftKey) { |
| 130 | 237 | e.preventDefault(); |
| 131 | 238 | void onSend(e as unknown as FormEvent); |
| @@ -255,10 +362,10 @@ export default function ChatView() { · 123 unmodified lines | ||
| 255 | 362 | onSubmit={onSend} |
| 256 | 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 | 366 | <textarea |
| 260 | 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 | 369 | rows={1} |
| 263 | 370 | value={prompt} |
| 264 | 371 | onChange={(e) => setPrompt(e.target.value)} |
| @@ -270,11 +377,78 @@ export default function ChatView() { · 5 unmodified lines | ||
| 270 | 377 | } |
| 271 | 378 | disabled={isSending || isConnecting} |
| 272 | 379 | /> |
| 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> | |
| 278 | 452 | </div> |
| 279 | 453 | {phase === "running" ? ( |
| 280 | 454 | <button |
| @@ -2,6 +2,8 @@ import { useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useStore } from "../store"; |
| 3 | 3 | import type { Project } from "../types"; |
| 4 | 4 | |
| 5 | +const DEFAULT_MODEL = "gpt-5.2-codex"; | |
| 6 | + | |
| 5 | 7 | function formatRelativeTime(iso: string): string { |
| 6 | 8 | const diff = Date.now() - new Date(iso).getTime(); |
| 7 | 9 | const minutes = Math.floor(diff / 60_000); |
| @@ -16,7 +18,7 @@ export default function Sidebar() { · 8 unmodified lines | ||
| 16 | 18 | const { state, dispatch } = useStore(); |
| 17 | 19 | const [addingProject, setAddingProject] = useState(false); |
| 18 | 20 | const [newCwd, setNewCwd] = useState(""); |
| 19 | − const [newModel, setNewModel] = useState("gpt-5.1-codex"); | |
| 21 | + const [newModel, setNewModel] = useState(DEFAULT_MODEL); | |
| 20 | 22 | |
| 21 | 23 | const handleAddProject = () => { |
| 22 | 24 | const cwd = newCwd.trim(); |
| @@ -26,12 +28,12 @@ export default function Sidebar() { · 3 unmodified lines | ||
| 26 | 28 | id: crypto.randomUUID(), |
| 27 | 29 | name, |
| 28 | 30 | cwd, |
| 29 | − model: newModel.trim() || "gpt-5.1-codex", | |
| 31 | + model: newModel.trim() || DEFAULT_MODEL, | |
| 30 | 32 | expanded: true, |
| 31 | 33 | }; |
| 32 | 34 | dispatch({ type: "ADD_PROJECT", project }); |
| 33 | 35 | setNewCwd(""); |
| 34 | − setNewModel("gpt-5.1-codex"); | |
| 36 | + setNewModel(DEFAULT_MODEL); | |
| 35 | 37 | setAddingProject(false); |
| 36 | 38 | }; |
| 37 | 39 | |
| @@ -42,6 +44,9 @@ export default function Sidebar() { · 4 unmodified lines | ||
| 42 | 44 | id: crypto.randomUUID(), |
| 43 | 45 | projectId, |
| 44 | 46 | title: "New thread", |
| 47 | + model: | |
| 48 | + state.projects.find((p) => p.id === projectId)?.model ?? | |
| 49 | + DEFAULT_MODEL, | |
| 45 | 50 | session: null, |
| 46 | 51 | messages: [], |
| 47 | 52 | events: [], |
| @@ -183,7 +188,7 @@ export default function Sidebar() { · 135 unmodified lines | ||
| 183 | 188 | /> |
| 184 | 189 | <input |
| 185 | 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 | 192 | value={newModel} |
| 188 | 193 | onChange={(e) => setNewModel(e.target.value)} |
| 189 | 194 | onKeyDown={(e) => { |
| @@ -27,7 +27,8 @@ type Action = · 26 unmodified lines | ||
| 27 | 27 | | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } |
| 28 | 28 | | { type: "PUSH_USER_MESSAGE"; threadId: string; id: string; text: string } |
| 29 | 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 }; | |
| 31 | 32 | |
| 32 | 33 | // ── State ──────────────────────────────────────────────────────────── |
| 33 | 34 | |
| @@ -160,6 +161,15 @@ function reducer(state: AppState, action: Action): AppState { · 126 unmodified lines | ||
| 160 | 161 | })), |
| 161 | 162 | }; |
| 162 | 163 | |
| 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 | 173 | default: |
| 164 | 174 | return state; |
| 165 | 175 | } |
| @@ -22,6 +22,7 @@ export interface Thread { · 21 unmodified lines | ||
| 22 | 22 | id: string; |
| 23 | 23 | projectId: string; |
| 24 | 24 | title: string; |
| 25 | + model: string; | |
| 25 | 26 | session: ProviderSession | null; |
| 26 | 27 | messages: ChatMessage[]; |
| 27 | 28 | events: ProviderEvent[]; |
| @@ -2,6 +2,8 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; · 1 unmodified line | ||
| 2 | 2 | import type { |
| 3 | 3 | ProviderEvent, |
| 4 | 4 | ProviderInterruptTurnInput, |
| 5 | + ProviderListModelsInput, | |
| 6 | + ProviderModel, | |
| 5 | 7 | ProviderSendTurnInput, |
| 6 | 8 | ProviderSession, |
| 7 | 9 | ProviderSessionStartInput, |
| @@ -27,6 +29,7 @@ export const IPC_CHANNELS = { · 19 unmodified lines | ||
| 27 | 29 | providerTurnInterrupt: "provider:turn:interrupt", |
| 28 | 30 | providerSessionStop: "provider:session:stop", |
| 29 | 31 | providerSessionList: "provider:session:list", |
| 32 | + providerModelList: "provider:model:list", | |
| 30 | 33 | providerEvent: "provider:event", |
| 31 | 34 | } as const; |
| 32 | 35 | |
| @@ -57,6 +60,7 @@ export interface NativeApi { · 24 unmodified lines | ||
| 57 | 60 | interruptTurn: (input: ProviderInterruptTurnInput) => Promise<void>; |
| 58 | 61 | stopSession: (input: ProviderStopSessionInput) => Promise<void>; |
| 59 | 62 | listSessions: () => Promise<ProviderSession[]>; |
| 63 | + listModels: (input?: ProviderListModelsInput) => Promise<ProviderModel[]>; | |
| 60 | 64 | onEvent: (callback: (event: ProviderEvent) => void) => () => void; |
| 61 | 65 | }; |
| 62 | 66 | } |
| @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | providerEventSchema, |
| 5 | + providerListModelsInputSchema, | |
| 5 | 6 | providerSendTurnInputSchema, |
| 6 | 7 | providerSessionStartInputSchema, |
| 7 | 8 | } from "./provider"; |
| @@ -16,12 +17,21 @@ describe("providerSessionStartInputSchema", () => { · 8 unmodified lines | ||
| 16 | 17 | }); |
| 17 | 18 | |
| 18 | 19 | describe("providerSendTurnInputSchema", () => { |
| 19 | − it("trims input text", () => { | |
| 20 | + it("trims input text and optional model", () => { | |
| 20 | 21 | const parsed = providerSendTurnInputSchema.parse({ |
| 21 | 22 | sessionId: "sess_1", |
| 22 | 23 | input: " summarize this repo ", |
| 24 | + model: " gpt-5.2-codex ", | |
| 23 | 25 | }); |
| 24 | 26 | 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"); | |
| 25 | 35 | }); |
| 26 | 36 | }); |
| 27 | 37 | |
| @@ -47,6 +47,7 @@ export const providerSessionStartInputSchema = z.object({ · 46 unmodified lines | ||
| 47 | 47 | export const providerSendTurnInputSchema = z.object({ |
| 48 | 48 | sessionId: z.string().min(1), |
| 49 | 49 | input: z.string().trim().min(1).max(120_000), |
| 50 | + model: z.string().trim().min(1).optional(), | |
| 50 | 51 | }); |
| 51 | 52 | |
| 52 | 53 | export const providerTurnStartResultSchema = z.object({ |
| @@ -63,6 +64,20 @@ export const providerStopSessionInputSchema = z.object({ · 10 unmodified lines | ||
| 63 | 64 | sessionId: z.string().min(1), |
| 64 | 65 | }); |
| 65 | 66 | |
| 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 | + | |
| 66 | 81 | export const providerEventKindSchema = z.enum([ |
| 67 | 82 | "session", |
| 68 | 83 | "notification", |
| @@ -105,5 +120,9 @@ export type ProviderInterruptTurnInput = z.input< · 36 unmodified lines | ||
| 105 | 120 | export type ProviderStopSessionInput = z.input< |
| 106 | 121 | typeof providerStopSessionInputSchema |
| 107 | 122 | >; |
| 123 | +export type ProviderModel = z.infer<typeof providerModelSchema>; | |
| 124 | +export type ProviderListModelsInput = z.input< | |
| 125 | + typeof providerListModelsInputSchema | |
| 126 | +>; | |
| 108 | 127 | export type ProviderEventKind = z.infer<typeof providerEventKindSchema>; |
| 109 | 128 | export type ProviderEvent = z.infer<typeof providerEventSchema>; |