diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000000000000000000000000000000000000..d5d1b5df1aa407711ad213e434ee0f9f7c6d305d --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +- [ ] Folder picker for new projects +- [ ] Projects should be persisted so they're still visible after closing +- [ ] Remove model selection from project creation +- [ ] First message vanishes when thread is created?? Awful ux +- [ ] Show more clearly when thinking is happening + +## Bigger overhauls + +- [ ] Markdown rendering +- [ ] Code block rendering diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts index 442bd5314467cc6dc7f1b17aa2003e04a7949f0c..ee099d0ea519cacd42ab92ef8c4a82f66f24a6f0 100644 --- a/apps/desktop/src/codexAppServerManager.ts +++ b/apps/desktop/src/codexAppServerManager.ts @@ -5,6 +5,7 @@ import readline from "node:readline"; import type { ProviderEvent, + ProviderModel, ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, @@ -197,7 +198,11 @@ export class CodexAppServerManager extends EventEmitter; + model?: string; + } = { threadId: context.session.threadId, input: [ { @@ -206,7 +211,16 @@ export class CodexAppServerManager extends EventEmitter { + const child = spawn("codex", ["app-server"], { + cwd, + env: process.env, + stdio: ["pipe", "pipe", "pipe"], + }); + const output = readline.createInterface({ input: child.stdout }); + const pending = new Map(); + let nextRequestId = 1; + let exited = false; + let exitError: Error | null = null; + + const cleanup = () => { + for (const request of pending.values()) { + clearTimeout(request.timeout); + } + pending.clear(); + output.close(); + if (!child.killed) { + child.kill(); + } + }; + + const writeMessage = (message: unknown) => { + if (!child.stdin.writable) { + throw new Error("Cannot write to codex app-server stdin."); + } + child.stdin.write(`${JSON.stringify(message)}\n`); + }; + + const sendRequest = async ( + method: string, + params: unknown, + timeoutMs = 20_000, + ): Promise => { + if (exited) { + throw ( + exitError ?? + new Error("codex app-server exited before request could be sent.") + ); + } + + const id = nextRequestId; + nextRequestId += 1; + + const result = await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pending.delete(String(id)); + reject(new Error(`Timed out waiting for ${method}.`)); + }, timeoutMs); + + pending.set(String(id), { + timeout, + resolve, + reject, + method, + }); + writeMessage({ id, method, params }); + }); + + return result as TResponse; + }; + + output.on("line", (line) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return; + } + + if (this.isResponse(parsed)) { + const key = String(parsed.id); + const request = pending.get(key); + if (!request) { + return; + } + + clearTimeout(request.timeout); + pending.delete(key); + + if (parsed.error?.message) { + request.reject( + new Error( + `${request.method} failed: ${String(parsed.error.message)}`, + ), + ); + return; + } + + request.resolve(parsed.result); + return; + } + + if (this.isServerRequest(parsed)) { + writeMessage({ + id: parsed.id, + error: { + code: -32601, + message: `Unsupported server request: ${parsed.method}`, + }, + }); + } + }); + + child.on("error", (error) => { + exited = true; + exitError = error; + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.reject(error); + } + pending.clear(); + }); + + child.on("exit", (code, signal) => { + exited = true; + if (!exitError && code !== 0) { + exitError = new Error( + `codex app-server exited (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + ); + } + for (const request of pending.values()) { + clearTimeout(request.timeout); + request.reject( + exitError ?? + new Error("codex app-server exited before request completed."), + ); + } + pending.clear(); + }); + + try { + await sendRequest("initialize", { + clientInfo: { + name: "codething_desktop", + title: "CodeThing Desktop", + version: "0.1.0", + }, + capabilities: { + experimentalApi: false, + }, + }); + + writeMessage({ method: "initialized" }); + + const deduped = new Map(); + let cursor: string | null = null; + + while (true) { + const response = await sendRequest("model/list", { + cursor, + limit: 100, + }); + const parsed = this.parseModelListResponse(response); + for (const model of parsed.models) { + deduped.set(model.model, model); + } + + if (!parsed.nextCursor) { + break; + } + cursor = parsed.nextCursor; + } + + return Array.from(deduped.values()); + } finally { + cleanup(); + } + } + async interruptTurn(sessionId: string, turnId?: string): Promise { const context = this.requireSession(sessionId); const effectiveTurnId = turnId ?? context.session.activeTurnId; @@ -622,6 +807,66 @@ export class CodexAppServerManager extends EventEmitter { return providerManager.listSessions(); }); + + ipcMain.handle( + IPC_CHANNELS.providerModelList, + async (_event, payload: unknown) => { + return providerManager.listModels( + payload as Parameters[0], + ); + }, + ); } async function runTerminalCommand( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9c44b6ef95241ddcde451fc9694bd5aaf2de0572..42d043f1f88a941bc201d6c8fac106b167de6698 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -41,6 +41,8 @@ const nativeApi: NativeApi = { stopSession: (input) => ipcRenderer.invoke(IPC_CHANNELS.providerSessionStop, input), listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.providerSessionList), + listModels: (input) => + ipcRenderer.invoke(IPC_CHANNELS.providerModelList, input), onEvent: (callback) => { const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload as Parameters[0]); diff --git a/apps/desktop/src/providerManager.ts b/apps/desktop/src/providerManager.ts index 2a9f37bcb2013647a86bd0b20d554037bb8f854b..69c3cec17228f6b5c1dc10dbe5e38782cc18819d 100644 --- a/apps/desktop/src/providerManager.ts +++ b/apps/desktop/src/providerManager.ts @@ -3,12 +3,15 @@ import { EventEmitter } from "node:events"; import { type ProviderEvent, type ProviderInterruptTurnInput, + type ProviderListModelsInput, + type ProviderModel, type ProviderSendTurnInput, type ProviderSession, type ProviderSessionStartInput, type ProviderStopSessionInput, type ProviderTurnStartResult, providerInterruptTurnInputSchema, + providerListModelsInputSchema, providerSendTurnInputSchema, providerSessionStartInputSchema, providerStopSessionInputSchema, @@ -63,6 +66,17 @@ export class ProviderManager extends EventEmitter { return this.codex.listSessions(); } + async listModels( + raw: ProviderListModelsInput = {}, + ): Promise { + const input = providerListModelsInputSchema.parse(raw); + if (input.provider !== "codex") { + throw new Error(`Provider '${input.provider}' is not implemented yet.`); + } + + return this.codex.listModels(input.cwd); + } + stopAll(): void { this.codex.stopAll(); } diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index dc4388df321bab0d40697942eaee78d9180602a0..d76ea9316ff2e3c4ee53db77395d79c9fb6a5a46 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -1,22 +1,54 @@ -import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import type { ProviderModel } from "@acme/contracts"; +import { + type FormEvent, + type KeyboardEvent, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; import { useStore } from "../store"; +const DEFAULT_MODEL = "gpt-5.2-codex"; + export default function ChatView() { const { state, dispatch } = useStore(); const api = useMemo(() => readNativeApi(), []); const [prompt, setPrompt] = useState(""); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); + const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [modelLoadError, setModelLoadError] = useState(null); + const [availableModels, setAvailableModels] = useState([]); const messagesEndRef = useRef(null); const textareaRef = useRef(null); + const modelMenuRef = useRef(null); const activeThread = state.threads.find((t) => t.id === state.activeThreadId); const activeProject = state.projects.find( (p) => p.id === activeThread?.projectId, ); + const selectedModel = + activeThread?.model || activeProject?.model || DEFAULT_MODEL; const phase = derivePhase(activeThread?.session ?? null); + const modelOptions = useMemo(() => { + const deduped = new Map(); + for (const model of availableModels) { + deduped.set(model.model, model); + } + if (!deduped.has(selectedModel)) { + deduped.set(selectedModel, { + id: selectedModel, + model: selectedModel, + displayName: selectedModel, + description: "Current model for this thread.", + }); + } + return Array.from(deduped.values()); + }, [availableModels, selectedModel]); // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; @@ -34,6 +66,67 @@ export default function ChatView() { ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, [prompt]); + useEffect(() => { + if (!isModelMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (!modelMenuRef.current) return; + if ( + event.target instanceof Node && + !modelMenuRef.current.contains(event.target) + ) { + setIsModelMenuOpen(false); + } + }; + + window.addEventListener("mousedown", handleClickOutside); + return () => { + window.removeEventListener("mousedown", handleClickOutside); + }; + }, [isModelMenuOpen]); + + useEffect(() => { + if (!api || !state.activeThreadId) return; + + let cancelled = false; + + const loadModels = async () => { + setIsLoadingModels(true); + setModelLoadError(null); + try { + const models = await api.providers.listModels( + activeProject?.cwd ? { cwd: activeProject.cwd } : undefined, + ); + if (cancelled) return; + + const sorted = [...models].sort((a, b) => { + if ((a.isDefault ?? false) && !(b.isDefault ?? false)) return -1; + if ((b.isDefault ?? false) && !(a.isDefault ?? false)) return 1; + return a.displayName.localeCompare(b.displayName); + }); + setAvailableModels(sorted); + } catch (err) { + if (cancelled) return; + setAvailableModels([]); + setModelLoadError( + err instanceof Error + ? err.message + : "Could not load available models from Codex.", + ); + } finally { + if (!cancelled) { + setIsLoadingModels(false); + } + } + }; + + void loadModels(); + + return () => { + cancelled = true; + }; + }, [api, activeProject?.cwd, state.activeThreadId]); + const ensureSession = async (): Promise => { if (!api || !activeThread || !activeProject) return false; if (activeThread.session && activeThread.session.status !== "closed") @@ -44,7 +137,7 @@ export default function ChatView() { const session = await api.providers.startSession({ provider: "codex", cwd: activeProject.cwd || undefined, - model: activeProject.model || undefined, + model: selectedModel || undefined, }); dispatch({ type: "UPDATE_SESSION", @@ -105,7 +198,11 @@ export default function ChatView() { setIsSending(true); try { - await api.providers.sendTurn({ sessionId, input: trimmed }); + await api.providers.sendTurn({ + sessionId, + input: trimmed, + model: selectedModel || undefined, + }); } catch (err) { dispatch({ type: "SET_ERROR", @@ -125,7 +222,17 @@ export default function ChatView() { }); }; - const onKeyDown = (e: React.KeyboardEvent) => { + const onModelSelect = (model: string) => { + if (!activeThread) return; + dispatch({ + type: "SET_THREAD_MODEL", + threadId: activeThread.id, + model, + }); + setIsModelMenuOpen(false); + }; + + const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(e as unknown as FormEvent); @@ -255,10 +362,10 @@ export default function ChatView() { onSubmit={onSend} className="mx-auto flex max-w-3xl items-end gap-2" > -
+