From 10841f25f5df3a329526604e72e5f672d42e1a34 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:59:04 -0400 Subject: [PATCH 01/17] ade code: per-provider Chat/CLI interface choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Interface: Chat | CLI row to the ADE Code (Ink TUI) new-chat and /model setup panes, matching the desktop/iOS switcher. Chat creates an SDK chat via chat.createSession (all providers, including Claude — a new path); CLI starts a tracked provider CLI terminal via start_cli_session (claude/codex/cursor/droid/opencode). Defaults to Chat; editable on a draft, read-only once a session exists. - Generalize the Claude-only terminal paths: startClaudeTerminalSession -> provider-generic startCliTerminalSession; listTerminalSessions and remoteLauncher.isTerminalSessionLaunchable now surface every tracked CLI provider (not just Claude); terminalSessionToChatSummary, the Ctrl+T control gates, TerminalPane status (" CONTROL"), FooterControls label, and grid control hint are provider-neutral. - Submit-path branching: focused terminal -> pty send/resume (Claude keeps its double-enter, other providers use pty.sendToSession); draft Interface=CLI -> tracked CLI terminal; otherwise chat.createSession. - Interface-aware Cursor model gating in the model picker (Chat disables CLI-only Cursor models and vice versa). - Keep Claude-only chrome: closed-transcript stripping, naming hint, and /model + /effort writing into a running Claude terminal. - Tests: provider-generic start payloads (5 providers), trackedCli provider resolution, listTerminalSessions/isTerminalSessionLaunchable inclusivity, interface-row state machine + defaulting, Cursor gating. - Docs: ADE Code README chat-setup + terminal-control sections. Co-Authored-By: Claude Opus 4.8 --- .../src/tuiClient/__tests__/adeApi.test.ts | 118 ++++++++- .../tuiClient/__tests__/interfaceMode.test.ts | 87 +++++++ .../src/tuiClient/__tests__/planMode.test.ts | 1 + .../__tests__/remoteLauncher.test.ts | 27 +++ apps/ade-cli/src/tuiClient/adeApi.ts | 58 +++-- apps/ade-cli/src/tuiClient/app.tsx | 226 +++++++++++++----- .../tuiClient/components/FooterControls.tsx | 10 +- .../ModelPicker/ModelPickerPane.tsx | 1 + .../ModelPicker/modelPickerLayout.test.ts | 30 +++ .../ModelPicker/modelPickerLayout.ts | 23 +- .../src/tuiClient/components/RightPane.tsx | 3 + .../src/tuiClient/components/TerminalPane.tsx | 50 +++- apps/ade-cli/src/tuiClient/remoteLauncher.ts | 18 +- apps/ade-cli/src/tuiClient/types.ts | 11 + docs/features/ade-code/README.md | 26 +- 15 files changed, 587 insertions(+), 102 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/interfaceMode.test.ts diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index efd02b60b..8b963937b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,8 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startClaudeTerminalSession, steerChatMessage, unarchiveChatSession } from "../adeApi"; +import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; +import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import type { AdeCodeConnection } from "../types"; const tmpPaths: string[] = []; @@ -530,7 +531,7 @@ describe("createChatSession", () => { }); }); -describe("startClaudeTerminalSession", () => { +describe("startCliTerminalSession", () => { it("passes Claude model reasoning and permission controls to start_cli_session", async () => { const calls: Array<{ name: string; args?: Record }> = []; const connection = { @@ -544,8 +545,9 @@ describe("startClaudeTerminalSession", () => { }, } as unknown as AdeCodeConnection; - await startClaudeTerminalSession({ + await startCliTerminalSession({ connection, + provider: "claude", laneId: "lane-1", title: "Claude smoke", model: "anthropic/claude-sonnet-4-6", @@ -574,6 +576,101 @@ describe("startClaudeTerminalSession", () => { }, ]); }); + + it("launches every provider CLI with its selected provider + fast mode", async () => { + for (const provider of ["codex", "cursor", "droid", "opencode", "claude"] as const) { + const calls: Array<{ name: string; args?: Record }> = []; + const connection = { + tool: async (name: string, args?: Record) => { + calls.push({ name, args }); + return { sessionId: `term-${provider}`, terminalId: `term-${provider}`, session: null }; + }, + } as unknown as AdeCodeConnection; + + await startCliTerminalSession({ + connection, + provider, + laneId: "lane-1", + model: `${provider}-model`, + reasoningEffort: "medium", + fastMode: true, + permissionMode: "default", + initialInput: "Go", + cols: 120, + rows: 36, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]!.name).toBe("start_cli_session"); + expect(calls[0]!.args).toEqual(expect.objectContaining({ + laneId: "lane-1", + provider, + model: `${provider}-model`, + reasoningEffort: "medium", + fastMode: true, + permissionMode: "default", + initialInput: "Go", + tracked: true, + })); + } + }); + + it("omits fastMode from the payload when the caller does not set it", async () => { + const calls: Array<{ name: string; args?: Record }> = []; + const connection = { + tool: async (name: string, args?: Record) => { + calls.push({ name, args }); + return { sessionId: "term-1", terminalId: "term-1", session: null }; + }, + } as unknown as AdeCodeConnection; + + await startCliTerminalSession({ connection, provider: "codex", laneId: "lane-1", cols: 100, rows: 28 }); + + expect(calls[0]!.args).not.toHaveProperty("fastMode"); + }); +}); + +describe("trackedCliTerminalProvider", () => { + const session = (overrides: Partial): ChatTerminalSession => ({ + terminalId: "t", + ptyId: null, + chatSessionId: null, + laneId: "lane-1", + laneName: "lane-1", + title: "t", + goal: null, + toolType: "shell", + status: "running", + runtimeState: "running", + active: true, + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + exitCode: null, + pid: null, + resumeCommand: null, + resumeMetadata: null, + lastOutputPreview: null, + summary: null, + ...overrides, + }); + + it("resolves each tracked CLI tool type to its provider", () => { + expect(trackedCliTerminalProvider(session({ toolType: "claude" }))).toBe("claude"); + expect(trackedCliTerminalProvider(session({ toolType: "claude-orchestrated" }))).toBe("claude"); + expect(trackedCliTerminalProvider(session({ toolType: "codex" }))).toBe("codex"); + expect(trackedCliTerminalProvider(session({ toolType: "cursor-cli" }))).toBe("cursor"); + expect(trackedCliTerminalProvider(session({ toolType: "droid" }))).toBe("droid"); + expect(trackedCliTerminalProvider(session({ toolType: "opencode" }))).toBe("opencode"); + }); + + it("falls back to resume metadata / command, and rejects plain shells", () => { + expect(trackedCliTerminalProvider(session({ + toolType: "shell", + resumeMetadata: { provider: "codex", targetKind: "session", targetId: "x", launch: {} }, + }))).toBe("codex"); + expect(trackedCliTerminalProvider(session({ toolType: "shell", resumeCommand: "claude --resume s1" }))).toBe("claude"); + expect(trackedCliTerminalProvider(session({ toolType: "shell" }))).toBeNull(); + }); }); describe("resumeTerminalSession", () => { @@ -664,7 +761,7 @@ describe("signalTerminal", () => { }); describe("listTerminalSessions", () => { - it("only exposes Claude Code CLI sessions in the ADE code TUI", async () => { + it("exposes every tracked provider CLI session but hides chat-backed terminals and plain shells", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; const sessions = [ { terminalId: "claude-1", toolType: "claude" }, @@ -674,9 +771,14 @@ describe("listTerminalSessions", () => { { terminalId: "codex-1", toolType: "codex" }, { terminalId: "codex-orch-1", toolType: "codex-orchestrated" }, { terminalId: "legacy-codex-1", toolType: "shell", resumeMetadata: { provider: "codex" } }, + { terminalId: "cursor-cli-1", toolType: "cursor-cli" }, + { terminalId: "droid-1", toolType: "droid" }, + { terminalId: "opencode-1", toolType: "opencode" }, + // Chat-backed terminals (surface via the chat session list) + plain shells stay hidden. { terminalId: "chat-claude-1", toolType: "claude-chat" }, { terminalId: "chat-codex-1", toolType: "codex-chat" }, - { terminalId: "cursor-1", toolType: "cursor" }, + { terminalId: "chat-cursor-1", toolType: "cursor" }, + { terminalId: "chat-droid-1", toolType: "droid-chat" }, { terminalId: "shell-1", toolType: "shell" }, ]; const connection = { @@ -700,6 +802,12 @@ describe("listTerminalSessions", () => { "claude-orch-1", "legacy-claude-1", "legacy-claude-command-1", + "codex-1", + "codex-orch-1", + "legacy-codex-1", + "cursor-cli-1", + "droid-1", + "opencode-1", ]); }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/interfaceMode.test.ts b/apps/ade-cli/src/tuiClient/__tests__/interfaceMode.test.ts new file mode 100644 index 000000000..ba7e6f627 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/interfaceMode.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { buildSetupRows, cliProviderForModelStateProvider } from "../app"; +import type { AdeCodeInterfaceMode, AdeCodeModelState } from "../types"; + +function baseModelState(overrides: Partial = {}): AdeCodeModelState { + return { + provider: "codex", + interfaceMode: "chat", + model: "gpt-5.5", + modelId: null, + displayName: "GPT-5.5", + reasoningEffort: "medium", + fastMode: false, + permissionMode: "default", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorAvailableModeIds: [], + cursorConfigValues: {}, + ...overrides, + }; +} + +function setupRows(interfaceMode: AdeCodeInterfaceMode, interfaceEditable: boolean) { + return buildSetupRows({ + modelState: baseModelState({ interfaceMode }), + models: [], + includeRefresh: false, + includeApply: true, + interfaceMode, + interfaceEditable, + }); +} + +// Mirror of the toggle the setup-row handler applies (Chat ↔ CLI). +function toggleInterface(mode: AdeCodeInterfaceMode): AdeCodeInterfaceMode { + return mode === "cli" ? "chat" : "cli"; +} + +describe("cliProviderForModelStateProvider", () => { + it("maps each of the five CLI providers to itself", () => { + for (const provider of ["claude", "codex", "cursor", "droid", "opencode"] as const) { + expect(cliProviderForModelStateProvider(provider)).toBe(provider); + } + }); + + it("returns null for providers with no tracked CLI (Ollama / LM Studio)", () => { + expect(cliProviderForModelStateProvider("ollama")).toBeNull(); + expect(cliProviderForModelStateProvider("lmstudio")).toBeNull(); + }); +}); + +describe("buildSetupRows interface row", () => { + it("inserts the Interface row immediately after Provider", () => { + const rows = setupRows("chat", true); + const kinds = rows.map((row) => row.kind); + expect(kinds[0]).toBe("provider"); + expect(kinds[1]).toBe("interface"); + }); + + it("defaults to Chat and reflects the current interface value", () => { + expect(setupRows("chat", true).find((row) => row.kind === "interface")?.value).toBe("Chat"); + expect(setupRows("cli", true).find((row) => row.kind === "interface")?.value).toBe("CLI"); + }); + + it("is editable/cyclable for a draft and read-only once a session exists", () => { + const draftRow = setupRows("chat", true).find((row) => row.kind === "interface")!; + expect(draftRow.disabled).toBeFalsy(); + expect(draftRow.cyclable).toBe(true); + expect(draftRow.detail).toBe("Chat · CLI"); + + const committedRow = setupRows("cli", false).find((row) => row.kind === "interface")!; + expect(committedRow.disabled).toBe(true); + expect(committedRow.cyclable).toBe(false); + expect(committedRow.detail).toBe("tracked CLI session"); + }); + + it("toggles Chat ↔ CLI (two-value state machine)", () => { + expect(toggleInterface("chat")).toBe("cli"); + expect(toggleInterface("cli")).toBe("chat"); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts index d0b83032d..be21a3789 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/planMode.test.ts @@ -6,6 +6,7 @@ import type { AdeCodeModelState } from "../types"; function baseModelState(overrides: Partial): AdeCodeModelState { return { provider: "codex", + interfaceMode: "chat", model: "gpt-5.5", modelId: null, displayName: "GPT-5.5", diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts index 8322bf2cb..af8ecedc7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteLauncher.test.ts @@ -209,6 +209,33 @@ describe("ade code remote launcher", () => { ]); }); + it("lists every tracked provider CLI terminal and hides chat-backed terminals", async () => { + const terminals = [ + { terminalId: "claude-cli", toolType: "claude", laneId: "lane-1", title: "Claude", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "codex-cli", toolType: "codex", laneId: "lane-1", title: "Codex", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "cursor-cli", toolType: "cursor-cli", laneId: "lane-1", title: "Cursor", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "droid-cli", toolType: "droid", laneId: "lane-1", title: "Droid", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "opencode-cli", toolType: "opencode", laneId: "lane-1", title: "OpenCode", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + // Chat-backed + plain shell must NOT be launchable. + { terminalId: "codex-chat", toolType: "codex-chat", laneId: "lane-1", title: "Codex chat", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "cursor-chat", toolType: "cursor", laneId: "lane-1", title: "Cursor chat", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + { terminalId: "raw-shell", toolType: "shell", laneId: "lane-1", title: "Shell", status: "running", runtimeState: "idle", startedAt: "2026-06-15T00:00:00.000Z" }, + ]; + const client = { + request: async (_method: string, params: unknown) => { + const args = (params as { arguments?: { domain?: string; action?: string } }).arguments; + if (args?.domain === "chat" && args.action === "listSessions") return { result: [] }; + if (args?.domain === "terminal" && args.action === "list") return { result: terminals }; + throw new Error("unexpected request"); + }, + }; + + const result = await listRemoteSessions(client as never, "project-1"); + expect(result.map((session) => session.sessionId).sort()).toEqual( + ["claude-cli", "codex-cli", "cursor-cli", "droid-cli", "opencode-cli"].sort(), + ); + }); + it("does not register a new remote project when a path query is ambiguous", async () => { const request = vi.fn(); await expect(selectProject(request as never, [ diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 6184e547a..2b39528b5 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -57,7 +57,7 @@ import type { TerminalSessionSummary, } from "../../../desktop/src/shared/types"; import { discoverAllProjectSlashCommands } from "../../../desktop/src/main/services/chat/projectSlashCommandDiscovery"; -import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; +import type { AdeCodeConnection, AdeCodeProvider, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; export const DEFAULT_CODEX_REASONING_EFFORT = "low"; @@ -154,17 +154,35 @@ const CHAT_BACKED_TERMINAL_TOOL_TYPES = new Set([ "droid-chat", ]); -const RESUMABLE_TERMINAL_TOOL_TYPES = new Set([ +const TRACKED_CLI_PROVIDERS = new Set([ "claude", - "claude-orchestrated", + "codex", + "cursor", + "droid", + "opencode", ]); -function isClaudeTerminalSession(session: ChatTerminalSession): boolean { +/** + * Resolve the CLI provider backing a tracked terminal session (or null when the + * session is not a provider CLI — e.g. a plain shell). Recognizes provider + * metadata, the tool-type prefix, and, as a legacy fallback, a `claude` resume + * command. Mirrors terminalSessionResumeProvider in app.tsx and + * isTerminalSessionLaunchable in remoteLauncher.ts. Callers should exclude + * CHAT_BACKED_TERMINAL_TOOL_TYPES first (a "cursor" chat vs a "cursor-cli" CLI). + */ +export function trackedCliTerminalProvider(session: ChatTerminalSession): AdeCodeProvider | null { + const metaProvider = session.resumeMetadata?.provider; + if (metaProvider && TRACKED_CLI_PROVIDERS.has(metaProvider as AdeCodeProvider)) { + return metaProvider as AdeCodeProvider; + } const toolType = session.toolType ?? ""; - if (RESUMABLE_TERMINAL_TOOL_TYPES.has(toolType)) return true; - if (session.resumeMetadata?.provider === "claude") return true; + if (toolType.startsWith("codex")) return "codex"; + if (toolType.startsWith("cursor")) return "cursor"; + if (toolType.startsWith("droid")) return "droid"; + if (toolType.startsWith("opencode")) return "opencode"; + if (toolType.startsWith("claude")) return "claude"; const resumeCommand = typeof session.resumeCommand === "string" ? session.resumeCommand.trim().toLowerCase() : ""; - return Boolean(resumeCommand && /\bclaude\b/.test(resumeCommand)); + return resumeCommand && /\bclaude\b/.test(resumeCommand) ? "claude" : null; } export async function listTerminalSessions( @@ -178,7 +196,7 @@ export async function listTerminalSessions( return sessions.filter((session) => { const toolType = session.toolType ?? ""; if (CHAT_BACKED_TERMINAL_TOOL_TYPES.has(toolType)) return false; - return isClaudeTerminalSession(session); + return trackedCliTerminalProvider(session) !== null; }); } @@ -216,8 +234,11 @@ export async function signalTerminal( await connection.action("terminal", "signal", { terminalId, signal }); } -export type StartClaudeTerminalSessionResult = { - provider: "claude"; +/** The five provider CLIs the TUI can launch as a tracked terminal session. */ +export type CliTerminalProvider = Extract; + +export type StartCliTerminalSessionResult = { + provider: string; laneId: string; title: string; permissionMode: AgentChatPermissionMode; @@ -261,25 +282,34 @@ export function normalizeChatTerminalSession( return terminalSummaryToChatSession(session); } -export async function startClaudeTerminalSession(args: { +/** + * Start a tracked provider CLI terminal via the shared `start_cli_session` + * action. The runtime owns launch-command construction (including Cursor CLI + * model-variant resolution) and title/goal derivation, so the TUI only forwards + * the picked provider/model/reasoning/permission plus the pane dimensions. + */ +export async function startCliTerminalSession(args: { connection: AdeCodeConnection; + provider: CliTerminalProvider; laneId: string; title?: string | null; model?: string | null; reasoningEffort?: string | null; + fastMode?: boolean; permissionMode?: AgentChatPermissionMode | null; initialInput?: string | null; cols: number; rows: number; -}): Promise { - const result = await args.connection.tool & { +}): Promise { + const result = await args.connection.tool & { session: ChatTerminalSession | TerminalSessionSummary | null; }>("start_cli_session", { laneId: args.laneId, - provider: "claude", + provider: args.provider, title: args.title ?? undefined, model: args.model ?? undefined, reasoningEffort: args.reasoningEffort ?? undefined, + ...(args.fastMode !== undefined ? { fastMode: args.fastMode } : {}), permissionMode: args.permissionMode ?? "default", initialInput: args.initialInput ?? undefined, cols: args.cols, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index bc0ed48ec..dc864af50 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -14,7 +14,7 @@ import { resolveProviderGroupForModel, type ModelProviderGroup, } from "../../../desktop/src/shared/modelRegistry"; -import { resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; +import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { CURSOR_AVAILABLE_MODE_IDS, CURSOR_MODE_LABELS } from "../../../desktop/src/shared/cursorModes"; import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; import type { @@ -30,6 +30,7 @@ import type { AgentChatModelCatalogRefreshProvider, AgentChatModelInfo, AgentChatPermissionMode, + AgentChatProvider, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, @@ -91,7 +92,8 @@ import { sendToTerminalSession, signalTerminal, setClaudeOutputStyle, - startClaudeTerminalSession, + startCliTerminalSession, + type CliTerminalProvider, steerChatMessage, tagChat, unarchiveChatSession, @@ -240,6 +242,7 @@ import { import type { AdeCodeConnection, AdeCodeProvider, + AdeCodeInterfaceMode, AdeCodeModelState, LocalNotice, MentionSuggestion, @@ -710,15 +713,24 @@ export function shouldToggleLatestFailedLineOnBlankEnter(args: { && !isTerminalSessionResumable(args.activeTerminalSession); } +/** Narrow a terminal session's derived provider to an AgentChatProvider (CLI terminals are always one of the five). */ +function terminalSummaryProvider(session: ChatTerminalSession): AgentChatProvider { + const provider = terminalSessionProvider(session); + return provider === "codex" || provider === "claude" || provider === "opencode" || provider === "cursor" || provider === "droid" + ? provider + : "claude"; +} + function terminalSessionToChatSummary(session: ChatTerminalSession): AgentChatSessionSummary { const status: AgentChatSessionSummary["status"] = session.status === "running" ? session.runtimeState === "idle" ? "idle" : "active" : "ended"; + const provider = terminalSummaryProvider(session); return { sessionId: session.terminalId, laneId: session.laneId, - provider: "claude", - model: "claude-code", + provider, + model: provider === "claude" ? "claude-code" : `${provider} cli`, title: session.title, goal: session.goal, permissionMode: session.resumeMetadata?.launch?.permissionMode ?? "default", @@ -862,6 +874,7 @@ function initialModelState(): AdeCodeModelState { const descriptor = getDefaultModelDescriptor("codex"); return { provider: "codex", + interfaceMode: "chat", model: descriptor?.providerModelId ?? "gpt-5.5", modelId: descriptor?.id ?? null, displayName: descriptor?.displayName ?? "GPT-5.5", @@ -903,6 +916,17 @@ function runtimeProviderForUiProvider(provider: AdeCodeProvider): ModelProviderG return provider === "ollama" || provider === "lmstudio" ? "opencode" : provider; } +/** + * The provider CLI a modelState provider launches as, or null when it has no + * tracked CLI (Ollama / LM Studio are OpenCode-backed chat only). Gates the + * Interface=CLI launch path. + */ +export function cliProviderForModelStateProvider(provider: AdeCodeProvider): CliTerminalProvider | null { + return provider === "claude" || provider === "codex" || provider === "cursor" || provider === "droid" || provider === "opencode" + ? provider + : null; +} + function claudeModelCommandKey(state: AdeCodeModelState, terminalId: string | null | undefined): string { return JSON.stringify([ terminalId ?? null, @@ -1681,13 +1705,17 @@ function formatDoctorReport(args: { ].join("\n"); } -function buildSetupRows(args: { +export function buildSetupRows(args: { modelState: AdeCodeModelState; models: AgentChatModelInfo[]; includeRefresh: boolean; includeApply: boolean; outputStyle?: string | null; outputStyleEditable?: boolean; + /** Draft/next-chat interface (Chat = SDK chat, CLI = tracked terminal). */ + interfaceMode: AdeCodeInterfaceMode; + /** False once a session exists — interface is fixed by the session type. */ + interfaceEditable: boolean; }): SetupPaneRow[] { const efforts = modelReasoningEfforts(args.modelState, args.models); const descriptor = args.modelState.modelId ? getModelById(args.modelState.modelId) : undefined; @@ -1702,6 +1730,18 @@ function buildSetupRows(args: { value: providerLabel(args.modelState.provider), cyclable: true, }, + { + kind: "interface", + label: "Interface", + value: args.interfaceMode === "cli" ? "CLI" : "Chat", + detail: args.interfaceEditable + ? "Chat · CLI" + : args.interfaceMode === "cli" + ? "tracked CLI session" + : "ADE chat", + disabled: !args.interfaceEditable, + cyclable: args.interfaceEditable, + }, { kind: "model", label: "Model", @@ -3742,13 +3782,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return { ...prev, [activeSessionId]: events }; }); }, [activeSessionId, events]); - const claudeTerminalControlAvailable = Boolean( + // Ctrl+T raw control works for any running tracked provider CLI (Claude, + // Codex, Cursor, Droid, OpenCode) — activeTerminalProvider is non-null for + // every terminal the TUI surfaces. + const terminalControlAvailable = Boolean( activeTerminalSession && activeTerminalSession.status === "running" - && activeTerminalProvider === "claude", + && activeTerminalProvider, ); - const claudeTerminalControlActive = claudeTerminalControlAvailable + const terminalControlActive = terminalControlAvailable && attachedTerminalId === activeTerminalSession?.terminalId; + // Provider-neutral label for the terminal control chrome (footer "^t