diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 5ed785bf..96d1424e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -41,6 +41,7 @@ import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js' import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js'; import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js'; import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js'; +import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js'; import * as codeGit from '@x/core/dist/code-mode/git/service.js'; import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js'; import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js'; @@ -972,6 +973,10 @@ export function setupIpcHandlers() { const service = container.resolve('codeSessionService'); return { session: await service.update(args.sessionId, args.patch) }; }, + 'codeMode:listModelOptions': async (_event, args) => { + const manager = container.resolve('codeModeManager'); + return manager.listModelOptions(args.agent); + }, 'codeSession:delete': async (_event, args) => { const service = container.resolve('codeSessionService'); disposeTerminal(args.sessionId); diff --git a/apps/x/apps/renderer/src/components/code/code-agent-options.ts b/apps/x/apps/renderer/src/components/code/code-agent-options.ts new file mode 100644 index 00000000..9bbe6db1 --- /dev/null +++ b/apps/x/apps/renderer/src/components/code/code-agent-options.ts @@ -0,0 +1,28 @@ +import type { CodingAgent } from '@x/shared/src/code-mode.js' +import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js' + +// Model + effort choices for a coding agent, discovered live from the engine +// (the same list `/model` shows) via the main process, which caches per agent. +// We memoize the in-flight/resolved promise per agent here too so reopening the +// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI +// just falls back to the engine default. +const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] } +const cache = new Map>() + +export function fetchCodeAgentOptions(agent: CodingAgent): Promise { + let pending = cache.get(agent) + if (!pending) { + pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY) + cache.set(agent, pending) + } + return pending +} + +// Always offer a Default fallback even before options load (or if discovery fails). +export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] { + return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options] +} + +export function optionLabel(options: CodeAgentOption[], value: string | undefined): string { + return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default' +} diff --git a/apps/x/apps/renderer/src/components/code/code-view.tsx b/apps/x/apps/renderer/src/components/code/code-view.tsx index 6ccb1986..7e1f88d3 100644 --- a/apps/x/apps/renderer/src/components/code/code-view.tsx +++ b/apps/x/apps/renderer/src/components/code/code-view.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react' -import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js' +import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js' +import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options' import type { ApprovalPolicy } from '@x/shared/src/code-mode.js' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -31,6 +32,16 @@ const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height' const TERMINAL_MIN_HEIGHT = 120 const TERMINAL_MAX_HEIGHT = 600 +// Remember which session was open so leaving the Code section (which unmounts +// this view) and coming back restores the selection — and with it the chat +// output in the right pane — instead of dropping back to the empty state. +const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session' + +function readStoredSelectedSessionId(): string | null { + if (typeof window === 'undefined') return null + return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null +} + function readStoredTerminalHeight(): number { if (typeof window === 'undefined') return 240 const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY)) @@ -70,7 +81,7 @@ export function CodeView({ onDiffOpened?: () => void }) { const { projects, sessions, statusOf, refresh } = useCodeSessions() - const [selectedSessionId, setSelectedSessionId] = useState(null) + const [selectedSessionId, setSelectedSessionId] = useState(readStoredSelectedSessionId) const [newSessionProjectId, setNewSessionProjectId] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [terminalOpen, setTerminalOpen] = useState(false) @@ -81,6 +92,11 @@ export function CodeView({ window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight)) }, [terminalHeight]) + useEffect(() => { + if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId) + else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY) + }, [selectedSessionId]) + const handleTerminalDragStart = useCallback((e: React.MouseEvent) => { e.preventDefault() dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight } @@ -104,6 +120,17 @@ export function CodeView({ const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle' const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null + // Live model/effort choices for the selected session's agent, for the header + // pickers. Discovered from the engine and cached, so this is cheap to re-run. + const [modelOpts, setModelOpts] = useState({ models: [], efforts: [] }) + const selectedAgent = selectedSession?.agent + useEffect(() => { + if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return } + let cancelled = false + void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) }) + return () => { cancelled = true } + }, [selectedAgent]) + // Tell App which session (and status) owns the right-hand chat pane. useEffect(() => { onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null) @@ -152,7 +179,7 @@ export function CodeView({ } }, [refresh, selectedSessionId]) - const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => { + const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex'; agentModel?: string; agentEffort?: string }) => { if (!selectedSessionId) return try { await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch }) @@ -201,6 +228,50 @@ export function CodeView({
+ + + + + + {withDefault(modelOpts.models).map((m) => ( + void handleUpdateSession({ agentModel: m.value })}> + {m.label} + {(selectedSession.agentModel ?? 'default') === m.value && } + + ))} + + + {modelOpts.efforts.length > 0 && ( + + + + + + {withDefault(modelOpts.efforts).map((e) => ( + void handleUpdateSession({ agentEffort: e.value })}> + {e.label} + {(selectedSession.agentEffort ?? 'default') === e.value && } + + ))} + + + )}
+ {/* The coding agent's own model + reasoning effort, discovered live + from the engine and applied to the ACP session each turn (so they + stay editable from the session header later). Effort is a separate + axis only for Claude; Codex folds it into the model id. */} +
+
+ + +
+ {modelOpts.efforts.length > 0 && ( +
+ + +
+ )} +
+ {/* The model only powers Rowboat's own turns; the coding agent uses its own configured model, so hide this entirely for direct sessions. */} {mode === 'rowboat' && modelOptions.length > 0 && ( diff --git a/apps/x/packages/core/src/code-mode/acp/client.ts b/apps/x/packages/core/src/code-mode/acp/client.ts index 477c994b..b78c5619 100644 --- a/apps/x/packages/core/src/code-mode/acp/client.ts +++ b/apps/x/packages/core/src/code-mode/acp/client.ts @@ -37,6 +37,74 @@ const STARTUP_TIMEOUT_MS = Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) > ? Number(process.env.ROWBOAT_ACP_STARTUP_TIMEOUT_MS) : 60_000; +export interface CodeAgentOption { value: string; label: string } +export interface CodeAgentModelOptions { models: CodeAgentOption[]; efforts: CodeAgentOption[] } + +// The agent advertises its model + effort choices on the session it opens (the +// same data that backs its `/model` picker), in one of two shapes: +// - `configOptions`: select options with id "model" / "effort" (Claude). +// - `models`: a SessionModelState { availableModels: [{ modelId, name }] } +// (Codex — which folds effort into the model id, so no separate effort). +// We read configOptions first and fall back to `models`, then prepend a +// synthetic "Default" so the user can always keep the engine default. +type RawSelectOption = { value?: unknown; name?: unknown; options?: Array<{ value?: unknown; name?: unknown }> }; +type RawConfigOption = { id?: string; options?: RawSelectOption[] }; +type RawModelState = { availableModels?: Array<{ modelId?: unknown; name?: unknown }> }; + +function withDefault(choices: CodeAgentOption[]): CodeAgentOption[] { + return choices.some((c) => c.value === 'default') + ? choices + : [{ value: 'default', label: 'Default' }, ...choices]; +} + +function toChoices(option: RawConfigOption | undefined): CodeAgentOption[] { + const flat = (option?.options ?? []).flatMap((o) => (Array.isArray(o.options) ? o.options : [o])); + return flat + .filter((o): o is { value: string; name?: unknown } => typeof o.value === 'string') + .map((o) => ({ value: o.value, label: typeof o.name === 'string' && o.name ? o.name : o.value })); +} + +function modelStateChoices(models: RawModelState | undefined): CodeAgentOption[] { + return (models?.availableModels ?? []) + .filter((m): m is { modelId: string; name?: unknown } => typeof m.modelId === 'string') + .map((m) => ({ value: m.modelId, label: typeof m.name === 'string' && m.name ? m.name : m.modelId })); +} + +export function extractModelOptions(configOptions: unknown, models?: unknown): CodeAgentModelOptions { + const list = (Array.isArray(configOptions) ? configOptions : []) as RawConfigOption[]; + const modelOpt = list.find((o) => o.id === 'model'); + const effortOpt = list.find((o) => o.id === 'effort'); + const modelChoices = toChoices(modelOpt); + return { + // configOptions is authoritative when present; otherwise fall back to the + // SessionModelState list (Codex reports models only there). + models: withDefault(modelChoices.length ? modelChoices : modelStateChoices(models as RawModelState)), + efforts: effortOpt ? withDefault(toChoices(effortOpt)) : [], + }; +} + +// Claude's `availableModels` exposes its top model only as "Default +// (recommended)" and omits an explicit "Opus" row (the interactive `/model` +// lists it, the ACP adapter dedupes it). Surface the canonical aliases +// explicitly for clarity — the adapter resolves "opus"/"sonnet"/"haiku" to the +// concrete model. Deduped against what the engine already returned, so in +// practice this only adds the missing "Opus" entry, placed right after Default. +const CLAUDE_ALIAS_ROWS: CodeAgentOption[] = [ + { value: 'opus', label: 'Opus' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'haiku', label: 'Haiku' }, +]; + +function withClaudeAliases(options: CodeAgentModelOptions): CodeAgentModelOptions { + const have = new Set(options.models.map((m) => m.value)); + const extra = CLAUDE_ALIAS_ROWS.filter((r) => !have.has(r.value)); + if (extra.length === 0) return options; + const at = options.models.findIndex((m) => m.value === 'default'); + const models = [...options.models]; + models.splice(at >= 0 ? at + 1 : 0, 0, ...extra); + return { ...options, models }; +} + // Map a raw ACP session/update notification onto our small CodeRunEvent union. function toEvent(update: SessionUpdate): CodeRunEvent { switch (update.sessionUpdate) { @@ -180,6 +248,20 @@ export class AcpClient { } } + // Open a throwaway session purely to read the agent's advertised model + + // effort choices, then let the caller dispose this client. Used for the + // model picker before any real session exists. + async describeModelOptions(): Promise { + try { + const res = await this.withStartupTimeout(this.conn().newSession({ cwd: this.cwd, mcpServers: [] })); + const r = res as { configOptions?: unknown; models?: unknown }; + const options = extractModelOptions(r.configOptions, r.models); + return this.agent === 'claude' ? withClaudeAliases(options) : options; + } catch (e) { + throw this.enrich(e, 'describeModelOptions'); + } + } + async loadSession(sessionId: string): Promise { try { await this.withStartupTimeout(this.conn().loadSession({ sessionId, cwd: this.cwd, mcpServers: [] })); @@ -188,6 +270,20 @@ export class AcpClient { } } + // Point the open session at a specific model. The adapter resolves aliases + // ("opus"/"sonnet"/…) to concrete ids. Throws if the model is unknown; the + // caller applies this best-effort so a bad value never blocks a turn. + async setModel(sessionId: string, modelId: string): Promise { + await this.conn().unstable_setSessionModel({ sessionId, modelId }); + } + + // Set the reasoning-effort level via the agent's "effort" config option. + // The option only exists for models that support it, so this throws for + // others — again applied best-effort by the caller. + async setEffort(sessionId: string, value: string): Promise { + await this.conn().setSessionConfigOption({ sessionId, configId: 'effort', value }); + } + async prompt(sessionId: string, text: string): Promise { try { return await this.conn().prompt({ sessionId, prompt: [{ type: 'text', text }] }); diff --git a/apps/x/packages/core/src/code-mode/acp/manager.ts b/apps/x/packages/core/src/code-mode/acp/manager.ts index 0e12d9ed..3e64bbc6 100644 --- a/apps/x/packages/core/src/code-mode/acp/manager.ts +++ b/apps/x/packages/core/src/code-mode/acp/manager.ts @@ -1,5 +1,6 @@ +import * as os from 'os'; import type { ApprovalPolicy, CodeRunEvent, CodingAgent, PermissionAsk, PermissionDecision, RunPromptResult } from './types.js'; -import { AcpClient } from './client.js'; +import { AcpClient, type CodeAgentModelOptions } from './client.js'; import { PermissionBroker } from './permission-broker.js'; import { readStoredSession, writeStoredSession, clearStoredSession } from './session-store.js'; @@ -9,6 +10,11 @@ export interface RunPromptArgs { cwd: string; prompt: string; policy: ApprovalPolicy; + /** Coding-agent model alias/id (e.g. "opus"); applied to the ACP session + * before the prompt. Omitted / "default" leaves the engine default. */ + model?: string; + /** Reasoning-effort level (e.g. "high"); applied alongside the model. */ + effort?: string; /** Called when the policy needs the user to decide (the "ask" path). */ ask: (ask: PermissionAsk) => Promise; /** Stream sink for this prompt's run. */ @@ -56,9 +62,32 @@ const CANCEL_GRACE_MS = 2_000; // resumes the persisted session via session/load. export class CodeModeManager { private readonly runs = new Map(); + // Per-agent model/effort choices, discovered once from the engine and reused + // (the list only changes when the provider ships new models, and the app can + // be restarted to pick those up). Avoids cold-starting an adapter per picker. + private readonly modelOptionsCache = new Map(); + + // Discover a coding agent's available models + effort levels straight from + // the engine (what its `/model` picker would show). Spawns a short-lived + // adapter, opens a throwaway session to read its advertised options, and + // tears it down. Cached per agent for the lifetime of the process. + async listModelOptions(agent: CodingAgent): Promise { + const cached = this.modelOptionsCache.get(agent); + if (cached) return cached; + const broker = new PermissionBroker({ policy: 'yolo', ask: async () => 'reject' }); + const client = new AcpClient({ agent, cwd: os.homedir(), broker, onEvent: () => {} }); + try { + await client.start(); + const options = await client.describeModelOptions(); + this.modelOptionsCache.set(agent, options); + return options; + } finally { + client.dispose(); + } + } async runPrompt(args: RunPromptArgs): Promise { - const { runId, agent, cwd, prompt, policy, ask, onEvent, signal, suppressReplay } = args; + const { runId, agent, cwd, prompt, policy, model, effort, ask, onEvent, signal, suppressReplay } = args; const broker = new PermissionBroker({ policy, @@ -67,6 +96,10 @@ export class CodeModeManager { }); const run = await this.ensureRun(runId, agent, cwd, broker, onEvent, suppressReplay ?? false); + // Re-apply the session's model + effort each turn (idempotent): a warm + // connection keeps the last selection, but a cold session/load resets it, + // and the user may have changed it from the header since the last turn. + await this.applyModelAndEffort(run, model, effort); run.inflight++; let graceTimer: ReturnType | undefined; @@ -109,6 +142,26 @@ export class CodeModeManager { } } + // Best-effort: a model the engine doesn't know, or an effort level a model + // doesn't support, must not abort the turn — we log and proceed with the + // engine default rather than surfacing a hard error to the user. + private async applyModelAndEffort(run: ActiveRun, model?: string, effort?: string): Promise { + if (model && model !== 'default') { + try { + await run.client.setModel(run.sessionId, model); + } catch (e) { + console.warn(`[code-mode] could not set model "${model}": ${e instanceof Error ? e.message : String(e)}`); + } + } + if (effort && effort !== 'default') { + try { + await run.client.setEffort(run.sessionId, effort); + } catch (e) { + console.warn(`[code-mode] could not set effort "${effort}": ${e instanceof Error ? e.message : String(e)}`); + } + } + } + dispose(runId: string): void { const run = this.runs.get(runId); if (!run) return; diff --git a/apps/x/packages/core/src/code-mode/sessions/service.ts b/apps/x/packages/core/src/code-mode/sessions/service.ts index 6eaf047b..99578dab 100644 --- a/apps/x/packages/core/src/code-mode/sessions/service.ts +++ b/apps/x/packages/core/src/code-mode/sessions/service.ts @@ -27,6 +27,10 @@ export interface CreateSessionArgs { // LLM for Rowboat-mode turns; unset falls through to the configured default. model?: string; provider?: string; + // The coding agent's own model + reasoning effort (ACP engine); unset leaves + // the engine default. Re-applied to the ACP session on every turn. + agentModel?: string; + agentEffort?: string; } export interface SendMessageResult { @@ -142,6 +146,8 @@ export class CodeSessionService { policy: args.policy, cwd, ...(worktree ? { worktree } : {}), + ...(args.agentModel ? { agentModel: args.agentModel } : {}), + ...(args.agentEffort ? { agentEffort: args.agentEffort } : {}), createdAt: new Date().toISOString(), }; await this.codeSessionsRepo.save(session); @@ -149,7 +155,7 @@ export class CodeSessionService { return session; } - async update(sessionId: string, patch: Partial>): Promise { + async update(sessionId: string, patch: Partial>): Promise { const session = await this.codeSessionsRepo.get(sessionId); if (!session) throw new Error(`Unknown session: ${sessionId}`); const updated: CodeSession = { ...session, ...patch }; @@ -217,6 +223,8 @@ export class CodeSessionService { cwd: session.cwd, prompt: text, policy: session.policy, + ...(session.agentModel ? { model: session.agentModel } : {}), + ...(session.agentEffort ? { effort: session.agentEffort } : {}), signal, suppressReplay: true, onEvent: (event) => { diff --git a/apps/x/packages/shared/src/code-sessions.ts b/apps/x/packages/shared/src/code-sessions.ts index 2ec8a027..61efc93a 100644 --- a/apps/x/packages/shared/src/code-sessions.ts +++ b/apps/x/packages/shared/src/code-sessions.ts @@ -53,11 +53,34 @@ export const CodeSession = z.object({ // Where the agent works: the project path, or the worktree path. cwd: z.string(), worktree: CodeWorktree.optional(), + // The coding agent's own model + reasoning effort (applied to the ACP engine, + // not the Rowboat-mode LLM). Values come from CODE_AGENT_MODELS / + // CODE_AGENT_EFFORTS; unset (or 'default') leaves the engine's own default. + agentModel: z.string().optional(), + agentEffort: z.string().optional(), createdAt: z.iso.datetime(), lastActivityAt: z.iso.datetime().optional(), }); export type CodeSession = z.infer; +// Model + effort choices for the ACP coding agents are discovered live from the +// engine (the same list `/model` shows), not hardcoded — so they always reflect +// whatever the provider currently offers. See the `codeMode:listModelOptions` +// IPC and CodeModeManager.listModelOptions. 'default' is a synthetic sentinel +// meaning "don't override the engine default". +// +// Claude exposes model and effort as two independent options; Codex folds the +// reasoning effort into the model id ("gpt-5-codex[high]") and so reports no +// separate effort list. The UI renders whatever each agent advertises. +export const CodeAgentOption = z.object({ value: z.string(), label: z.string() }); +export type CodeAgentOption = z.infer; + +export const CodeAgentModelOptions = z.object({ + models: z.array(CodeAgentOption), + efforts: z.array(CodeAgentOption), +}); +export type CodeAgentModelOptions = z.infer; + export const GitFileState = z.enum(["modified", "added", "deleted", "untracked", "renamed"]); export type GitFileState = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index c3ed2448..202e0713 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -21,7 +21,7 @@ import { BillingInfoSchema } from './billing.js'; import { EmailBlockSchema, GmailThreadSchema } from './blocks.js'; import { PermissionDecision, ApprovalPolicy, CodingAgent } from './code-mode.js'; import { NotificationSettingsSchema } from './notification-settings.js'; -import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile } from './code-sessions.js'; +import { CodeProject, CodeSession, CodeSessionMode, CodeSessionStatus, GitRepoInfo, GitStatusFile, CodeAgentModelOptions } from './code-sessions.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -601,6 +601,10 @@ const ipcSchemas = { // chat, the model is fixed once the session's run exists. model: z.string().optional(), provider: z.string().optional(), + // The coding agent's own model + reasoning effort (ACP engine). Unlike the + // Rowboat model these are re-applied each turn, so they stay editable. + agentModel: z.string().optional(), + agentEffort: z.string().optional(), }), res: z.object({ session: CodeSession, @@ -616,12 +620,18 @@ const ipcSchemas = { 'codeSession:update': { req: z.object({ sessionId: z.string(), - patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true }).partial(), + patch: CodeSession.pick({ title: true, mode: true, policy: true, agent: true, agentModel: true, agentEffort: true }).partial(), }), res: z.object({ session: CodeSession, }), }, + // Live model + effort choices for a coding agent, discovered from the engine + // (cached per agent in the main process). Mirrors what `/model` would show. + 'codeMode:listModelOptions': { + req: z.object({ agent: CodingAgent }), + res: CodeAgentModelOptions, + }, 'codeSession:delete': { req: z.object({ sessionId: z.string(),