From fdb654de45d8bcac6bfee3d79199fb9bdcde844d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 8 Feb 2026 00:15:07 -0800 Subject: [PATCH] progress --- .agents/skills/btca-cli/SKILL.md | 20 + .agents/skills/btca-cli/agents/openai.yaml | 3 + .../desktop/src/codexAppServerManager.test.ts | 23 +- apps/desktop/src/codexAppServerManager.ts | 273 ++----------- apps/desktop/src/main.ts | 9 - apps/desktop/src/preload.ts | 2 - apps/desktop/src/providerManager.ts | 14 - apps/renderer/src/components/ChatView.tsx | 384 +++++++++++------- apps/renderer/src/components/Sidebar.tsx | 17 +- apps/renderer/src/model-logic.test.ts | 33 ++ apps/renderer/src/model-logic.ts | 39 ++ apps/renderer/src/session-logic.ts | 241 +++++++++++ apps/renderer/src/store.ts | 60 ++- packages/contracts/src/ipc.ts | 4 - packages/contracts/src/provider.test.ts | 12 +- packages/contracts/src/provider.ts | 19 +- 16 files changed, 695 insertions(+), 458 deletions(-) create mode 100644 .agents/skills/btca-cli/SKILL.md create mode 100644 .agents/skills/btca-cli/agents/openai.yaml create mode 100644 apps/renderer/src/model-logic.test.ts create mode 100644 apps/renderer/src/model-logic.ts diff --git a/.agents/skills/btca-cli/SKILL.md b/.agents/skills/btca-cli/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..e6b71ee4e8c172516f17140d6f6482d87091f7b6 --- /dev/null +++ b/.agents/skills/btca-cli/SKILL.md @@ -0,0 +1,20 @@ +--- +name: btca-cli +description: btca is a cli for getting information from other git repositories by querying them with natural language. Spawns an external subagent to explore external codebases (i.e. a github repo) and answer questions about them. Great when integrating complex libraries, frameworks, or tools. +--- + +btca is a cli for asking questions about various folders, primarily git repositories. It's built to give you the exact information you need without bloating your context window. btca uses agents under the hood to answer your question, so prompt it the way you would prompt a tool similar to yourself. + +For example, assuming you have a resource named `codex`, you can ask questions like: + +```bash +btca ask -r codex -q "What is the return format for Codex App Server responses? Are there any edge cases worth knowing?" +``` + +Your prompt may include specific names for resources to use, if they do then you should try those first. If you need to check available resources, you can use the `btca resources` command. + +New resources can be created if the ones you need do not yet exist. + +```bash +btca add -n svelte-dev https://github.com/sveltejs/svelte.dev +``` diff --git a/.agents/skills/btca-cli/agents/openai.yaml b/.agents/skills/btca-cli/agents/openai.yaml new file mode 100644 index 0000000000000000000000000000000000000000..37e40099138ee0e4b826f2363ef130528edb0f4d --- /dev/null +++ b/.agents/skills/btca-cli/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "BTCA CLI" + short_description: "Help with BTCA CLI setup and usage workflows" diff --git a/apps/desktop/src/codexAppServerManager.test.ts b/apps/desktop/src/codexAppServerManager.test.ts index 796aa2aedd99aa3551d1a903bfeff1e1c399d8be..fd028607d31a6f7264b154906802eb9e8fd4e352 100644 --- a/apps/desktop/src/codexAppServerManager.test.ts +++ b/apps/desktop/src/codexAppServerManager.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { classifyCodexStderrLine } from "./codexAppServerManager"; +import { + classifyCodexStderrLine, + normalizeCodexModelSlug, +} from "./codexAppServerManager"; describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { @@ -34,3 +37,21 @@ describe("classifyCodexStderrLine", () => { }); }); }); + +describe("normalizeCodexModelSlug", () => { + it("maps 5.3 aliases to gpt-5.3-codex", () => { + expect(normalizeCodexModelSlug("5.3")).toBe("gpt-5.3-codex"); + expect(normalizeCodexModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + }); + + it("prefers codex id when model differs", () => { + expect(normalizeCodexModelSlug("gpt-5.3", "gpt-5.3-codex")).toBe( + "gpt-5.3-codex", + ); + }); + + it("keeps non-aliased models as-is", () => { + expect(normalizeCodexModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex"); + expect(normalizeCodexModelSlug("gpt-5.2")).toBe("gpt-5.2"); + }); +}); diff --git a/apps/desktop/src/codexAppServerManager.ts b/apps/desktop/src/codexAppServerManager.ts index ee099d0ea519cacd42ab92ef8c4a82f66f24a6f0..f2dafc34dc892a4475c6be504278430b8603a3aa 100644 --- a/apps/desktop/src/codexAppServerManager.ts +++ b/apps/desktop/src/codexAppServerManager.ts @@ -5,7 +5,6 @@ import readline from "node:readline"; import type { ProviderEvent, - ProviderModel, ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, @@ -60,6 +59,34 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ "state db missing rollout path for thread", "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", ]; +const MODEL_SLUG_ALIASES: Record = { + "5.3": "gpt-5.3-codex", + "gpt-5.3": "gpt-5.3-codex", +}; + +export function normalizeCodexModelSlug( + model: string | undefined | null, + preferredId?: string, +): string | undefined { + if (typeof model !== "string") { + return undefined; + } + + const trimmed = model.trim(); + if (!trimmed) { + return undefined; + } + + if ( + preferredId && + preferredId.endsWith("-codex") && + preferredId !== trimmed + ) { + return preferredId; + } + + return MODEL_SLUG_ALIASES[trimmed] ?? trimmed; +} export function classifyCodexStderrLine( rawLine: string, @@ -104,7 +131,7 @@ export class CodexAppServerManager extends EventEmitter; model?: string; + effort?: string; } = { threadId: context.session.threadId, input: [ @@ -212,8 +240,12 @@ 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; @@ -807,66 +668,6 @@ 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 42d043f1f88a941bc201d6c8fac106b167de6698..9c44b6ef95241ddcde451fc9694bd5aaf2de0572 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -41,8 +41,6 @@ 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 69c3cec17228f6b5c1dc10dbe5e38782cc18819d..2a9f37bcb2013647a86bd0b20d554037bb8f854b 100644 --- a/apps/desktop/src/providerManager.ts +++ b/apps/desktop/src/providerManager.ts @@ -3,15 +3,12 @@ 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, @@ -66,17 +63,6 @@ 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 27b51ba9496189b965db1f006f67325a231e6a71..b580ef0119ac40acd16d18130da73356d57d2b5e 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -1,4 +1,3 @@ -import type { ProviderModel } from "@acme/contracts"; import { type FormEvent, type KeyboardEvent, @@ -8,10 +7,40 @@ import { useState, } from "react"; -import { derivePhase, formatTimestamp, readNativeApi } from "../session-logic"; +import { + derivePhase, + deriveWorkLogEntries, + formatElapsed, + formatTimestamp, + readNativeApi, +} from "../session-logic"; +import { + DEFAULT_MODEL, + DEFAULT_REASONING, + MODEL_OPTIONS, + REASONING_OPTIONS, + resolveModelSlug, +} from "../model-logic"; import { useStore } from "../store"; -const DEFAULT_MODEL = "gpt-5.2-codex"; +function formatMessageMeta(createdAt: string, duration: string | null): string { + if (!duration) return formatTimestamp(createdAt); + return `${formatTimestamp(createdAt)} • ${duration}`; +} + +function statusLabel(phase: string): string { + if (phase === "running") return "Thinking / working"; + if (phase === "connecting") return "Connecting"; + if (phase === "ready") return "Ready"; + return "Disconnected"; +} + +function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { + if (tone === "thinking") return "text-sky-100"; + if (tone === "tool") return "text-emerald-100"; + if (tone === "error") return "text-rose-100"; + return "text-[#d8d8d8]"; +} export default function ChatView() { const { state, dispatch } = useStore(); @@ -20,9 +49,10 @@ export default function ChatView() { 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 [selectedEffort, setSelectedEffort] = useState( + DEFAULT_REASONING, + ); + const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const modelMenuRef = useRef(null); @@ -31,31 +61,42 @@ export default function ChatView() { const activeProject = state.projects.find( (p) => p.id === activeThread?.projectId, ); - const selectedModel = - activeThread?.model || activeProject?.model || DEFAULT_MODEL; + const selectedModel = resolveModelSlug( + 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); + const isWorking = phase === "running" || isSending || isConnecting; + const activeTurnId = activeThread?.session?.activeTurnId; + const nowIso = new Date(nowTick).toISOString(); + const modelOptions = MODEL_OPTIONS; + const workLogEntries = useMemo( + () => deriveWorkLogEntries(activeThread?.events ?? [], activeTurnId), + [activeThread?.events, activeTurnId], + ); + const assistantCompletionByItemId = useMemo(() => { + const map = new Map(); + const ordered = [...(activeThread?.events ?? [])].reverse(); + for (const event of ordered) { + if (event.method !== "item/completed") continue; + if (!event.itemId) continue; + map.set(event.itemId, event.createdAt); } - 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]); + return map; + }, [activeThread?.events]); + const recentWorkLogEntries = workLogEntries.slice(-12); // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; + const workLogCount = recentWorkLogEntries.length; // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on message count change useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [messageCount]); + // biome-ignore lint/correctness/useExhaustiveDependencies: auto-scroll while active work-log events stream in + useEffect(() => { + if (phase !== "running") return; + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [phase, workLogCount]); // Auto-resize textarea // biome-ignore lint/correctness/useExhaustiveDependencies: trigger on prompt change @@ -66,6 +107,16 @@ export default function ChatView() { ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }, [prompt]); + useEffect(() => { + if (phase !== "running") return; + const timer = window.setInterval(() => { + setNowTick(Date.now()); + }, 250); + return () => { + window.clearInterval(timer); + }; + }, [phase]); + useEffect(() => { if (!isModelMenuOpen) return; @@ -85,48 +136,6 @@ export default function ChatView() { }; }, [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 null; if (activeThread.session && activeThread.session.status !== "closed") { @@ -197,6 +206,7 @@ export default function ChatView() { sessionId, input: trimmed, model: selectedModel || undefined, + effort: selectedEffort || undefined, }); } catch (err) { dispatch({ @@ -222,7 +232,7 @@ export default function ChatView() { dispatch({ type: "SET_THREAD_MODEL", threadId: activeThread.id, - model, + model: resolveModelSlug(model), }); setIsModelMenuOpen(false); }; @@ -266,19 +276,38 @@ export default function ChatView() {
{/* Status indicator */} -
-
+ + {(phase === "running" || phase === "connecting") && ( + + )} + - {phase} + ? "bg-amber-200" + : phase === "ready" + ? "bg-emerald-200" + : "bg-[#a0a0a0]/40" + }`} + /> + + {statusLabel(phase)}
{/* Diff toggle */} - {isModelMenuOpen && ( -
-

- Select model -

-
- {modelOptions.map((model) => { - const isSelected = model.model === selectedModel; - return ( - + {isModelMenuOpen && ( +
+

+ Select model +

+
+ {modelOptions.map((model) => { + const isSelected = model === selectedModel; + return ( + - ); - })} + {model} + + ✓ + + + ); + })} +
- {isLoadingModels && ( -

- Refreshing available models... -

- )} - {modelLoadError && ( -

- Could not load full model list from Codex:{" "} - {modelLoadError} -

- )} -
- )} + )} +
+
{activeProject && ( diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx index a998d2208494caed63d85e62184737c6833af11d..7e2c74d1fff9a8692d06fc389b8ecafda24d513e 100644 --- a/apps/renderer/src/components/Sidebar.tsx +++ b/apps/renderer/src/components/Sidebar.tsx @@ -1,9 +1,8 @@ import { useState } from "react"; +import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic"; import { useStore } from "../store"; import type { Project } from "../types"; -const DEFAULT_MODEL = "gpt-5.2-codex"; - function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -24,11 +23,12 @@ export default function Sidebar() { const cwd = newCwd.trim(); if (!cwd) return; const name = cwd.split("/").filter(Boolean).pop() ?? "project"; + const normalizedModel = resolveModelSlug(newModel); const project: Project = { id: crypto.randomUUID(), name, cwd, - model: newModel.trim() || DEFAULT_MODEL, + model: normalizedModel, expanded: true, }; dispatch({ type: "ADD_PROJECT", project }); @@ -186,16 +186,21 @@ export default function Sidebar() { if (e.key === "Escape") setAddingProject(false); }} /> - setNewModel(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleAddProject(); if (e.key === "Escape") setAddingProject(false); }} - /> + > + {MODEL_OPTIONS.map((model) => ( + + ))} +