From 4fb61ad43aa7d1bc8190a15bcae023fc3e0d4663 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 9 May 2026 12:28:21 -0400 Subject: [PATCH 1/7] Harden ade code slash commands --- apps/ade-cli/src/bootstrap.ts | 1 + .../src/tuiClient/__tests__/adeApi.test.ts | 39 ++- .../src/tuiClient/__tests__/commands.test.ts | 15 +- apps/ade-cli/src/tuiClient/adeApi.ts | 5 +- apps/ade-cli/src/tuiClient/app.tsx | 222 ++++++++++++++---- apps/ade-cli/src/tuiClient/commands.ts | 28 ++- .../src/tuiClient/components/Header.tsx | 3 +- .../src/tuiClient/components/RightPane.tsx | 1 + .../main/services/adeActions/registry.test.ts | 28 +++ .../src/main/services/adeActions/registry.ts | 25 ++ 10 files changed, 310 insertions(+), 57 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 0a9907ff..9b2dd6f5 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1123,6 +1123,7 @@ export async function createAdeRuntime(args: { swallow(() => linearOAuthService.dispose()); swallow(() => headlessLinearServices.dispose()); swallow(() => aiOrchestratorService.dispose()); + swallow(() => agentChatService?.forceDisposeAll?.()); swallow(() => testService.disposeAll()); swallow(() => ptyService.disposeAll()); swallow(() => db.flushNow()); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index c58672c4..f27744ea 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { latestTokenStats } from "../adeApi"; +import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, latestTokenStats } from "../adeApi"; +import type { AdeCodeConnection } from "../types"; function envelope( sequence: number, @@ -44,3 +45,39 @@ describe("latestTokenStats", () => { }); }); }); + +describe("createChatSession", () => { + it("defaults Codex chats to GPT-5.5 low reasoning", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { + id: "chat-1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + }; + }, + } as unknown as AdeCodeConnection; + + await createChatSession({ connection, laneId: "lane-1" }); + + expect(calls).toEqual([ + expect.objectContaining({ + domain: "chat", + action: "createSession", + args: expect.objectContaining({ + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, + surface: "work", + }), + }), + ]); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index b6c197f9..06333047 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -16,7 +16,7 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("right"); }); - it("routes user-defined commands to chat", () => { + it("routes runtime commands to chat", () => { const parsed = parseCommand("/ship now", [ { name: "/ship", description: "Ship it", source: "sdk" }, ]); @@ -24,15 +24,24 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); }); - it("lets local project commands override ADE built-ins on exact name", () => { + it("lets runtime commands override single-word ADE built-ins on exact name", () => { const parsed = parseCommand("/status please", [ - { name: "/status", description: "Project status prompt", source: "local" }, + { name: "/status", description: "Runtime status", source: "sdk" }, ]); expect(parsed?.spec).toBeNull(); expect(parsed?.userCommand?.name).toBe("/status"); expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); }); + it("keeps multi-word ADE commands ahead of first-token runtime commands", () => { + const parsed = parseCommand("/new lane perf-pass", [ + { name: "/new", description: "Start a new runtime chat", source: "sdk" }, + ]); + expect(parsed?.name).toBe("/new lane"); + expect(parsed?.args).toBe("perf-pass"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + }); + it("tags built-ins and user commands in the palette", () => { const rows = paletteCommands("/ship", [ { name: "/ship", description: "Ship it", source: "sdk" }, diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index ae46c8a8..70a2179a 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -11,6 +11,8 @@ import type { import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; +export const DEFAULT_CODEX_REASONING_EFFORT = "low"; + export async function listLanes(connection: AdeCodeConnection): Promise { return await connection.action("lane", "list", { includeArchived: false, @@ -66,13 +68,14 @@ export async function createChatSession(args: { : getDefaultModelDescriptor(provider); const modelId = args.modelId ?? descriptor?.id ?? null; const model = descriptor?.providerModelId ?? descriptor?.shortId ?? (provider === "claude" ? "sonnet" : "gpt-5.5"); + const reasoningEffort = args.reasoningEffort ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null); return await args.connection.action("chat", "createSession", { laneId: args.laneId, provider, model, ...(modelId ? { modelId } : {}), ...(args.title?.trim() ? { title: args.title.trim() } : {}), - ...(args.reasoningEffort ? { reasoningEffort: args.reasoningEffort } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), surface: "work", }); } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index ba0e88b5..e38fe045 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -12,6 +12,7 @@ import type { } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import { + DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, createChatSession, getAvailableModels, @@ -85,7 +86,7 @@ function initialModelState(): AdeCodeModelState { model: descriptor?.providerModelId ?? "gpt-5.5", modelId: descriptor?.id ?? null, displayName: descriptor?.displayName ?? "GPT-5.5", - reasoningEffort: "medium", + reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, }; } @@ -135,14 +136,56 @@ function splitFirstArg(input: string): { first: string; rest: string } { }; } -function parseAdeActionArgs(input: string): Record { +type ParsedAdeActionPayload = + | { args: Record } + | { argsList: unknown[] } + | { arg: unknown }; + +function parseAdeActionPayload(input: string): ParsedAdeActionPayload { const trimmed = input.trim(); - if (!trimmed) return {}; + if (!trimmed) return { args: {} }; const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error("/ade action arguments must be a JSON object."); + if (Array.isArray(parsed)) { + return { argsList: parsed }; + } + if (parsed && typeof parsed === "object") { + return { args: parsed as Record }; + } + return { arg: parsed }; +} + +function parseLinearIssueListArgs(input: string): Record { + const projectSlugs: string[] = []; + const stateTypes: string[] = []; + let limit: number | undefined; + const tokens = input.match(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^']*)'|(\S+)/g)?.map((token) => ( + token.startsWith("\"") && token.endsWith("\"") + ? token.slice(1, -1).replace(/\\"/g, "\"") + : token.startsWith("'") && token.endsWith("'") + ? token.slice(1, -1) + : token + )) ?? []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] ?? ""; + const next = tokens[index + 1]; + if ((token === "--project" || token === "--project-slug" || token === "--projects") && next) { + projectSlugs.push(...next.split(",").map((entry) => entry.trim()).filter(Boolean)); + index += 1; + } else if ((token === "--state" || token === "--states" || token === "--state-type") && next) { + stateTypes.push(...next.split(",").map((entry) => entry.trim()).filter(Boolean)); + index += 1; + } else if (token === "--limit" && next && Number.isFinite(Number(next))) { + limit = Math.max(1, Math.min(100, Math.floor(Number(next)))); + index += 1; + } else if (!token.startsWith("--")) { + projectSlugs.push(token); + } } - return parsed as Record; + return { + projectSlugs, + stateTypes, + ...(limit ? { limit } : {}), + }; } function printableInput(input: string): string { @@ -224,6 +267,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const eventCountRef = useRef(0); const heartbeatRef = useRef(null); + const selectActiveLaneId = useCallback((laneId: string | null) => { + activeLaneIdRef.current = laneId; + setActiveLaneId(laneId); + }, []); + + const selectActiveSessionId = useCallback((sessionId: string | null) => { + activeSessionIdRef.current = sessionId; + setActiveSessionId(sessionId); + }, []); + const projectName = path.basename(project.projectRoot); const activeLane = useMemo( () => lanes.find((lane) => lane.id === activeLaneId) ?? null, @@ -426,7 +479,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const stats = latestTokenStats(history.events); setContextPercent(stats.percent); setTokenSummary(formatTokenSummary(stats)); - setStreaming(stats.streaming || nextSession?.status === "active"); + setStreaming(nextSession?.status === "active"); const previousCount = eventCountRef.current; eventCountRef.current = history.events.length; if (previousCount > 0 && history.events.length > previousCount && Date.now() - lastLocalSendAtRef.current > 4_000) { @@ -442,8 +495,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ?? null; setLanes(nextLanes); setSessions(nextSessions); - setActiveLaneId(nextLaneId); - setActiveSessionId(nextSessionId); + selectActiveLaneId(nextLaneId); + selectActiveSessionId(nextSessionId); setEvents(nextEvents); setSlashCommands(nextCommands); setModels(nextModels); @@ -455,7 +508,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } displayName: activeModel?.displayName ?? nextSession?.model ?? modelState.displayName, reasoningEffort: nextSession?.reasoningEffort ?? modelState.reasoningEffort, }); - }, [clearedAt, modelState.displayName, modelState.model, modelState.modelId, modelState.reasoningEffort, project]); + }, [clearedAt, modelState.displayName, modelState.model, modelState.modelId, modelState.reasoningEffort, project, selectActiveLaneId, selectActiveSessionId]); useEffect(() => { let cancelled = false; @@ -525,11 +578,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const laneId = activeLaneIdRef.current; if (!conn || !laneId) return null; if (activeSessionIdRef.current) return activeSessionIdRef.current; - const created = await createChatSession({ connection: conn, laneId }); - setActiveSessionId(created.id); + const created = await createChatSession({ + connection: conn, + laneId, + modelId: modelState.modelId, + reasoningEffort: modelState.reasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, + }); + selectActiveSessionId(created.id); await refreshState(); return created.id; - }, [refreshState]); + }, [modelState.modelId, modelState.reasoningEffort, refreshState, selectActiveSessionId]); const resolvePendingApproval = useCallback(async ( approval: PendingApproval, @@ -621,8 +679,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); return; } - const created = await createChatSession({ connection: conn, laneId, title: args }); - setActiveSessionId(created.id); + const created = await createChatSession({ + connection: conn, + laneId, + title: args, + modelId: modelState.modelId, + reasoningEffort: modelState.reasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, + }); + selectActiveSessionId(created.id); addNotice(`Created chat "${args}".`, "success"); await refreshState(); return; @@ -641,7 +705,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } const created = await conn.action("lane", "create", { name: args }); - setActiveLaneId(created.id); + selectActiveLaneId(created.id); + selectActiveSessionId(null); setRightPane({ kind: "details", title: "New lane", body: renderObject(created, 20) }); await refreshState(); return; @@ -672,7 +737,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "Diff", body: "No active lane is selected." }); return; } - const diff = await conn.action("diff", "getChanges", { laneId }); + const diff = await conn.actionList("diff", "getChanges", [laneId]); setRightPane({ kind: "diff", title: "Diff", files: summarizeDiffChanges(diff) }); return; } @@ -686,6 +751,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name.startsWith("/pr")) { + if (!laneId) { + setRightPane({ kind: "details", title: name.slice(1) || "PR", body: "No active lane is selected." }); + return; + } const prs = await conn.action>>("pr", "listAll", laneId ? { laneId } : {}); const activePr = prs[0] ?? null; const prId = activePr ? String(activePr.id ?? activePr.prId ?? "") : ""; @@ -714,10 +783,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "PR open", body: renderObject(activePr, 24) }); return; } - if (!laneId) { - setRightPane({ kind: "details", title: "PR open", body: "No active lane is selected." }); - return; - } if (!args) { openForm({ kind: "form", @@ -753,7 +818,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/linear list") { - const linear = await conn.action("linear_issue_tracker", "listIssues", { limit: 20 }); + const linear = await conn.action("linear_issue_tracker", "listIssues", parseLinearIssueListArgs(args || "--limit 20")); setRightPane({ kind: "list", title: "Linear", rows: routeRows(linear), emptyText: "No Linear issues." }); return; } @@ -860,11 +925,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const lane = lanes.find((entry) => entry.id.toLowerCase() === query || entry.name.toLowerCase().includes(query)); if (lane) { - setActiveLaneId(lane.id); + selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); - setActiveSessionId(session?.sessionId ?? null); + selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); addNotice(`Switched to lane ${lane.name}.`, "success"); } else { @@ -883,7 +948,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/model") { - if (args && sessionId) { + if (args) { + if (!sessionId) { + const model = models.find((entry) => entry.id === args || entry.modelId === args); + setModelState((prev) => ({ + ...prev, + model: model?.id ?? args, + modelId: model?.modelId ?? model?.id ?? args, + displayName: model?.displayName ?? args, + })); + addNotice(`Default model set to ${model?.displayName ?? args}.`, "success"); + return; + } await updateChatModel({ connection: conn, sessionId, modelId: args }); addNotice(`Model set to ${args}.`, "success"); await refreshState(); @@ -896,7 +972,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } if (name === "/effort") { - if (args && sessionId) { + if (args) { + if (!EFFORTS.includes(args)) { + setRightPane({ kind: "details", title: "Effort", body: `Usage: /effort <${EFFORTS.join("|")}>` }); + return; + } + if (!sessionId) { + setModelState((prev) => ({ ...prev, reasoningEffort: args })); + addNotice(`Default effort set to ${args}.`, "success"); + return; + } await updateChatModel({ connection: conn, sessionId, reasoningEffort: args }); addNotice(`Effort set to ${args}.`, "success"); await refreshState(); @@ -916,19 +1001,42 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (name === "/ade") { const parsed = splitFirstArg(args); + const possibleBuiltin = parsed.first.startsWith("/") ? parsed.first : `/${parsed.first}`; + const alias = possibleBuiltin !== "/ade" + ? parseCommand(`${possibleBuiltin}${parsed.rest ? ` ${parsed.rest}` : ""}`, []) + : null; + if (alias?.spec?.placement === "right") { + await runRightCommand(alias.name, alias.args); + return; + } + if (alias?.spec?.placement === "inline") { + setRightPane({ + kind: "details", + title: "ADE command", + body: `/${parsed.first.replace(/^\//, "")} is an inline TUI command. Run it before creating a runtime chat, or use the keyboard shortcut when available.`, + }); + return; + } const [domain, action] = parsed.first.split(".", 2); if (!domain || !action) { setRightPane({ kind: "details", title: "ADE action", - body: "Usage: /ade [json-object-args]", + body: "Usage: /ade [json-object|json-array|json-scalar]", }); return; } - const result = await conn.action(domain, action, parseAdeActionArgs(parsed.rest)); - setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(result, 24) }); + const result = await conn.tool("run_ade_action", { + domain, + action, + ...parseAdeActionPayload(parsed.rest), + }); + const body = result && typeof result === "object" && "result" in result + ? (result as { result?: unknown }).result + : result; + setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); } - }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openForm, project, refreshState, sessions]); + }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions]); const runInlineCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -1058,8 +1166,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!laneId) return; const title = values.title?.trim() || null; const message = values.message?.trim() ?? ""; - const created = await createChatSession({ connection: conn, laneId, title }); - setActiveSessionId(created.id); + const created = await createChatSession({ + connection: conn, + laneId, + title, + modelId: modelState.modelId, + reasoningEffort: modelState.reasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, + }); + selectActiveSessionId(created.id); if (message) { await sendChatMessage(conn, created.id, message); } @@ -1078,8 +1192,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } name, ...(baseBranch ? { baseBranch } : {}), }); - setActiveLaneId(created.id); - setActiveSessionId(null); + selectActiveLaneId(created.id); + selectActiveSessionId(null); setRightOpen(false); setRightPane({ kind: "empty" }); addNotice(`Created lane ${created.name}.`, "success"); @@ -1114,7 +1228,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice("Created draft PR.", "success"); await refreshState(); } - }, [addNotice, refreshState]); + }, [addNotice, modelState.modelId, modelState.reasoningEffort, refreshState, selectActiveLaneId, selectActiveSessionId]); const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); @@ -1275,21 +1389,21 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (rightPane.action.kind === "switch-lane") { const lane = lanes.find((entry) => entry.id === selectedId); if (!lane) return; - setActiveLaneId(lane.id); + selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); - setActiveSessionId(session?.sessionId ?? null); + selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); addNotice(`Switched to lane ${lane.name}.`, "success"); return; } const session = sessions.find((entry) => entry.sessionId === selectedId); if (!session) return; - setActiveLaneId(session.laneId); + selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); setSelectedDrawerLaneId(session.laneId); - setActiveSessionId(session.sessionId); + selectActiveSessionId(session.sessionId); setSelectedDrawerChatId(session.sessionId); addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); return; @@ -1297,14 +1411,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; - if (!conn || !sessionId) { - addNotice("Create or select a chat before changing model settings.", "error"); + if (!conn) { return; } if (rightPane.kind === "models") { const model = rightPane.models[rightSelectionIndex] ?? rightPane.models[0]; if (!model) return; const modelId = model.modelId ?? model.id; + if (!sessionId) { + setModelState((prev) => ({ + ...prev, + model: model.id, + modelId, + displayName: model.displayName, + })); + addNotice(`Default model set to ${model.displayName}.`, "success"); + return; + } void updateChatModel({ connection: conn, sessionId, modelId }) .then(() => { addNotice(`Model set to ${model.displayName}.`, "success"); @@ -1315,6 +1438,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const effort = rightPane.efforts[rightSelectionIndex] ?? rightPane.efforts[0]; if (!effort) return; + if (!sessionId) { + setModelState((prev) => ({ ...prev, reasoningEffort: effort })); + addNotice(`Default effort set to ${effort}.`, "success"); + return; + } void updateChatModel({ connection: conn, sessionId, reasoningEffort: effort }) .then(() => { addNotice(`Effort set to ${effort}.`, "success"); @@ -1375,18 +1503,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (drawerSection === "lanes") { const lane = drawerLaneRows[selectedLaneIndex]; if (lane) { + selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); - setDrawerSection("chats"); - setSelectedDrawerChatId(sessions.find((session) => session.laneId === lane.id)?.sessionId ?? null); + const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + selectActiveSessionId(session?.sessionId ?? null); + setSelectedDrawerChatId(session?.sessionId ?? null); + setDrawerSection(session ? "chats" : "lanes"); + addNotice(`Switched to lane ${lane.name}.`, "success"); } } else { const session = drawerLaneSessions[selectedChatIndex]; if (session) { - setActiveLaneId(session.laneId); + selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); setSelectedDrawerLaneId(session.laneId); - setActiveSessionId(session.sessionId); + selectActiveSessionId(session.sessionId); setSelectedDrawerChatId(session.sessionId); } } diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index f5dda49b..37281592 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -47,7 +47,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/model", description: "Pick the active chat model", placement: "right" }, { name: "/effort", description: "Pick reasoning effort", placement: "right" }, { name: "/system", description: "Show system and runtime details", placement: "right" }, - { name: "/ade", description: "Run an allowlisted ADE action", placement: "right", argumentHint: " [json]" }, + { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: " [json]" }, ]; export type ParsedCommand = { @@ -65,17 +65,33 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ const trimmed = input.trim(); if (!trimmed.startsWith("/")) return null; const [first = ""] = trimmed.split(/\s+/, 1); - const exactLocalCommand = userCommands.find((command) => command.source === "local" && command.name === first) ?? null; - if (exactLocalCommand) { + const candidates = [...BUILTIN_COMMANDS] + .sort((left, right) => right.name.length - left.name.length); + + // Preserve ADE's multi-word commands (`/new lane`, `/pr open`, `/linear pull`) + // even when a runtime exposes a first-token command like `/new`. + for (const spec of candidates.filter((candidate) => candidate.name.includes(" "))) { + const name = normalizeSlashName(spec.name); + if (trimmed === name || trimmed.startsWith(`${name} `)) { + return { + name, + args: trimmed.slice(name.length).trim(), + spec, + userCommand: null, + }; + } + } + + const exactUserCommand = userCommands.find((command) => command.name === first) ?? null; + if (exactUserCommand) { return { name: first, args: trimmed.slice(first.length).trim(), spec: null, - userCommand: exactLocalCommand, + userCommand: exactUserCommand, }; } - const candidates = [...BUILTIN_COMMANDS] - .sort((left, right) => right.name.length - left.name.length); + for (const spec of candidates) { const name = normalizeSlashName(spec.name); if (trimmed === name || trimmed.startsWith(`${name} `)) { diff --git a/apps/ade-cli/src/tuiClient/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx index 1a13eae6..b2f8407a 100644 --- a/apps/ade-cli/src/tuiClient/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -23,6 +23,7 @@ export function Header({ let modeColor: string = "gray"; if (mode === "attached") modeColor = "green"; else if (mode === "embedded") modeColor = "yellow"; + const modelLabel = model.reasoningEffort ? `${model.displayName} ${model.reasoningEffort}` : model.displayName; return ( ▌ ADE @@ -31,7 +32,7 @@ export function Header({ {formatLaneLabel(lane)} - {model.displayName} + {modelLabel} {mode} {` · ⏵ ${tuiCount} tui${tuiCount === 1 ? "" : "s"}`} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 002b5347..8b3918d0 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -11,6 +11,7 @@ function HelpPane() { esc closes the active side pane ctrl-c interrupts a running chat; press again to quit / opens commands, @ opens references, tab inserts selected + /ade status forces ADE's TUI command when a runtime owns /status ); } diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 137a1694..5ee0ed15 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -212,22 +212,50 @@ describe("runtime Linear issue tracker actions", () => { const users = [{ id: "user-1", name: "Arul" }]; const labels = [{ id: "label-1", name: "Bug" }]; const states = [{ id: "state-1", name: "Todo" }]; + const issues = [ + { id: "LIN-1", title: "First" }, + { id: "LIN-2", title: "Second" }, + { id: "LIN-3", title: "Third" }, + ]; + const fetchCandidateIssues = vi.fn(async () => issues); const tracker = { + getConnectionStatus: vi.fn(async () => ({ + connected: true, + viewerId: "user-1", + viewerName: "Arul", + message: null, + })), + fetchCandidateIssues, listProjects: vi.fn(async () => projects), listUsers: vi.fn(async () => users), listLabels: vi.fn(async () => labels), listWorkflowStates: vi.fn(async () => states), }; const runtime = { + linearCredentialService: { + getStatus: vi.fn(() => ({ + tokenStored: true, + authMode: "oauth", + oauthConfigured: true, + tokenExpiresAt: null, + })), + }, linearIssueTracker: tracker, } as unknown as Parameters[0]; const service = getAdeActionDomainServices(runtime).linear_issue_tracker as { + getStatus: () => Promise; getWorkflowCatalog: () => Promise; getIssuePickerData: () => Promise; + listIssues: (args?: Record) => Promise; } & Record; + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getStatus"); + expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("listIssues"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getWorkflowCatalog"); expect(listAllowedAdeActionNames("linear_issue_tracker", service)).toContain("getIssuePickerData"); + await expect(service.getStatus()).resolves.toMatchObject({ connected: true, tokenStored: true }); + await expect(service.listIssues({ project: "desktop,cli", state: ["open"], limit: 2 })).resolves.toEqual(issues.slice(0, 2)); + expect(fetchCandidateIssues).toHaveBeenCalledWith({ projectSlugs: ["desktop", "cli"], stateTypes: ["open"] }); await expect(service.getWorkflowCatalog()).resolves.toEqual({ users, labels, states }); await expect(service.getIssuePickerData()).resolves.toEqual({ projects, users, states }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 14c103da..a4844626 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -3211,9 +3211,23 @@ function buildLinearIssueTrackerDomainService(runtime: AdeRuntime): OpaqueServic if (!tracker) return null; return { ...(tracker as unknown as OpaqueService), + async getStatus() { + return buildRuntimeLinearConnectionStatus(runtime); + }, async getConnectionStatus() { return buildRuntimeLinearConnectionStatus(runtime); }, + async listIssues(args?: unknown) { + const actionArgs = asActionRecord(args); + const issues = await tracker.fetchCandidateIssues({ + projectSlugs: asStringArray(actionArgs.projectSlugs ?? actionArgs.projectSlug ?? actionArgs.projects ?? actionArgs.project), + stateTypes: asStringArray(actionArgs.stateTypes ?? actionArgs.stateType ?? actionArgs.states ?? actionArgs.state), + }); + const limit = typeof actionArgs.limit === "number" && Number.isFinite(actionArgs.limit) + ? Math.max(1, Math.min(100, Math.floor(actionArgs.limit))) + : 20; + return issues.slice(0, limit); + }, async getQuickView(connection?: LinearConnectionStatus): Promise { const nextConnection = connection ?? await buildRuntimeLinearConnectionStatus(runtime); if (!nextConnection.connected) return createEmptyLinearQuickView(nextConnection); @@ -3249,6 +3263,17 @@ function buildLinearIssueTrackerDomainService(runtime: AdeRuntime): OpaqueServic }; } +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); + } + if (typeof value === "string" && value.trim().length) { + return value.split(",").map((entry) => entry.trim()).filter(Boolean); + } + return []; +} + async function buildRuntimeLinearConnectionStatus(runtime: AdeRuntime): Promise { const credentialStatus = runtime.linearCredentialService?.getStatus() ?? { tokenStored: false, From 0852778705f7cb5424140a3d2c6f61c11f66b0f9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 9 May 2026 23:37:03 -0500 Subject: [PATCH 2/7] ade-code: TUI aesthetic pass + inline context meter, plus CLI/desktop branch work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ade-code chrome: - Header: inverse-purple `ADE` brand pill + lane chip in lane color, drops the duplicated `ADE · ADE` segment - Boot hero: bordered rounded card with a 3-row purple `ADE` block-letter wordmark and a dim displaced shadow row (mirrors the real ADE logo's glitch-slice), version, lane + branch, dim dividers, key-hint row, and suggestion list - Inline context meter on the right of the model-status row above the prompt (`XX% ████░░░░░░ · in 12.4K`); accent <80%, warning ≥80%, danger ≥95% - Standalone top-of-screen context line removed Plumbing: - `tokens` event variant gains optional `contextWindow`; cursor SDK mapper threads it through - `latestTokenStats` accepts a `fallbackContextWindow` and computes percent from final input + output ÷ limit; `app.tsx` falls back to `getModelById(modelId).contextWindow` so the meter fills in even without emitter changes Also bundles in-progress `ade-windows-and-cli-2` branch work: ade-cli RPC + launcher tweaks, slash command discovery hardening (Claude/Codex), agent action registry, AI settings status, providers UI, and updated tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ade-cli/src/adeRpcServer.test.ts | 48 + apps/ade-cli/src/cli.ts | 5 +- .../src/tuiClient/__tests__/ChatView.test.tsx | 96 ++ .../tuiClient/__tests__/RightPane.test.tsx | 91 + .../src/tuiClient/__tests__/adeApi.test.ts | 105 +- .../src/tuiClient/__tests__/commands.test.ts | 54 + .../src/tuiClient/__tests__/format.test.ts | 146 +- apps/ade-cli/src/tuiClient/adeApi.ts | 130 +- apps/ade-cli/src/tuiClient/app.tsx | 1483 ++++++++++++++--- apps/ade-cli/src/tuiClient/cli.tsx | 6 +- apps/ade-cli/src/tuiClient/commands.ts | 47 +- .../src/tuiClient/components/AdeWordmark.tsx | 26 + .../src/tuiClient/components/ChatView.tsx | 233 ++- .../src/tuiClient/components/Drawer.tsx | 35 +- .../tuiClient/components/FooterControls.tsx | 77 + .../src/tuiClient/components/Header.tsx | 79 +- .../src/tuiClient/components/ModelStatus.tsx | 76 + .../src/tuiClient/components/RightPane.tsx | 146 +- .../src/tuiClient/components/SlashPalette.tsx | 35 +- apps/ade-cli/src/tuiClient/format.ts | 123 +- apps/ade-cli/src/tuiClient/theme.ts | 78 + apps/ade-cli/src/tuiClient/types.ts | 71 +- .../main/services/adeActions/registry.test.ts | 2 +- .../src/main/services/adeActions/registry.ts | 5 + .../src/main/services/ai/aiSettingsStatus.ts | 138 ++ .../main/services/ai/claudeRuntimeProbe.ts | 2 +- .../services/ai/providerConnectionStatus.ts | 2 +- .../services/chat/agentChatService.test.ts | 44 +- .../main/services/chat/agentChatService.ts | 74 +- .../chat/claudeSlashCommandDiscovery.test.ts | 124 +- .../chat/claudeSlashCommandDiscovery.ts | 82 +- .../chat/codexSlashCommandDiscovery.ts | 22 +- .../services/chat/cursorSdkEventMapper.ts | 2 + .../components/settings/ProvidersSection.tsx | 4 +- apps/desktop/src/shared/types/chat.ts | 1 + docs/features/ade-code/README.md | 11 +- 36 files changed, 3269 insertions(+), 434 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx create mode 100644 apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/FooterControls.tsx create mode 100644 apps/ade-cli/src/tuiClient/components/ModelStatus.tsx create mode 100644 apps/ade-cli/src/tuiClient/theme.ts create mode 100644 apps/desktop/src/main/services/ai/aiSettingsStatus.ts diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 669561d4..3bbd9d1a 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -225,6 +225,41 @@ function createRuntime() { list: vi.fn(() => [{ id: "op-1", kind: "git_push", status: "running" }]), }, projectConfigService: {} as any, + aiIntegrationService: { + getStatus: vi.fn(async () => ({ + mode: "subscription", + availableProviders: { + claude: true, + codex: true, + cursor: false, + droid: false, + }, + models: { + claude: [], + codex: [], + cursor: [], + droid: [], + }, + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + ], + providerConnections: {}, + runtimeConnections: {}, + availableModelIds: ["openai/gpt-5.5"], + opencodeBinaryInstalled: true, + opencodeBinarySource: "bundled", + opencodeInventoryError: null, + opencodeProviders: [], + apiKeyStore: { + secureStorageAvailable: true, + legacyPlaintextDetected: false, + decryptionFailed: false, + }, + })), + getDailyUsageBatch: vi.fn(() => new Map()), + getFeatureFlag: vi.fn(() => true), + getDailyBudgetLimit: vi.fn(() => null), + } as any, conflictService: { runPrediction: vi.fn(async () => ({ lanes: [], matrix: [], overlaps: [] })), getLaneStatus: vi.fn(async ({ laneId }: { laneId: string }) => ({ laneId, status: "merge-ready" })), @@ -4113,6 +4148,7 @@ describe("adeRpcServer", () => { const allDomains = await callTool(handler, "list_ade_actions", { domain: "all" }); expect(allDomains?.isError).toBeUndefined(); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "memory")).toBe(true); + expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "ai")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "mission")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator")).toBe(true); expect(allDomains.structuredContent.actions.some((entry: { domain: string }) => entry.domain === "orchestrator_core")).toBe(true); @@ -4187,6 +4223,18 @@ describe("adeRpcServer", () => { expect(keybindings?.isError).toBeUndefined(); expect(fixture.runtime.keybindingsService.get).toHaveBeenCalled(); + const aiStatus = await callTool(handler, "run_ade_action", { + domain: "ai", + action: "getStatus", + args: { refreshOpenCodeInventory: true }, + }); + expect(aiStatus?.isError).toBeUndefined(); + expect(fixture.runtime.aiIntegrationService.getStatus).toHaveBeenCalledWith({ + force: false, + refreshOpenCodeInventory: true, + }); + expect(aiStatus.structuredContent.result.availableModelIds).toContain("openai/gpt-5.5"); + const layoutSet = await callTool(handler, "run_ade_action", { domain: "layout", action: "set", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index fa436feb..f9056a82 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -883,8 +883,9 @@ const HELP_BY_COMMAND: Record = { code: `${ADE_BANNER} ADE Code - Launch the terminal-native ADE Work chat. It shares lanes, chat sessions, - transcript state, and slash commands with desktop ADE. + Launch the terminal-native ADE Work chat. It uses the same project lanes, + chat sessions, transcript state, and slash commands as desktop ADE, but it + does not require the desktop app to be running. $ ade code Start the TUI for the current project $ ade code --print-state Smoke-test attach/embed state diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx new file mode 100644 index 00000000..27cb5848 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { ChatView } from "../components/ChatView"; +import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; + +const session: AgentChatSessionSummary = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, +}; + +function renderEvents(events: AgentChatEventEnvelope[]): string { + const result = render( + , + ); + return result.lastFrame() ?? ""; +} + +describe("ChatView", () => { + it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { + const frame = renderEvents([]); + expect(frame).toMatch(/[╭╮╯╰]/); + expect(frame).toContain("█▀█"); + expect(frame).toContain("ade code"); + expect(frame).toContain("v0.1"); + expect(frame).toContain("Primary"); + expect(frame).toContain("type to chat"); + expect(frame).toContain("›"); + expect(frame).toContain("inspect the current diff"); + }); + + it("right-aligns user messages inside an accent-bordered bubble", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "hello" }, + }, + ]); + const lines = frame.split(/\r?\n/); + const bubbleLine = lines.find((line) => line.includes("hello")); + expect(bubbleLine).toBeTruthy(); + // Round border characters wrap the bubble; verify presence so layout stays a bubble. + expect(frame).toMatch(/[╭╮╯╰]/); + // Bubble is right-aligned: the content sits past the half-width of the frame. + if (bubbleLine) { + const helloIndex = bubbleLine.indexOf("hello"); + expect(helloIndex).toBeGreaterThan(0); + } + }); + + it("renders assistant messages flat without the bubble border", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "I'm Codex." }, + }, + ]); + expect(frame).toContain("I'm Codex."); + // No round-border glyphs in an assistant-only frame. + expect(frame).not.toMatch(/[╭╮╯╰]/); + }); + + it("indents tool call output", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "command", command: "git branch", cwd: "/repo", output: "main", itemId: "cmd-1", status: "completed", exitCode: 0, durationMs: 12 }, + }, + ]); + const lines = frame.split(/\r?\n/).filter((line) => line.includes("run git branch")); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(line.startsWith(" ")).toBe(true); + } + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx new file mode 100644 index 00000000..8b0242ef --- /dev/null +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { describe, expect, it } from "vitest"; +import { render } from "ink-testing-library"; +import { RightPane } from "../components/RightPane"; +import type { ProviderReadinessRow, RightPaneContent, SetupPaneRow } from "../types"; + +const setupRows: SetupPaneRow[] = [ + { kind: "provider", label: "Provider", value: "Codex", cyclable: true }, + { kind: "model", label: "Model", value: "GPT-5.5", cyclable: true, detail: "5 available" }, + { kind: "reasoning", label: "Reasoning", value: "medium", cyclable: true, detail: "low, medium, high" }, + { kind: "permission", label: "Permissions", value: "default", cyclable: true }, + { kind: "codex-fast", label: "Fast mode", value: "off", cyclable: true, detail: "Codex service tier" }, + { kind: "refresh-status", label: "Refresh status", value: "run", detail: "checks provider auth/runtime state" }, + { kind: "open-settings", label: "Full settings", value: "open desktop", detail: "Settings > AI Providers" }, +]; + +const providerRows: ProviderReadinessRow[] = [ + { provider: "codex", label: "Codex", status: "ready", detail: "ready at /usr/local/bin/codex", modelCount: 6 }, + { provider: "claude", label: "Claude", status: "ready", detail: "ready at /usr/local/bin/claude", modelCount: 4 }, + { provider: "cursor", label: "Cursor", status: "unknown", detail: "API key store not yet readable", modelCount: 0 }, + { provider: "droid", label: "Droid", status: "unavailable", detail: "no Factory Droid CLI or FACTORY_API_KEY", modelCount: 0 }, + { provider: "opencode", label: "OpenCode", status: "ready", detail: "user-installed · 0 shared runtime", modelCount: 4442 }, +]; + +function content(overrides: Partial> = {}): RightPaneContent { + return { + kind: "model-setup", + rows: setupRows, + providerRows, + activeProvider: "codex", + checkedAt: "2026-05-09T19:57:09.000Z", + desktopAttached: true, + ...overrides, + }; +} + +function renderModelSetup(selectedIndex: number, overrides: Partial> = {}): string { + const result = render( + , + ); + return result.lastFrame() ?? ""; +} + +describe("RightPane model-setup", () => { + it("renders MODEL and PROVIDERS section headers", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("MODEL"); + expect(frame).toContain("PROVIDERS"); + }); + + it("shows ‹ › chevron on cyclable setup rows and ↵ on action rows", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("‹ ›"); + expect(frame).toContain("↵"); + }); + + it("renders all five providers with their brand glyphs", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("◇ Codex"); + expect(frame).toContain("◆ Claude"); + expect(frame).toContain("▲ Cursor"); + expect(frame).toContain("▣ Droid"); + expect(frame).toContain("◈ OpenCode"); + }); + + it("collapses provider detail when no provider row is selected", () => { + const frame = renderModelSetup(0); + expect(frame).not.toContain("4 models"); + expect(frame).not.toContain("/usr/local/bin/claude"); + }); + + it("expands provider detail when its row is selected", () => { + const claudeIndex = setupRows.length + 1; + const frame = renderModelSetup(claudeIndex); + expect(frame).toContain("4 models"); + expect(frame).toContain("/usr/local/bin/claude"); + }); + + it("renders the footer with checked time and key hints", () => { + const frame = renderModelSetup(0); + expect(frame).toContain("19:57:09"); + expect(frame).toContain("↑↓"); + expect(frame).toContain("←→"); + expect(frame).toContain("enter"); + }); + + it("marks the active provider in the providers list", () => { + const frame = renderModelSetup(0); + expect(frame).toMatch(/◇ Codex.*active/); + }); +}); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index f27744ea..0e708127 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, latestTokenStats } from "../adeApi"; +import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, latestTokenStats, sendChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; function envelope( @@ -24,7 +24,6 @@ describe("latestTokenStats", () => { turnId: "turn-1", inputTokens: 2_000, outputTokens: 500, - totalTokens: 2_500, contextWindow: 10_000, } as AgentChatEventEnvelope["event"]), envelope(3, { @@ -37,13 +36,46 @@ describe("latestTokenStats", () => { ]; expect(latestTokenStats(events)).toEqual({ - percent: 25, + percent: 28, streaming: false, inputTokens: 2_100, outputTokens: 700, costUsd: 0.42, }); }); + + it("falls back to the active model contextWindow when the event omits one", () => { + const events = [ + envelope(1, { type: "status", turnStatus: "started" }), + envelope(2, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 40_000, outputTokens: 10_000 }, + costUsd: 0.12, + }), + ]; + + expect(latestTokenStats(events, 200_000)).toEqual({ + percent: 25, + streaming: false, + inputTokens: 40_000, + outputTokens: 10_000, + costUsd: 0.12, + }); + }); + + it("returns null percent when no contextWindow is available", () => { + const events = [ + envelope(1, { + type: "done", + turnId: "turn-1", + status: "completed", + usage: { inputTokens: 100, outputTokens: 50 }, + }), + ]; + expect(latestTokenStats(events).percent).toBeNull(); + }); }); describe("createChatSession", () => { @@ -80,4 +112,71 @@ describe("createChatSession", () => { }), ]); }); + + it("passes native model controls when creating chats", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { + id: "chat-1", + laneId: "lane-1", + provider: args?.provider, + model: args?.model, + status: "idle", + createdAt: "2026-01-01T00:00:00.000Z", + lastActivityAt: "2026-01-01T00:00:00.000Z", + }; + }, + } as unknown as AdeCodeConnection; + + await createChatSession({ + connection, + laneId: "lane-1", + provider: "codex", + modelId: "openai/gpt-5.5", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + expect(calls[0]?.args).toEqual(expect.objectContaining({ + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + reasoningEffort: "high", + codexFastMode: true, + permissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + })); + }); +}); + +describe("sendChatMessage", () => { + it("waits until the runtime has accepted the turn", async () => { + const calls: Array<{ domain: string; action: string; argsList: unknown[] }> = []; + const connection = { + actionList: async (domain: string, action: string, argsList: unknown[]) => { + calls.push({ domain, action, argsList }); + }, + } as unknown as AdeCodeConnection; + + await sendChatMessage(connection, "chat-1", "hello"); + + expect(calls).toEqual([ + { + domain: "chat", + action: "sendMessage", + argsList: [ + { sessionId: "chat-1", text: "hello" }, + { awaitDispatch: true }, + ], + }, + ]); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 06333047..653f55d6 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -33,6 +33,24 @@ describe("commands", () => { expect(parsed ? commandPlacement(parsed) : null).toBe("chat"); }); + it("keeps provider login as an ADE-code terminal command", () => { + const parsed = parseCommand("/login", [ + { name: "/login", description: "Claude SDK login", source: "sdk" }, + ]); + expect(parsed?.spec?.name).toBe("/login"); + expect(parsed?.userCommand).toBeNull(); + expect(parsed ? commandPlacement(parsed) : null).toBe("inline"); + }); + + it("keeps terminal control commands in ADE Code", () => { + const parsed = parseCommand("/quit", [ + { name: "/quit", description: "Runtime quit", source: "sdk" }, + ]); + expect(parsed?.spec?.name).toBe("/quit"); + expect(parsed?.userCommand).toBeNull(); + expect(parsed ? commandPlacement(parsed) : null).toBe("inline"); + }); + it("keeps multi-word ADE commands ahead of first-token runtime commands", () => { const parsed = parseCommand("/new lane perf-pass", [ { name: "/new", description: "Start a new runtime chat", source: "sdk" }, @@ -48,4 +66,40 @@ describe("commands", () => { ]); expect(rows).toContainEqual(expect.objectContaining({ name: "/ship", source: "user" })); }); + + it("surfaces SDK commands like /compact when filtering", () => { + const rows = paletteCommands("/comp", [ + { name: "/compact", description: "Free up context by summarizing", source: "sdk" }, + ]); + expect(rows.find((row) => row.name === "/compact")).toBeTruthy(); + }); + + it("prefers SDK/user entry when same command exists in ADE builtins (dedupe)", () => { + // /clear is in ADE BUILTIN_COMMANDS; the SDK also exposes /clear. + const rows = paletteCommands("/clear", [ + { name: "/clear", description: "Start a new conversation with empty context", source: "sdk" }, + ]); + const clearRows = rows.filter((row) => row.name === "/clear"); + expect(clearRows).toHaveLength(1); + expect(clearRows[0]?.source).toBe("user"); + expect(clearRows[0]?.description).toBe("Start a new conversation with empty context"); + }); + + it("returns more than 9 results for empty/short queries", () => { + const userCommands = Array.from({ length: 20 }, (_, i) => ({ + name: `/sdk-cmd-${i}`, + description: `SDK command ${i}`, + source: "sdk" as const, + })); + const rows = paletteCommands("/", userCommands); + expect(rows.length).toBeGreaterThan(20); + }); + + it("ranks prefix matches above substring matches", () => { + const rows = paletteCommands("/compact", [ + { name: "/compact", description: "Free up context", source: "sdk" }, + { name: "/something-compact-related", description: "Other", source: "sdk" }, + ]); + expect(rows[0]?.name).toBe("/compact"); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index a751521b..972ffa32 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -23,7 +23,65 @@ describe("renderChatLines", () => { }); expect(lines.map((line) => line.tone)).toEqual(["user", "assistant"]); expect(lines[0]?.header).toContain("you"); - expect(lines[1]?.header).toContain("ade"); + expect(lines[1]?.header).toContain("ADE"); + }); + + it("orders local notices and chat events by timestamp", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [ + { + id: "notice-1", + timestamp: "2026-01-01T12:00:02.000Z", + tone: "success", + text: "Auth completed.", + }, + ], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "user_message", text: "hello" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 2, + event: { type: "text", text: "hi" }, + }, + ], + }); + + expect(lines.map((line) => line.body)).toEqual(["hello", "Auth completed.", "hi"]); + }); + + it("keeps terminal formatting artifacts out of model labels", () => { + const lines = renderChatLines({ + activeSession: { + sessionId: "s1", + laneId: "lane-1", + provider: "claude", + model: "claude-opus-4-7[1m]", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + }, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "hi" }, + }, + ], + }); + + expect(lines[0]?.header).toMatch(/^Claude · .* · claude-opus-4-7$/); }); it("renders non-JSON-safe objects without throwing", () => { @@ -102,6 +160,92 @@ describe("renderChatLines", () => { expect(latestExpandableFailureId([...events])).toBe("1:command:2026-01-01T12:00:00.000Z"); }); + it("coalesces consecutive streamed text events from the same provider into one line", () => { + const session = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + } as const; + const lines = renderChatLines({ + activeSession: session, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "I'm Codex," }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 2, + event: { type: "text", text: " running as a GPT-5 based" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 3, + event: { type: "text", text: " software engineering agent." }, + }, + ], + }); + expect(lines).toHaveLength(1); + expect(lines[0]?.tone).toBe("assistant"); + expect(lines[0]?.body).toBe("I'm Codex, running as a GPT-5 based software engineering agent."); + expect(lines[0]?.header).toMatch(/^Codex /); + }); + + it("does not coalesce assistant text across a tool call", () => { + const session = { + sessionId: "s1", + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + status: "idle", + startedAt: "2026-01-01T12:00:00.000Z", + endedAt: null, + lastActivityAt: "2026-01-01T12:00:00.000Z", + lastOutputPreview: null, + summary: null, + } as const; + const lines = renderChatLines({ + activeSession: session, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "I'll check the branch." }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 2, + event: { type: "tool_call", tool: "shell", args: { command: "git branch" }, itemId: "tool-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 3, + event: { type: "text", text: "We're on main." }, + }, + ], + }); + expect(lines.map((line) => line.tone)).toEqual(["assistant", "tool", "assistant"]); + expect(lines[0]?.body).toBe("I'll check the branch."); + expect(lines[2]?.body).toBe("We're on main."); + expect(lines[2]?.header).toMatch(/^Codex /); + }); + it("renders expanded failed tool output when requested", () => { const events = [{ sessionId: "s1", diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 70a2179a..1732c5e9 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -1,13 +1,29 @@ -import { getDefaultModelDescriptor, type ModelProviderGroup } from "../../../desktop/src/shared/modelRegistry"; +import { + getDefaultModelDescriptor, + getModelById, + getRuntimeModelRefForDescriptor, + resolveProviderGroupForModel, + type ModelProviderGroup, +} from "../../../desktop/src/shared/modelRegistry"; import type { + AgentChatClaudePermissionMode, + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, + AgentChatCursorConfigValue, + AgentChatDroidPermissionMode, AgentChatEventEnvelope, AgentChatFileRef, + AgentChatInteractionMode, AgentChatModelInfo, + AgentChatOpenCodePermissionMode, + AgentChatPermissionMode, AgentChatProvider, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, } from "../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; @@ -54,6 +70,21 @@ export async function getAvailableModels( }); } +export async function getAiSettingsStatus( + connection: AdeCodeConnection, + args: { force?: boolean; refreshOpenCodeInventory?: boolean } = {}, +): Promise { + return await connection.action("ai", "getStatus", args); +} + +export async function getStoredApiKeyProviders(connection: AdeCodeConnection): Promise { + return await connection.action("ai", "listApiKeys", {}); +} + +export async function getOpenCodeRuntimeDiagnostics(connection: AdeCodeConnection): Promise { + return await connection.action("ai", "getOpenCodeRuntimeDiagnostics", {}); +} + export async function createChatSession(args: { connection: AdeCodeConnection; laneId: string; @@ -61,13 +92,32 @@ export async function createChatSession(args: { provider?: ModelProviderGroup; modelId?: string | null; reasoningEffort?: string | null; + codexFastMode?: boolean; + permissionMode?: AgentChatPermissionMode; + interactionMode?: AgentChatInteractionMode; + claudePermissionMode?: AgentChatClaudePermissionMode; + codexApprovalPolicy?: AgentChatCodexApprovalPolicy; + codexSandbox?: AgentChatCodexSandbox; + codexConfigSource?: AgentChatCodexConfigSource; + opencodePermissionMode?: AgentChatOpenCodePermissionMode; + droidPermissionMode?: AgentChatDroidPermissionMode; + cursorModeId?: string | null; + cursorConfigValues?: Record; }): Promise { - const provider = args.provider ?? "codex"; - const descriptor = args.modelId - ? null - : getDefaultModelDescriptor(provider); + const requestedDescriptor = args.modelId ? getModelById(args.modelId) : undefined; + const provider = args.provider + ?? (requestedDescriptor ? resolveProviderGroupForModel(requestedDescriptor) : "codex"); + const descriptor = requestedDescriptor ?? getDefaultModelDescriptor(provider); const modelId = args.modelId ?? descriptor?.id ?? null; - const model = descriptor?.providerModelId ?? descriptor?.shortId ?? (provider === "claude" ? "sonnet" : "gpt-5.5"); + const model = descriptor + ? getRuntimeModelRefForDescriptor(descriptor, provider) + : provider === "claude" + ? "sonnet" + : provider === "cursor" + ? "auto" + : provider === "droid" + ? "claude-sonnet-4-5-20250929" + : "gpt-5.5"; const reasoningEffort = args.reasoningEffort ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null); return await args.connection.action("chat", "createSession", { laneId: args.laneId, @@ -76,6 +126,17 @@ export async function createChatSession(args: { ...(modelId ? { modelId } : {}), ...(args.title?.trim() ? { title: args.title.trim() } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), + ...(provider === "codex" && args.codexFastMode === true ? { codexFastMode: true } : {}), + ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), + ...(provider === "claude" && args.interactionMode ? { interactionMode: args.interactionMode } : {}), + ...(provider === "claude" && args.claudePermissionMode ? { claudePermissionMode: args.claudePermissionMode } : {}), + ...(provider === "codex" && args.codexApprovalPolicy ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), + ...(provider === "codex" && args.codexSandbox ? { codexSandbox: args.codexSandbox } : {}), + ...(provider === "codex" && args.codexConfigSource ? { codexConfigSource: args.codexConfigSource } : {}), + ...(provider === "opencode" && args.opencodePermissionMode ? { opencodePermissionMode: args.opencodePermissionMode } : {}), + ...(provider === "droid" && args.droidPermissionMode ? { droidPermissionMode: args.droidPermissionMode } : {}), + ...(provider === "cursor" && args.cursorModeId !== undefined ? { cursorModeId: args.cursorModeId } : {}), + ...(provider === "cursor" && args.cursorConfigValues ? { cursorConfigValues: args.cursorConfigValues } : {}), surface: "work", }); } @@ -86,11 +147,14 @@ export async function sendChatMessage( text: string, attachments: AgentChatFileRef[] = [], ): Promise { - await connection.action("chat", "sendMessage", { - sessionId, - text, - ...(attachments.length ? { attachments } : {}), - }); + await connection.actionList("chat", "sendMessage", [ + { + sessionId, + text, + ...(attachments.length ? { attachments } : {}), + }, + { awaitDispatch: true }, + ]); } export async function approveToolUse(args: { @@ -146,11 +210,33 @@ export async function updateChatModel(args: { sessionId: string; modelId?: string | null; reasoningEffort?: string | null; + codexFastMode?: boolean; + permissionMode?: AgentChatPermissionMode; + interactionMode?: AgentChatInteractionMode; + claudePermissionMode?: AgentChatClaudePermissionMode; + codexApprovalPolicy?: AgentChatCodexApprovalPolicy; + codexSandbox?: AgentChatCodexSandbox; + codexConfigSource?: AgentChatCodexConfigSource; + opencodePermissionMode?: AgentChatOpenCodePermissionMode; + droidPermissionMode?: AgentChatDroidPermissionMode; + cursorModeId?: string | null; + cursorConfigValues?: Record; }): Promise { return await args.connection.action("chat", "updateSession", { sessionId: args.sessionId, ...(args.modelId !== undefined ? { modelId: args.modelId } : {}), ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), + ...(args.codexFastMode !== undefined ? { codexFastMode: args.codexFastMode } : {}), + ...(args.permissionMode !== undefined ? { permissionMode: args.permissionMode } : {}), + ...(args.interactionMode !== undefined ? { interactionMode: args.interactionMode } : {}), + ...(args.claudePermissionMode !== undefined ? { claudePermissionMode: args.claudePermissionMode } : {}), + ...(args.codexApprovalPolicy !== undefined ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), + ...(args.codexSandbox !== undefined ? { codexSandbox: args.codexSandbox } : {}), + ...(args.codexConfigSource !== undefined ? { codexConfigSource: args.codexConfigSource } : {}), + ...(args.opencodePermissionMode !== undefined ? { opencodePermissionMode: args.opencodePermissionMode } : {}), + ...(args.droidPermissionMode !== undefined ? { droidPermissionMode: args.droidPermissionMode } : {}), + ...(args.cursorModeId !== undefined ? { cursorModeId: args.cursorModeId } : {}), + ...(args.cursorConfigValues !== undefined ? { cursorConfigValues: args.cursorConfigValues } : {}), }); } @@ -173,12 +259,16 @@ export type TokenStats = { costUsd: number | null; }; -export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { +export function latestTokenStats( + events: AgentChatEventEnvelope[], + fallbackContextWindow?: number | null, +): TokenStats { let percent: number | null = null; let streaming = false; let inputTokens: number | null = null; let outputTokens: number | null = null; let costUsd: number | null = null; + let eventLimit: number | null = null; for (const envelope of events) { const event = envelope.event as Record; if (event.type === "status" && event.turnStatus === "started") streaming = true; @@ -186,16 +276,7 @@ export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { if (event.type === "tokens") { inputTokens = typeof event.inputTokens === "number" ? event.inputTokens : inputTokens; outputTokens = typeof event.outputTokens === "number" ? event.outputTokens : outputTokens; - let used: number | null = null; - if (typeof event.totalTokens === "number") { - used = event.totalTokens; - } else if (inputTokens != null || outputTokens != null) { - used = (inputTokens ?? 0) + (outputTokens ?? 0); - } - const limit = typeof event.contextWindow === "number" ? event.contextWindow : null; - if (used != null && limit != null && limit > 0) { - percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); - } + if (typeof event.contextWindow === "number") eventLimit = event.contextWindow; } if (event.type === "done") { const usage = event.usage && typeof event.usage === "object" ? event.usage as Record : null; @@ -204,5 +285,10 @@ export function latestTokenStats(events: AgentChatEventEnvelope[]): TokenStats { costUsd = typeof event.costUsd === "number" ? event.costUsd : costUsd; } } + const used = inputTokens != null || outputTokens != null ? (inputTokens ?? 0) + (outputTokens ?? 0) : null; + const limit = eventLimit ?? (typeof fallbackContextWindow === "number" && fallbackContextWindow > 0 ? fallbackContextWindow : null); + if (used != null && limit != null && limit > 0) { + percent = Math.max(0, Math.min(100, Math.round((used / limit) * 100))); + } return { percent, streaming, inputTokens, outputTokens, costUsd }; } diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index e38fe045..974ed453 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -2,22 +2,37 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { spawn } from "node:child_process"; import path from "node:path"; import { Box, Text, useApp, useInput } from "ink"; -import { getDefaultModelDescriptor } from "../../../desktop/src/shared/modelRegistry"; +import { + getDefaultModelDescriptor, + getModelById, + listModelDescriptorsForProvider, + modelSupportsFastMode, + resolveProviderGroupForModel, +} from "../../../desktop/src/shared/modelRegistry"; +import { CURSOR_AVAILABLE_MODE_IDS, CURSOR_MODE_LABELS } from "../../../desktop/src/shared/cursorModes"; import type { + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, AgentChatEventEnvelope, AgentChatFileRef, AgentChatModelInfo, + AgentChatPermissionMode, AgentChatSessionSummary, AgentChatSlashCommand, } from "../../../desktop/src/shared/types/chat"; +import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, createChatSession, getAvailableModels, + getAiSettingsStatus, getChatHistory, + getOpenCodeRuntimeDiagnostics, getSlashCommands, + getStoredApiKeyProviders, interruptChat, latestTokenStats, listChatSessions, @@ -39,6 +54,9 @@ import { RightPane } from "./components/RightPane"; import { SlashPalette } from "./components/SlashPalette"; import { MentionPalette } from "./components/MentionPalette"; import { ApprovalPrompt } from "./components/ApprovalPrompt"; +import { ModelStatus } from "./components/ModelStatus"; +import { FooterControls } from "./components/FooterControls"; +import { theme } from "./theme"; import { chooseInitialLane } from "./project"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; @@ -46,18 +64,37 @@ import { buildLinearToolRequest } from "./linearCommands"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; import type { AdeCodeConnection, + AdeCodeProvider, AdeCodeModelState, LocalNotice, MentionSuggestion, PendingApproval, + ProviderReadinessRow, ProjectLaunchContext, RightPaneContent, + SetupPaneRow, RuntimeMode, } from "./types"; -const PURPLE = "#A78BFA"; -const EFFORTS = ["low", "medium", "high", "xhigh"]; -const PROVIDERS = new Set(["codex", "claude", "opencode", "cursor", "droid"]); +const PURPLE = theme.color.accent; +const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +const PROVIDER_OPTIONS: Array<{ value: AdeCodeProvider; label: string }> = [ + { value: "codex", label: "Codex" }, + { value: "claude", label: "Claude" }, + { value: "opencode", label: "OpenCode" }, + { value: "cursor", label: "Cursor" }, + { value: "droid", label: "Droid" }, +]; +const PROVIDERS = new Set(PROVIDER_OPTIONS.map((provider) => provider.value)); +const CODEX_PRESETS = ["default", "plan", "full-auto", "config-toml"] as const; +const CLAUDE_PERMISSION_OPTIONS = ["default", "plan", "acceptEdits", "bypassPermissions"] as const; +const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto"] as const; +const DROID_PERMISSION_OPTIONS = ["read-only", "auto-low", "auto-medium", "auto-high"] as const; +const SETTINGS_AI_ROUTE = "/settings?tab=ai#ai-providers"; +type PaneFocus = "drawer" | "chat" | "details"; +type FooterControl = "drawer" | "details"; +type DrawerLaneAction = "new-lane"; +type DrawerChatAction = "new-chat"; const DESKTOP_COMMAND_ROUTES: Record = { "/app-control": "/app-control", "/browser": "/browser", @@ -87,9 +124,172 @@ function initialModelState(): AdeCodeModelState { modelId: descriptor?.id ?? null, displayName: descriptor?.displayName ?? "GPT-5.5", reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, + codexFastMode: false, + permissionMode: "default", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + cursorModeId: "agent", + cursorConfigValues: {}, + }; +} + +type CodexPreset = (typeof CODEX_PRESETS)[number]; + +function providerLabel(provider: AdeCodeProvider): string { + return PROVIDER_OPTIONS.find((entry) => entry.value === provider)?.label ?? provider; +} + +function normalizeProvider(value: string | null | undefined): AdeCodeProvider { + return PROVIDERS.has(value as AdeCodeProvider) ? value as AdeCodeProvider : "codex"; +} + +function firstReasoningEffortForModel(model: AgentChatModelInfo | null | undefined, provider: AdeCodeProvider): string | null { + const efforts = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; + if (efforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; + if (efforts.length) return efforts[0] ?? null; + const descriptor = model?.modelId || model?.id ? getModelById(model.modelId ?? model.id) : undefined; + const descriptorEfforts = descriptor?.reasoningTiers ?? []; + if (descriptorEfforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; + if (descriptorEfforts.length) return descriptorEfforts[0] ?? null; + return provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null; +} + +function modelStatePatchForModel(provider: AdeCodeProvider, model: AgentChatModelInfo): Pick { + const modelId = model.modelId ?? model.id; + const descriptor = getModelById(modelId); + const resolvedProvider = descriptor ? normalizeProvider(resolveProviderGroupForModel(descriptor)) : provider; + return { + provider: resolvedProvider, + model: model.id, + modelId, + displayName: model.displayName, + reasoningEffort: firstReasoningEffortForModel(model, resolvedProvider), }; } +function fallbackModelStatePatch(provider: AdeCodeProvider): Pick { + const descriptor = getDefaultModelDescriptor(provider) + ?? listModelDescriptorsForProvider(provider)[0] + ?? getDefaultModelDescriptor("codex"); + return { + provider, + model: descriptor?.providerModelId ?? descriptor?.shortId ?? descriptor?.id ?? "gpt-5.5", + modelId: descriptor?.id ?? null, + displayName: descriptor?.displayName ?? providerLabel(provider), + reasoningEffort: descriptor?.reasoningTiers?.[0] ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null), + }; +} + +function modelReasoningEfforts(modelState: AdeCodeModelState, models: AgentChatModelInfo[]): string[] { + if (modelState.provider === "cursor" || modelState.provider === "droid") return []; + const model = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); + const fromModel = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; + if (fromModel.length) return fromModel; + const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; + return descriptor?.reasoningTiers?.length ? descriptor.reasoningTiers : EFFORTS; +} + +function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custom" { + if (modelState.codexConfigSource === "config-toml") return "config-toml"; + if (modelState.codexApprovalPolicy === "never" && modelState.codexSandbox === "danger-full-access") return "full-auto"; + if ( + (modelState.codexApprovalPolicy === "on-request" || modelState.codexApprovalPolicy === "untrusted") + && modelState.codexSandbox === "read-only" + ) return "plan"; + if ( + (modelState.codexApprovalPolicy === "on-request" || modelState.codexApprovalPolicy === "on-failure" || modelState.codexApprovalPolicy === "untrusted") + && modelState.codexSandbox === "workspace-write" + ) return "default"; + return "custom"; +} + +function codexPresetPatch(preset: CodexPreset): Pick { + if (preset === "full-auto") { + return { + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "flags", + permissionMode: "full-auto", + }; + } + if (preset === "plan") { + return { + codexApprovalPolicy: "on-request", + codexSandbox: "read-only", + codexConfigSource: "flags", + permissionMode: "plan", + }; + } + if (preset === "config-toml") { + return { + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "config-toml", + permissionMode: "config-toml", + }; + } + return { + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + permissionMode: "default", + }; +} + +function droidPermissionToLegacy(mode: AdeCodeModelState["droidPermissionMode"]): AgentChatPermissionMode { + if (mode === "read-only") return "plan"; + if (mode === "auto-low") return "edit"; + if (mode === "auto-medium") return "default"; + return "full-auto"; +} + +function cursorModeLabel(modeId: string | null | undefined): string { + const normalized = modeId?.trim().toLowerCase() || "agent"; + return CURSOR_MODE_LABELS[normalized] ?? normalized; +} + +function permissionSummary(modelState: AdeCodeModelState): string { + if (modelState.provider === "codex") return resolveCodexPreset(modelState); + if (modelState.provider === "claude") { + if (modelState.interactionMode === "plan" || modelState.claudePermissionMode === "plan") return "plan"; + if (modelState.claudePermissionMode === "acceptEdits") return "accept edits"; + if (modelState.claudePermissionMode === "bypassPermissions") return "bypass"; + return "default"; + } + if (modelState.provider === "opencode") return modelState.opencodePermissionMode; + if (modelState.provider === "droid") return modelState.droidPermissionMode; + return cursorModeLabel(modelState.cursorModeId); +} + +function applyProviderPermissionMode(modelState: AdeCodeModelState): Partial { + if (modelState.provider === "codex") { + const preset = resolveCodexPreset(modelState); + return { permissionMode: preset === "custom" ? modelState.permissionMode : preset }; + } + if (modelState.provider === "claude") { + if (modelState.interactionMode === "plan" || modelState.claudePermissionMode === "plan") { + return { permissionMode: "plan", interactionMode: "plan", claudePermissionMode: "plan" }; + } + if (modelState.claudePermissionMode === "acceptEdits") return { permissionMode: "edit", interactionMode: "default" }; + if (modelState.claudePermissionMode === "bypassPermissions") return { permissionMode: "full-auto", interactionMode: "default" }; + return { permissionMode: "default", interactionMode: "default" }; + } + if (modelState.provider === "opencode") return { permissionMode: modelState.opencodePermissionMode }; + if (modelState.provider === "droid") return { permissionMode: droidPermissionToLegacy(modelState.droidPermissionMode) }; + if (modelState.provider === "cursor") { + if (modelState.cursorModeId === "plan") return { permissionMode: "plan" }; + if (modelState.cursorModeId === "ask") return { permissionMode: "edit" }; + if (modelState.cursorModeId === "full-auto") return { permissionMode: "full-auto" }; + return { permissionMode: "default" }; + } + return {}; +} + function noticeId(): string { return `${Date.now()}:${Math.random().toString(36).slice(2)}`; } @@ -122,6 +322,158 @@ function formatTokenSummary(stats: ReturnType): string return parts.length ? parts.join(" · ") : null; } +function buildSetupRows(args: { + modelState: AdeCodeModelState; + models: AgentChatModelInfo[]; + includeRefresh: boolean; + includeApply: boolean; +}): SetupPaneRow[] { + const efforts = modelReasoningEfforts(args.modelState, args.models); + const descriptor = args.modelState.modelId ? getModelById(args.modelState.modelId) : undefined; + const fastSupported = args.modelState.provider === "codex" && modelSupportsFastMode(descriptor); + const rows: SetupPaneRow[] = [ + { + kind: "provider", + label: "Provider", + value: providerLabel(args.modelState.provider), + cyclable: true, + }, + { + kind: "model", + label: "Model", + value: args.modelState.displayName, + detail: args.models.length ? `${args.models.length} available` : "using registry default", + cyclable: true, + }, + { + kind: "reasoning", + label: "Reasoning", + value: args.modelState.reasoningEffort ?? "none", + detail: efforts.length ? efforts.join(", ") : "not exposed by this model", + disabled: !efforts.length, + cyclable: true, + }, + { + kind: "permission", + label: "Permissions", + value: permissionSummary(args.modelState), + detail: args.modelState.provider === "codex" + ? `${args.modelState.codexApprovalPolicy} / ${args.modelState.codexSandbox}` + : args.modelState.provider === "cursor" + ? "Cursor mode" + : "provider native", + cyclable: true, + }, + ]; + if (args.modelState.provider === "codex") { + rows.push({ + kind: "codex-fast", + label: "Fast mode", + value: fastSupported ? (args.modelState.codexFastMode ? "on" : "off") : "unsupported", + detail: "Codex service tier", + disabled: !fastSupported, + cyclable: true, + }); + } + if (args.includeRefresh) { + rows.push({ + kind: "refresh-status", + label: "Refresh status", + value: "run", + detail: "checks provider auth/runtime state", + }); + } + rows.push({ + kind: "open-settings", + label: "Full settings", + value: "open desktop", + detail: "Settings > AI Providers", + }); + if (args.includeApply) { + rows.push({ + kind: "apply", + label: "Use this setup", + value: "ready", + detail: "returns focus to the chat composer", + }); + } + return rows; +} + +function setupRowsForRuntime(rows: SetupPaneRow[], mode: RuntimeMode | "connecting"): SetupPaneRow[] { + if (mode === "attached") return rows; + return rows.map((row) => row.kind === "open-settings" + ? { + ...row, + value: "unavailable", + detail: "use /login for Claude, Codex, or OpenCode; open ADE desktop for full settings", + disabled: true, + } + : row); +} + +function providerConnectionDetail(status: AiSettingsStatus | null, provider: Exclude): ProviderReadinessRow { + const connection = status?.providerConnections?.[provider]; + const modelCount = status?.models?.[provider]?.length ?? 0; + if (connection?.runtimeAvailable) { + return { + provider, + label: providerLabel(provider), + status: "ready", + detail: connection.path ? `ready at ${connection.path}` : "runtime and auth ready", + modelCount, + }; + } + if (connection?.runtimeDetected || connection?.authAvailable) { + return { + provider, + label: providerLabel(provider), + status: "unknown", + detail: connection.blocker ?? "detected but not fully ready", + modelCount, + }; + } + return { + provider, + label: providerLabel(provider), + status: "unavailable", + detail: connection?.blocker ?? "not detected", + modelCount, + }; +} + +function buildProviderReadinessRows( + status: AiSettingsStatus | null, + storedApiKeyProviders: string[], + openCodeDiagnostics: OpenCodeRuntimeSnapshot | null, +): ProviderReadinessRow[] { + const rows: ProviderReadinessRow[] = [ + providerConnectionDetail(status, "codex"), + providerConnectionDetail(status, "claude"), + providerConnectionDetail(status, "cursor"), + providerConnectionDetail(status, "droid"), + ]; + const opencodeProviders = status?.opencodeProviders ?? []; + const opencodeModelCount = opencodeProviders.reduce((sum, provider) => sum + provider.modelCount, 0); + rows.push({ + provider: "opencode", + label: "OpenCode", + status: status?.opencodeBinaryInstalled ? "ready" : "unavailable", + detail: status?.opencodeInventoryError + ?? (status?.opencodeBinaryInstalled + ? `${status.opencodeBinarySource ?? "installed"} · ${openCodeDiagnostics?.sharedCount ?? 0} shared runtime` + : "binary missing"), + modelCount: opencodeModelCount, + }); + if (storedApiKeyProviders.includes("cursor")) { + const cursor = rows.find((row) => row.provider === "cursor"); + if (cursor && cursor.status !== "ready") { + cursor.detail = `${cursor.detail} · Cursor key stored`; + } + } + return rows; +} + function desktopRouteForCommand(commandName: string | null | undefined): string | null { if (!commandName) return null; return DESKTOP_COMMAND_ROUTES[commandName] ?? null; @@ -197,6 +549,55 @@ function inputBeforeLineBreak(input: string): string | null { return index === -1 ? null : input.slice(0, index); } +function runInteractiveTerminalCommand(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const stdin = process.stdin as NodeJS.ReadStream & { isRaw?: boolean; setRawMode?: (mode: boolean) => void }; + const wasRaw = Boolean(stdin.isRaw); + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(false); + } + process.stdout.write("\n"); + const child = spawn(command, args, { + cwd, + stdio: "inherit", + env: process.env, + }); + const restore = () => { + if (typeof stdin.setRawMode === "function") { + stdin.setRawMode(wasRaw); + } + }; + child.once("error", (error) => { + restore(); + reject(error); + }); + child.once("close", (code) => { + restore(); + process.stdout.write("\n"); + resolve(code); + }); + }); +} + +type ProviderLoginCommand = { command: string; args: string[]; label: string }; + +function loginCommandsForProvider(provider: AdeCodeProvider): ProviderLoginCommand[] { + if (provider === "claude") return [{ command: "claude", args: ["auth", "login"], label: "claude auth login" }]; + if (provider === "codex") return [{ command: "codex", args: ["login"], label: "codex login" }]; + if (provider === "opencode") return [{ command: "opencode", args: ["auth", "login"], label: "opencode auth login" }]; + return []; +} + +function loginUnavailableHint(provider: AdeCodeProvider): string { + if (provider === "cursor") { + return "ADE Cursor chat uses @cursor/sdk, which requires a Cursor API key. Open Settings > AI Providers, use ADE's encrypted key store, or set CURSOR_API_KEY before launching ADE."; + } + if (provider === "droid") { + return "ADE Droid chat runs Factory Droid over ACP. Set FACTORY_API_KEY before launching ADE, or run `droid` and use its interactive `/login`."; + } + return "No terminal login command is known for this provider."; +} + function activeMention(value: string): { start: number; query: string } | null { const match = value.match(/(^|\s)@([^\s@]*)$/); if (!match || match.index == null) return null; @@ -236,19 +637,23 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [slashCommands, setSlashCommands] = useState([]); const [models, setModels] = useState([]); const [modelState, setModelState] = useState(initialModelState); + const [draftChatActive, setDraftChatActive] = useState(false); + const [aiStatus, setAiStatus] = useState(null); + const [aiStatusCheckedAt, setAiStatusCheckedAt] = useState(null); + const [storedApiKeyProviders, setStoredApiKeyProviders] = useState([]); + const [openCodeDiagnostics, setOpenCodeDiagnostics] = useState(null); const [rightPane, setRightPane] = useState({ kind: "empty" }); const [formValues, setFormValues] = useState>({}); const [formFieldIndex, setFormFieldIndex] = useState(0); const [rightSelectionIndex, setRightSelectionIndex] = useState(0); const [drawerOpen, setDrawerOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); + const [activePane, setActivePane] = useState("chat"); const [prompt, setPrompt] = useState(""); const [error, setError] = useState(null); - const [tuiCount, setTuiCount] = useState(1); const [contextPercent, setContextPercent] = useState(null); const [tokenSummary, setTokenSummary] = useState(null); const [streaming, setStreaming] = useState(false); - const [desktopDriving, setDesktopDriving] = useState(false); const [clearedAt, setClearedAt] = useState(null); const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); const [mentionSuggestions, setMentionSuggestions] = useState([]); @@ -259,13 +664,25 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [drawerLaneId, setDrawerLaneId] = useState(null); const [selectedDrawerLaneId, setSelectedDrawerLaneId] = useState(null); const [selectedDrawerChatId, setSelectedDrawerChatId] = useState(null); + const [selectedDrawerLaneAction, setSelectedDrawerLaneAction] = useState(null); + const [selectedDrawerChatAction, setSelectedDrawerChatAction] = useState(null); + const [formDiscardArmed, setFormDiscardArmed] = useState(false); + const [footerControl, setFooterControl] = useState(null); const connectionRef = useRef(null); const activeLaneIdRef = useRef(null); const activeSessionIdRef = useRef(null); + const draftChatActiveRef = useRef(false); + const activePaneRef = useRef("chat"); + const footerControlRef = useRef(null); + const paneBeforeDetailsRef = useRef("chat"); + const chatDraftRef = useRef(""); + const promptRef = useRef(""); const lastLocalSendAtRef = useRef(0); const eventCountRef = useRef(0); const heartbeatRef = useRef(null); + const draftSeededFromHistoryRef = useRef(false); + const attachProbeInFlightRef = useRef(false); const selectActiveLaneId = useCallback((laneId: string | null) => { activeLaneIdRef.current = laneId; @@ -273,10 +690,128 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, []); const selectActiveSessionId = useCallback((sessionId: string | null) => { + if (sessionId) { + draftChatActiveRef.current = false; + setDraftChatActive(false); + setSelectedDrawerChatAction(null); + } activeSessionIdRef.current = sessionId; setActiveSessionId(sessionId); }, []); + const setDraftChatMode = useCallback((active: boolean) => { + draftChatActiveRef.current = active; + setDraftChatActive(active); + }, []); + + const setPaneFocus = useCallback((pane: PaneFocus) => { + activePaneRef.current = pane; + setActivePane(pane); + }, []); + + const selectFooterControl = useCallback((control: FooterControl | null) => { + footerControlRef.current = control; + setFooterControl(control); + }, []); + + useEffect(() => { + promptRef.current = prompt; + }, [prompt]); + + const stashActiveInput = useCallback(() => { + const pane = activePaneRef.current; + if (pane === "chat") { + chatDraftRef.current = promptRef.current; + return; + } + if (pane === "details" && rightPane.kind === "form") { + const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; + if (field) { + setFormValues((prev) => ({ ...prev, [field.name]: promptRef.current })); + } + } + }, [formFieldIndex, rightPane]); + + const focusChat = useCallback(() => { + stashActiveInput(); + setFormDiscardArmed(false); + selectFooterControl(null); + setPrompt(chatDraftRef.current); + setPaneFocus("chat"); + }, [selectFooterControl, setPaneFocus, stashActiveInput]); + + const focusDrawer = useCallback(() => { + stashActiveInput(); + setFormDiscardArmed(false); + selectFooterControl(null); + setPrompt(""); + setDrawerOpen(true); + setPaneFocus("drawer"); + }, [selectFooterControl, setPaneFocus, stashActiveInput]); + + const focusDetails = useCallback(() => { + const previousPane = activePaneRef.current; + stashActiveInput(); + selectFooterControl(null); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setFormDiscardArmed(false); + setRightOpen(true); + if (rightPane.kind === "form") { + const field = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; + setPrompt(field ? formValues[field.name] ?? field.initialValue ?? "" : ""); + } else { + setPrompt(""); + } + setPaneFocus("details"); + }, [formFieldIndex, formValues, rightPane, selectFooterControl, setPaneFocus, stashActiveInput]); + + const toggleDrawerPane = useCallback(() => { + selectFooterControl(null); + if (drawerOpen) { + setDrawerOpen(false); + focusChat(); + return; + } + focusDrawer(); + }, [drawerOpen, focusChat, focusDrawer, selectFooterControl]); + + const toggleDetailsPane = useCallback(() => { + selectFooterControl(null); + if (rightOpen && rightPane.kind !== "form") { + setRightOpen(false); + focusChat(); + return; + } + if (activePaneRef.current === "details") { + focusChat(); + return; + } + focusDetails(); + }, [focusChat, focusDetails, rightOpen, rightPane.kind, selectFooterControl]); + + const cyclePaneFocus = useCallback(() => { + const order: PaneFocus[] = ["drawer", "chat", "details"]; + const currentIndex = order.indexOf(activePaneRef.current); + const nextPane = order[(currentIndex + 1) % order.length] ?? "chat"; + if (nextPane === "drawer") { + focusDrawer(); + } else if (nextPane === "details") { + focusDetails(); + } else { + focusChat(); + } + }, [focusChat, focusDetails, focusDrawer]); + + const focusAfterDetails = useCallback(() => { + if (paneBeforeDetailsRef.current === "drawer" && drawerOpen) { + focusDrawer(); + return; + } + focusChat(); + }, [drawerOpen, focusChat, focusDrawer]); + const projectName = path.basename(project.projectRoot); const activeLane = useMemo( () => lanes.find((lane) => lane.id === activeLaneId) ?? null, @@ -293,24 +828,40 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } [drawerLaneId, sessions], ); const selectedLaneIndex = useMemo(() => { + if (selectedDrawerLaneAction === "new-lane") return drawerLaneRows.length; const targetId = selectedDrawerLaneId ?? drawerLaneId ?? activeLaneId; const index = drawerLaneRows.findIndex((lane) => lane.id === targetId); return index >= 0 ? index : 0; - }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneId]); + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); const selectedChatIndex = useMemo(() => { + if (selectedDrawerChatAction === "new-chat") return drawerLaneSessions.length; const targetId = selectedDrawerChatId ?? (drawerLaneId === activeLaneId ? activeSessionId : null); const index = drawerLaneSessions.findIndex((session) => session.sessionId === targetId); return index >= 0 ? index : 0; - }, [activeLaneId, activeSessionId, drawerLaneId, drawerLaneSessions, selectedDrawerChatId]); - const activeMentionRange = useMemo(() => activeMention(prompt), [prompt]); + }, [activeLaneId, activeSessionId, drawerLaneId, drawerLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + const activeMentionRange = useMemo(() => ( + activePane === "chat" ? activeMention(prompt) : null + ), [activePane, prompt]); const slashRows = useMemo(() => ( - prompt.startsWith("/") ? paletteCommands(prompt, slashCommands) : [] - ), [prompt, slashCommands]); + activePane === "chat" && prompt.startsWith("/") ? paletteCommands(prompt, slashCommands) : [] + ), [activePane, prompt, slashCommands]); const pendingApproval = useMemo(() => latestPendingApproval(events), [events]); const activeFormField = rightPane.kind === "form" ? rightPane.fields[formFieldIndex] ?? rightPane.fields[0] ?? null : null; + const providerReadinessRows = useMemo( + () => buildProviderReadinessRows(aiStatus, storedApiKeyProviders, openCodeDiagnostics), + [aiStatus, openCodeDiagnostics, storedApiKeyProviders], + ); + const newChatSetupRows = useMemo( + () => setupRowsForRuntime(buildSetupRows({ modelState, models, includeRefresh: false, includeApply: true }), mode), + [mode, modelState, models], + ); + const modelSetupRows = useMemo( + () => setupRowsForRuntime(buildSetupRows({ modelState, models, includeRefresh: true, includeApply: false }), mode), + [mode, modelState, models], + ); useEffect(() => { activeLaneIdRef.current = activeLaneId; @@ -320,6 +871,30 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSessionIdRef.current = activeSessionId; }, [activeSessionId]); + useEffect(() => { + if (rightPane.kind === "new-chat-setup") { + setRightPane((prev) => prev.kind === "new-chat-setup" + ? { + ...prev, + laneId: activeLaneId ?? prev.laneId, + laneLabel: activeLane?.name ?? prev.laneLabel, + rows: newChatSetupRows, + } + : prev); + } else if (rightPane.kind === "model-setup") { + setRightPane((prev) => prev.kind === "model-setup" + ? { + ...prev, + rows: modelSetupRows, + providerRows: providerReadinessRows, + activeProvider: modelState.provider, + checkedAt: aiStatusCheckedAt, + desktopAttached: mode === "attached", + } + : prev); + } + }, [activeLane?.name, activeLaneId, aiStatusCheckedAt, mode, modelSetupRows, modelState.provider, newChatSetupRows, providerReadinessRows, rightPane.kind]); + useEffect(() => { if (!drawerLaneId || !lanes.some((lane) => lane.id === drawerLaneId)) { setDrawerLaneId(activeLaneId); @@ -327,15 +902,22 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }, [activeLaneId, drawerLaneId, lanes]); useEffect(() => { + if (selectedDrawerLaneAction) return; if (selectedDrawerLaneId && drawerLaneRows.some((lane) => lane.id === selectedDrawerLaneId)) return; setSelectedDrawerLaneId(drawerLaneId ?? activeLaneId ?? drawerLaneRows[0]?.id ?? null); - }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneId]); + }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); useEffect(() => { + if (selectedDrawerChatAction) return; + if (draftChatActive && drawerLaneId === activeLaneId) { + setSelectedDrawerChatId(null); + setSelectedDrawerChatAction("new-chat"); + return; + } if (selectedDrawerChatId && drawerLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; const activeChatInDrawer = drawerLaneSessions.find((session) => session.sessionId === activeSessionId); setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerLaneSessions[0]?.sessionId ?? null); - }, [activeSessionId, drawerLaneSessions, selectedDrawerChatId]); + }, [activeLaneId, activeSessionId, draftChatActive, drawerLaneId, drawerLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); useEffect(() => { setSlashIndex(0); @@ -348,14 +930,122 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ]); }, []); + const refreshAiSetupStatus = useCallback(async (options: { force?: boolean } = {}) => { + const conn = connectionRef.current; + if (!conn) return; + const [status, storedProviders, diagnostics] = await Promise.all([ + getAiSettingsStatus(conn, { + force: options.force === true, + refreshOpenCodeInventory: true, + }), + getStoredApiKeyProviders(conn).catch(() => []), + getOpenCodeRuntimeDiagnostics(conn).catch(() => null), + ]); + setAiStatus(status); + setStoredApiKeyProviders(storedProviders.map((provider) => provider.trim().toLowerCase()).filter(Boolean)); + setOpenCodeDiagnostics(diagnostics); + setAiStatusCheckedAt(new Date().toISOString()); + }, []); + + const loadProviderModels = useCallback(async (provider: AdeCodeProvider, options: { applyDefault?: boolean } = {}) => { + const conn = connectionRef.current; + const nextModels = conn + ? await getAvailableModels(conn, provider).catch(() => []) + : []; + setModels(nextModels); + if (options.applyDefault !== false) { + const model = nextModels.find((entry) => entry.isDefault) ?? nextModels[0] ?? null; + setModelState((prev) => ({ + ...prev, + ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), + })); + } + return nextModels; + }, []); + const openForm = useCallback((content: Extract) => { + const previousPane = activePaneRef.current; + stashActiveInput(); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } const nextValues = Object.fromEntries(content.fields.map((field) => [field.name, field.initialValue ?? ""])); setFormValues(nextValues); setFormFieldIndex(0); + setFormDiscardArmed(false); setPrompt(content.fields[0]?.initialValue ?? ""); setRightPane(content); setRightOpen(true); - }, []); + setPaneFocus("details"); + }, [setPaneFocus, stashActiveInput]); + + const openNewLaneForm = useCallback(() => { + openForm({ + kind: "form", + title: "New lane", + command: "new-lane", + fields: [ + { name: "name", label: "Name", required: true, placeholder: "feature-name" }, + { name: "baseBranch", label: "Base branch", placeholder: "default" }, + ], + }); + }, [openForm]); + + const openNewChatSetup = useCallback(() => { + if (!activeLaneIdRef.current) { + setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); + focusDetails(); + return; + } + draftSeededFromHistoryRef.current = true; + const previousPane = activePaneRef.current; + stashActiveInput(); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setDraftChatMode(true); + selectActiveSessionId(null); + setEvents([]); + setClearedAt(null); + chatDraftRef.current = ""; + setPrompt(""); + setRightSelectionIndex(0); + setFormDiscardArmed(false); + setRightPane({ + kind: "new-chat-setup", + laneId: activeLaneIdRef.current, + laneLabel: activeLane?.name ?? activeLaneIdRef.current, + rows: newChatSetupRows, + }); + setRightOpen(true); + setPaneFocus("details"); + void refreshAiSetupStatus().catch(() => undefined); + void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); + }, [activeLane?.name, focusDetails, loadProviderModels, modelState.provider, newChatSetupRows, refreshAiSetupStatus, selectActiveSessionId, setDraftChatMode, setPaneFocus, stashActiveInput]); + + const openModelSetup = useCallback((options: { forceRefresh?: boolean } = {}) => { + const previousPane = activePaneRef.current; + stashActiveInput(); + if (previousPane !== "details") { + paneBeforeDetailsRef.current = previousPane; + } + setRightSelectionIndex(0); + setRightPane({ + kind: "model-setup", + rows: modelSetupRows, + providerRows: providerReadinessRows, + activeProvider: modelState.provider, + checkedAt: aiStatusCheckedAt, + desktopAttached: mode === "attached", + }); + setRightOpen(true); + setPrompt(""); + setPaneFocus("details"); + void refreshAiSetupStatus({ force: options.forceRefresh === true }).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + void loadProviderModels(modelState.provider, { applyDefault: false }).catch(() => undefined); + }, [addNotice, aiStatusCheckedAt, loadProviderModels, mode, modelSetupRows, modelState.provider, providerReadinessRows, refreshAiSetupStatus, setPaneFocus, stashActiveInput]); useEffect(() => { const range = activeMentionRange; @@ -465,10 +1155,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextLaneId = nextLane?.id ?? null; const nextSessions = await listChatSessions(conn); const laneSessions = nextSessions.filter((session) => session.laneId === nextLaneId); - const activeSessionId = activeSessionIdRef.current; - const nextSession = activeSessionId - ? nextSessions.find((session) => session.sessionId === activeSessionId) ?? null - : null; + const draftMode = draftChatActiveRef.current; + const seedSession = draftMode ? newestSession(laneSessions) : null; + const nextSession = draftMode + ? null + : nextSessions.find((session) => session.sessionId === activeSessionIdRef.current) + ?? newestSession(laneSessions); const nextSessionId = nextSession?.sessionId ?? null; let nextEvents: AgentChatEventEnvelope[] = []; if (nextSessionId) { @@ -476,21 +1168,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } nextEvents = clearedAt ? history.events.filter((event) => event.timestamp > clearedAt) : history.events; - const stats = latestTokenStats(history.events); + const activeModelId = nextSession?.modelId ?? null; + const fallbackContext = activeModelId ? getModelById(activeModelId)?.contextWindow ?? null : null; + const stats = latestTokenStats(history.events, fallbackContext); setContextPercent(stats.percent); setTokenSummary(formatTokenSummary(stats)); setStreaming(nextSession?.status === "active"); - const previousCount = eventCountRef.current; eventCountRef.current = history.events.length; - if (previousCount > 0 && history.events.length > previousCount && Date.now() - lastLocalSendAtRef.current > 4_000) { - setDesktopDriving(true); - setTimeout(() => setDesktopDriving(false), 3_000); - } - } - const nextProvider = nextSession?.provider ?? "codex"; + } else { + setContextPercent(null); + setTokenSummary(null); + setStreaming(false); + eventCountRef.current = 0; + } + const configSession = nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); + const nextProvider = configSession?.provider ?? modelState.provider ?? "codex"; const nextCommands = await getSlashCommands(conn, nextSessionId).catch(() => []); const nextModels = await getAvailableModels(conn, nextProvider).catch(() => []); - const activeModel = nextModels.find((model) => model.modelId === nextSession?.modelId || model.id === nextSession?.modelId) + const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) ?? nextModels.find((model) => model.isDefault) ?? null; setLanes(nextLanes); @@ -500,15 +1195,55 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setEvents(nextEvents); setSlashCommands(nextCommands); setModels(nextModels); - setTuiCount(heartbeatRef.current?.readCount() ?? 1); - setModelState({ - provider: PROVIDERS.has(nextProvider) ? nextProvider as AdeCodeModelState["provider"] : "codex", - model: nextSession?.model ?? activeModel?.id ?? modelState.model, - modelId: nextSession?.modelId ?? activeModel?.modelId ?? activeModel?.id ?? modelState.modelId, - displayName: activeModel?.displayName ?? nextSession?.model ?? modelState.displayName, - reasoningEffort: nextSession?.reasoningEffort ?? modelState.reasoningEffort, + if (configSession && (!draftMode || !draftSeededFromHistoryRef.current)) { + const provider = normalizeProvider(nextProvider); + setModelState((prev) => ({ + ...prev, + provider, + model: configSession.model ?? activeModel?.id ?? prev.model, + modelId: configSession.modelId ?? activeModel?.modelId ?? activeModel?.id ?? prev.modelId, + displayName: activeModel?.displayName ?? configSession.model ?? prev.displayName, + reasoningEffort: configSession.reasoningEffort ?? prev.reasoningEffort, + codexFastMode: configSession.codexFastMode === true, + permissionMode: configSession.permissionMode ?? prev.permissionMode, + interactionMode: configSession.interactionMode ?? prev.interactionMode, + claudePermissionMode: configSession.claudePermissionMode ?? prev.claudePermissionMode, + codexApprovalPolicy: configSession.codexApprovalPolicy ?? prev.codexApprovalPolicy, + codexSandbox: configSession.codexSandbox ?? prev.codexSandbox, + codexConfigSource: configSession.codexConfigSource ?? prev.codexConfigSource, + opencodePermissionMode: configSession.opencodePermissionMode ?? prev.opencodePermissionMode, + droidPermissionMode: configSession.droidPermissionMode ?? prev.droidPermissionMode, + cursorModeId: configSession.cursorModeId ?? configSession.cursorModeSnapshot?.currentModeId ?? prev.cursorModeId, + cursorConfigValues: configSession.cursorConfigValues ?? prev.cursorConfigValues, + })); + if (draftMode) draftSeededFromHistoryRef.current = true; + } + }, [clearedAt, modelState.provider, project, selectActiveLaneId, selectActiveSessionId]); + + const commitModelStateToSession = useCallback(async (nextState: AdeCodeModelState) => { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (!conn || !sessionId || draftChatActiveRef.current) return; + const normalized = { ...nextState, ...applyProviderPermissionMode(nextState) }; + await updateChatModel({ + connection: conn, + sessionId, + modelId: normalized.modelId, + reasoningEffort: normalized.reasoningEffort, + codexFastMode: normalized.provider === "codex" ? normalized.codexFastMode : undefined, + permissionMode: normalized.permissionMode, + interactionMode: normalized.provider === "claude" ? normalized.interactionMode : undefined, + claudePermissionMode: normalized.provider === "claude" ? normalized.claudePermissionMode : undefined, + codexApprovalPolicy: normalized.provider === "codex" ? normalized.codexApprovalPolicy : undefined, + codexSandbox: normalized.provider === "codex" ? normalized.codexSandbox : undefined, + codexConfigSource: normalized.provider === "codex" ? normalized.codexConfigSource : undefined, + opencodePermissionMode: normalized.provider === "opencode" ? normalized.opencodePermissionMode : undefined, + droidPermissionMode: normalized.provider === "droid" ? normalized.droidPermissionMode : undefined, + cursorModeId: normalized.provider === "cursor" ? normalized.cursorModeId : undefined, + cursorConfigValues: normalized.provider === "cursor" ? normalized.cursorConfigValues : undefined, }); - }, [clearedAt, modelState.displayName, modelState.model, modelState.modelId, modelState.reasoningEffort, project, selectActiveLaneId, selectActiveSessionId]); + await refreshState(); + }, [refreshState]); useEffect(() => { let cancelled = false; @@ -523,6 +1258,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } connectionRef.current = conn; setConnection(conn); setMode(conn.mode); + draftSeededFromHistoryRef.current = false; + setDraftChatMode(true); + selectActiveSessionId(null); + setEvents([]); await refreshState(); } catch (err) { heartbeatRef.current?.stop(); @@ -556,10 +1295,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const event = envelope.event as Record; if (event.type === "status" && event.turnStatus === "started") setStreaming(true); if (event.type === "done" || (event.type === "status" && event.turnStatus === "completed")) setStreaming(false); - if (Date.now() - lastLocalSendAtRef.current > 4_000) { - setDesktopDriving(true); - setTimeout(() => setDesktopDriving(false), 3_000); - } }); }, [clearedAt, connection, refreshState]); @@ -573,21 +1308,69 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return () => clearInterval(timer); }, [connection, refreshState]); + useEffect(() => { + if (!connection || mode === "attached" || forceEmbedded) return; + const timer = setInterval(() => { + if (streaming || attachProbeInFlightRef.current) return; + attachProbeInFlightRef.current = true; + void (async () => { + let attached: AdeCodeConnection | null = null; + try { + attached = await connectToAde({ + project, + forceEmbedded: false, + requireSocket: true, + socketPath, + }); + if (attached.mode !== "attached") { + await attached.close().catch(() => {}); + return; + } + const previous = connectionRef.current; + connectionRef.current = attached; + setConnection(attached); + setMode(attached.mode); + await previous?.close().catch(() => {}); + await refreshState(); + } catch { + await attached?.close().catch(() => {}); + } finally { + attachProbeInFlightRef.current = false; + } + })(); + }, 3_000); + return () => clearInterval(timer); + }, [connection, forceEmbedded, mode, project, refreshState, socketPath, streaming]); + const ensureActiveSession = useCallback(async (): Promise => { const conn = connectionRef.current; const laneId = activeLaneIdRef.current; if (!conn || !laneId) return null; if (activeSessionIdRef.current) return activeSessionIdRef.current; + const normalized = { ...modelState, ...applyProviderPermissionMode(modelState) }; const created = await createChatSession({ connection: conn, laneId, - modelId: modelState.modelId, - reasoningEffort: modelState.reasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, + provider: normalized.provider, + modelId: normalized.modelId, + reasoningEffort: normalized.reasoningEffort, + codexFastMode: normalized.codexFastMode, + permissionMode: normalized.permissionMode, + interactionMode: normalized.interactionMode, + claudePermissionMode: normalized.claudePermissionMode, + codexApprovalPolicy: normalized.codexApprovalPolicy, + codexSandbox: normalized.codexSandbox, + codexConfigSource: normalized.codexConfigSource, + opencodePermissionMode: normalized.opencodePermissionMode, + droidPermissionMode: normalized.droidPermissionMode, + cursorModeId: normalized.cursorModeId, + cursorConfigValues: normalized.cursorConfigValues, }); + setDraftChatMode(false); selectActiveSessionId(created.id); await refreshState(); return created.id; - }, [modelState.modelId, modelState.reasoningEffort, refreshState, selectActiveSessionId]); + }, [modelState, refreshState, selectActiveSessionId, setDraftChatMode]); const resolvePendingApproval = useCallback(async ( approval: PendingApproval, @@ -642,7 +1425,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (!conn) return; const laneId = activeLaneIdRef.current; const sessionId = activeSessionIdRef.current; - setRightOpen(true); + focusDetails(); if (name === "/help") { setRightPane({ kind: "help", title: "Help" }); @@ -656,8 +1439,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } ["workspace", project.workspaceRoot], ["lane", activeLane?.name ?? laneId ?? "none"], ["chat", activeSession?.title ?? activeSession?.sessionId ?? "none"], - ["runtime", mode], - ["socket", conn.socketPath ?? "embedded"], + ["ADE", "ready"], ], }); return; @@ -667,48 +1449,31 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); return; } - if (!args) { - openForm({ - kind: "form", - title: "New chat", - command: "new-chat", - fields: [ - { name: "title", label: "Title", placeholder: "Untitled chat" }, - { name: "message", label: "First message", placeholder: "Optional" }, - ], - }); - return; + if (args) { + chatDraftRef.current = args; } - const created = await createChatSession({ - connection: conn, - laneId, - title: args, - modelId: modelState.modelId, - reasoningEffort: modelState.reasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, - }); - selectActiveSessionId(created.id); - addNotice(`Created chat "${args}".`, "success"); - await refreshState(); + openNewChatSetup(); return; } if (name === "/new lane") { if (!args) { - openForm({ - kind: "form", - title: "New lane", - command: "new-lane", - fields: [ - { name: "name", label: "Name", required: true, placeholder: "feature-name" }, - { name: "baseBranch", label: "Base branch", placeholder: "default" }, - ], - }); + openNewLaneForm(); return; } const created = await conn.action("lane", "create", { name: args }); selectActiveLaneId(created.id); selectActiveSessionId(null); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerChatId(null); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatAction(null); + setDrawerSection("lanes"); setRightPane({ kind: "details", title: "New lane", body: renderObject(created, 20) }); await refreshState(); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerLaneAction(null); return; } if (name === "/rename") { @@ -965,10 +1730,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); return; } - setRightSelectionIndex(Math.max(0, models.findIndex((model) => ( - model.id === modelState.modelId || model.modelId === modelState.modelId - )))); - setRightPane({ kind: "models", models, activeModelId: modelState.modelId }); + openModelSetup(); return; } if (name === "/effort") { @@ -995,7 +1757,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "System", - body: renderObject({ mode, project, socketPath: conn.socketPath, pid: process.pid }, 24), + body: renderObject({ project, pid: process.pid }, 24), }); return; } @@ -1036,7 +1798,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : result; setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); } - }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, lanes, mode, modelState.modelId, modelState.reasoningEffort, models, openForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions]); + }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions]); const runInlineCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -1063,6 +1825,43 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await refreshState(); return; } + if (name === "/login") { + const provider = normalizeProvider(activeSession?.provider ?? modelState.provider); + const loginCommands = loginCommandsForProvider(provider); + if (!loginCommands.length) { + addNotice(`/login is not available for ${providerLabel(provider)}. ${loginUnavailableHint(provider)}`, "error"); + return; + } + let selectedLogin: ProviderLoginCommand | null = null; + let code: number | null = null; + let ranLogin = false; + for (const login of loginCommands) { + selectedLogin = login; + addNotice(`Starting \`${login.label}\` in this terminal.`, "info"); + try { + code = await runInteractiveTerminalCommand(login.command, login.args, project.projectRoot); + ranLogin = true; + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + throw error; + } + } + if (!selectedLogin || !ranLogin) { + addNotice(`Could not find a ${providerLabel(provider)} login command on PATH.`, "error"); + return; + } + if (code === 0) { + addNotice(`${providerLabel(provider)} auth completed. Refreshing provider status.`, "success"); + await refreshAiSetupStatus({ force: true }); + await loadProviderModels(provider, { applyDefault: false }); + } else { + addNotice(`${providerLabel(provider)} login exited with code ${code ?? "unknown"}.`, "error"); + } + return; + } if (name === "/commit") { if (!laneId) { addNotice("No active lane is selected.", "error"); @@ -1136,7 +1935,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setConnection(attached); setMode(attached.mode); await previous?.close().catch(() => {}); - addNotice("Attached to desktop and opened this context.", "success"); + addNotice("Opened ADE desktop at this context.", "success"); await refreshState(); return; } @@ -1144,7 +1943,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [addNotice, exit, project, refreshState, socketPath]); + }, [activeSession?.provider, addNotice, exit, loadProviderModels, modelState.provider, project, refreshAiSetupStatus, refreshState, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -1162,28 +1961,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return null; }; - if (form.command === "new-chat") { - if (!laneId) return; - const title = values.title?.trim() || null; - const message = values.message?.trim() ?? ""; - const created = await createChatSession({ - connection: conn, - laneId, - title, - modelId: modelState.modelId, - reasoningEffort: modelState.reasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, - }); - selectActiveSessionId(created.id); - if (message) { - await sendChatMessage(conn, created.id, message); - } - setRightOpen(false); - setRightPane({ kind: "empty" }); - addNotice(title ? `Created chat "${title}".` : "Created chat.", "success"); - await refreshState(); - return; - } - if (form.command === "new-lane") { const name = requireField("name", "Name"); if (!name) return; @@ -1194,10 +1971,20 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); selectActiveLaneId(created.id); selectActiveSessionId(null); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerChatId(null); + setSelectedDrawerLaneAction(null); + setSelectedDrawerChatAction(null); + setDrawerSection("lanes"); setRightOpen(false); setRightPane({ kind: "empty" }); + focusAfterDetails(); addNotice(`Created lane ${created.name}.`, "success"); await refreshState(); + setDrawerLaneId(created.id); + setSelectedDrawerLaneId(created.id); + setSelectedDrawerLaneAction(null); return; } @@ -1208,6 +1995,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } await renameChat(conn, sessionId, title); setRightOpen(false); setRightPane({ kind: "empty" }); + focusAfterDetails(); addNotice(`Renamed chat to "${title}".`, "success"); await refreshState(); return; @@ -1228,7 +2016,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice("Created draft PR.", "success"); await refreshState(); } - }, [addNotice, modelState.modelId, modelState.reasoningEffort, refreshState, selectActiveLaneId, selectActiveSessionId]); + }, [addNotice, focusAfterDetails, refreshState, selectActiveLaneId, selectActiveSessionId]); const submitPrompt = useCallback(async (value: string) => { const text = value.trim(); @@ -1236,11 +2024,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const conn = connectionRef.current; if (!conn) return; try { - if (desktopDriving && streaming && !text.startsWith("/") && rightPane.kind !== "form") { - addNotice("Desktop is driving this chat; draft kept locally until the stream settles.", "info"); + if (streaming && !text.startsWith("/") && rightPane.kind !== "form") { + addNotice("This chat is still responding. Press ctrl-c to interrupt before sending another message.", "info"); return; } setPrompt(""); + promptRef.current = ""; + if (activePaneRef.current === "chat") { + chatDraftRef.current = ""; + } setError(null); if (pendingApproval?.mode === "approval") { const lowered = text.toLowerCase(); @@ -1285,6 +2077,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const sessionId = await ensureActiveSession(); if (sessionId) { + setStreaming(true); await sendChatMessage(conn, sessionId, selected.name); await refreshState(); } @@ -1322,14 +2115,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const attachments: AgentChatFileRef[] = selectedMentions .filter((mention) => mention.kind === "file" && mention.filePath && text.includes(mention.insertText)) .map((mention) => ({ type: "file", path: mention.filePath! })); + setStreaming(true); await sendChatMessage(conn, sessionId, text, attachments); await refreshState(); } catch (err) { const message = err instanceof Error ? err.message : String(err); + setStreaming(false); setError(message); addNotice(message, "error"); } - }, [activeFormField, addNotice, answerPendingInput, desktopDriving, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); + }, [activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); const insertMention = useCallback((suggestion: MentionSuggestion) => { const range = activeMention(prompt); @@ -1349,23 +2144,315 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setPrompt(`${selected.name}${selected.argumentHint ? " " : ""}`); }, [slashIndex, slashRows]); + const applyModelState = useCallback((updater: (prev: AdeCodeModelState) => AdeCodeModelState) => { + setModelState((prev) => { + const next = updater(prev); + void commitModelStateToSession(next).catch((err) => { + addNotice(err instanceof Error ? err.message : String(err), "error"); + }); + return next; + }); + }, [addNotice, commitModelStateToSession]); + + const selectProvider = useCallback(async (provider: AdeCodeProvider) => { + const conn = connectionRef.current; + const nextModels = conn ? await getAvailableModels(conn, provider).catch(() => []) : []; + setModels(nextModels); + const model = nextModels.find((entry) => entry.isDefault) ?? nextModels[0] ?? null; + applyModelState((prev) => ({ + ...prev, + ...(model ? modelStatePatchForModel(provider, model) : fallbackModelStatePatch(provider)), + })); + }, [applyModelState]); + + const cycleProvider = useCallback((delta: number) => { + const index = Math.max(0, PROVIDER_OPTIONS.findIndex((entry) => entry.value === modelState.provider)); + const next = PROVIDER_OPTIONS[(index + delta + PROVIDER_OPTIONS.length) % PROVIDER_OPTIONS.length]?.value ?? "codex"; + void selectProvider(next).catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + }, [addNotice, modelState.provider, selectProvider]); + + const cycleModel = useCallback((delta: number) => { + const candidates = models.length + ? models + : listModelDescriptorsForProvider(modelState.provider).map((descriptor) => ({ + id: descriptor.id, + modelId: descriptor.id, + displayName: descriptor.displayName, + isDefault: descriptor.id === getDefaultModelDescriptor(modelState.provider)?.id, + reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + })); + if (!candidates.length) return; + const index = Math.max(0, candidates.findIndex((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId)); + const nextModel = candidates[(index + delta + candidates.length) % candidates.length] ?? candidates[0]!; + applyModelState((prev) => ({ + ...prev, + ...modelStatePatchForModel(modelState.provider, nextModel), + codexFastMode: modelSupportsFastMode(getModelById(nextModel.modelId ?? nextModel.id)) ? prev.codexFastMode : false, + })); + }, [applyModelState, modelState.modelId, modelState.provider, models]); + + const cycleReasoning = useCallback((delta: number) => { + const efforts = modelReasoningEfforts(modelState, models); + if (!efforts.length) return; + const index = Math.max(0, efforts.findIndex((effort) => effort === modelState.reasoningEffort)); + const nextEffort = efforts[(index + delta + efforts.length) % efforts.length] ?? efforts[0]!; + applyModelState((prev) => ({ ...prev, reasoningEffort: nextEffort })); + }, [applyModelState, modelState, models]); + + const cyclePermission = useCallback((delta: number) => { + if (modelState.provider === "codex") { + const current = resolveCodexPreset(modelState); + const index = Math.max(0, CODEX_PRESETS.findIndex((entry) => entry === current)); + const next = CODEX_PRESETS[(index + delta + CODEX_PRESETS.length) % CODEX_PRESETS.length] ?? "default"; + applyModelState((prev) => ({ ...prev, ...codexPresetPatch(next) })); + return; + } + if (modelState.provider === "claude") { + const current = modelState.interactionMode === "plan" ? "plan" : modelState.claudePermissionMode; + const index = Math.max(0, CLAUDE_PERMISSION_OPTIONS.findIndex((entry) => entry === current)); + const next = CLAUDE_PERMISSION_OPTIONS[(index + delta + CLAUDE_PERMISSION_OPTIONS.length) % CLAUDE_PERMISSION_OPTIONS.length] ?? "default"; + applyModelState((prev) => ({ + ...prev, + interactionMode: next === "plan" ? "plan" : "default", + claudePermissionMode: next, + permissionMode: next === "plan" + ? "plan" + : next === "acceptEdits" + ? "edit" + : next === "bypassPermissions" + ? "full-auto" + : "default", + })); + return; + } + if (modelState.provider === "opencode") { + const index = Math.max(0, OPENCODE_PERMISSION_OPTIONS.findIndex((entry) => entry === modelState.opencodePermissionMode)); + const next = OPENCODE_PERMISSION_OPTIONS[(index + delta + OPENCODE_PERMISSION_OPTIONS.length) % OPENCODE_PERMISSION_OPTIONS.length] ?? "edit"; + applyModelState((prev) => ({ ...prev, opencodePermissionMode: next, permissionMode: next })); + return; + } + if (modelState.provider === "droid") { + const index = Math.max(0, DROID_PERMISSION_OPTIONS.findIndex((entry) => entry === modelState.droidPermissionMode)); + const next = DROID_PERMISSION_OPTIONS[(index + delta + DROID_PERMISSION_OPTIONS.length) % DROID_PERMISSION_OPTIONS.length] ?? "auto-low"; + applyModelState((prev) => ({ ...prev, droidPermissionMode: next, permissionMode: droidPermissionToLegacy(next) })); + return; + } + const index = Math.max(0, CURSOR_AVAILABLE_MODE_IDS.findIndex((entry) => entry === modelState.cursorModeId)); + const next = CURSOR_AVAILABLE_MODE_IDS[(index + delta + CURSOR_AVAILABLE_MODE_IDS.length) % CURSOR_AVAILABLE_MODE_IDS.length] ?? "agent"; + applyModelState((prev) => ({ + ...prev, + cursorModeId: next, + permissionMode: next === "plan" + ? "plan" + : next === "ask" + ? "edit" + : next === "full-auto" + ? "full-auto" + : "default", + })); + }, [applyModelState, modelState]); + + const handleSetupRow = useCallback((row: SetupPaneRow, direction = 1) => { + const conn = connectionRef.current; + if (row.disabled) return; + if (row.kind === "provider") { + cycleProvider(direction); + return; + } + if (row.kind === "model") { + cycleModel(direction); + return; + } + if (row.kind === "reasoning") { + cycleReasoning(direction); + return; + } + if (row.kind === "permission") { + cyclePermission(direction); + return; + } + if (row.kind === "codex-fast") { + applyModelState((prev) => ({ ...prev, codexFastMode: !prev.codexFastMode })); + return; + } + if (row.kind === "refresh-status") { + void refreshAiSetupStatus({ force: true }) + .then(() => addNotice("AI provider status refreshed.", "success")) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + if (row.kind === "open-settings") { + if (!conn) return; + void navigateDesktop(conn, { source: "ade-code", target: { kind: "route", route: SETTINGS_AI_ROUTE } }) + .then((result) => { + addNotice(result.ok ? "Opened ADE Settings > AI Providers." : result.message ?? "Desktop settings are unavailable.", result.ok ? "success" : "error"); + }) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + if (row.kind === "apply") { + setRightOpen(false); + setRightPane({ kind: "empty" }); + focusChat(); + addNotice(`New chat ready in ${activeLane?.name ?? activeLaneIdRef.current ?? "current lane"}.`, "success"); + } + }, [activeLane?.name, addNotice, applyModelState, cycleModel, cyclePermission, cycleProvider, cycleReasoning, focusChat, refreshAiSetupStatus]); + useInput((input, key) => { + const pane = activePaneRef.current; + const detailsFormActive = pane === "details" && rightOpen && rightPane.kind === "form"; + const footerActive = footerControlRef.current != null; + const textInputActive = (pane === "chat" && !footerActive) || detailsFormActive; + const currentFormValues = (): Record => { + if (rightPane.kind !== "form") return formValues; + const currentField = rightPane.fields[formFieldIndex] ?? rightPane.fields[0]; + return currentField ? { ...formValues, [currentField.name]: prompt } : formValues; + }; + const formHasChanges = (values: Record): boolean => { + if (rightPane.kind !== "form") return false; + return rightPane.fields.some((field) => (values[field.name] ?? "") !== (field.initialValue ?? "")); + }; + + if (key.tab && key.shift) { + cyclePaneFocus(); + return; + } + + if (key.ctrl && input === "o") { + focusDrawer(); + return; + } + + if (key.ctrl && input === "p") { + focusDetails(); + return; + } + + if (footerActive) { + if (key.leftArrow || key.rightArrow) { + selectFooterControl(footerControlRef.current === "drawer" ? "details" : "drawer"); + return; + } + if (key.upArrow || key.escape) { + selectFooterControl(null); + return; + } + if (key.return) { + if (footerControlRef.current === "drawer") { + toggleDrawerPane(); + } else { + toggleDetailsPane(); + } + return; + } + if (key.backspace || key.delete) { + selectFooterControl(null); + handlePromptChange(prompt.slice(0, -1)); + return; + } + if (!key.ctrl && input) { + const suffix = printableInput(input); + if (suffix) { + selectFooterControl(null); + handlePromptChange(`${prompt}${suffix}`); + } + return; + } + } + + if (key.escape) { + if (pane === "details" && rightOpen) { + if (rightPane.kind === "form") { + const values = currentFormValues(); + if (formHasChanges(values) && !formDiscardArmed) { + setFormValues(values); + setFormDiscardArmed(true); + addNotice("Press Esc again to discard this form.", "info"); + return; + } + setFormDiscardArmed(false); + setFormValues({}); + setFormFieldIndex(0); + setPrompt(""); + setRightPane({ kind: "empty" }); + } + setRightOpen(false); + focusAfterDetails(); + return; + } + if (pane === "drawer") { + setDrawerOpen(false); + focusChat(); + return; + } + setPrompt(""); + return; + } + + if (key.ctrl && input === "c") { + const conn = connectionRef.current; + const sessionId = activeSessionIdRef.current; + if (streaming && conn && sessionId) { + void interruptChat(conn, sessionId) + .then(() => addNotice("Interrupted chat.", "info")) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + return; + } + exit(); + return; + } + if (pendingApproval?.mode === "approval" && !pendingApproval.highStakes && (input === "a" || input === "d")) { void resolvePendingApproval(pendingApproval, input === "a" ? "accept" : "decline") .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } - if (rightPane.kind === "form" && key.tab) { + + if (pane === "details" && rightOpen && rightPane.kind === "form" && (key.upArrow || key.downArrow || key.return)) { const fields = rightPane.fields; - const currentField = fields[formFieldIndex] ?? fields[0]; - const nextValues = currentField ? { ...formValues, [currentField.name]: prompt } : formValues; - const nextIndex = fields.length ? (formFieldIndex + 1) % fields.length : 0; + const nextValues = currentFormValues(); + if (key.return) { + if (prompt.trim().startsWith("/")) { + void submitPrompt(prompt); + } else { + setFormDiscardArmed(false); + void submitRightForm(rightPane, nextValues) + .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); + } + return; + } + const delta = key.upArrow ? -1 : 1; + const nextIndex = fields.length ? (formFieldIndex + delta + fields.length) % fields.length : 0; setFormValues(nextValues); setFormFieldIndex(nextIndex); setPrompt(fields[nextIndex] ? nextValues[fields[nextIndex]!.name] ?? "" : ""); return; } - if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.upArrow) { + + if ( + pane === "details" + && rightOpen + && (rightPane.kind === "new-chat-setup" || rightPane.kind === "model-setup") + && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return) + ) { + const rows = rightPane.rows; + const providerRowCount = rightPane.kind === "model-setup" ? rightPane.providerRows.length : 0; + const totalRows = rows.length + providerRowCount; + if (key.upArrow || key.downArrow) { + const delta = key.upArrow ? -1 : 1; + setRightSelectionIndex((index) => totalRows ? (index + delta + totalRows) % totalRows : 0); + return; + } + if (rightSelectionIndex >= rows.length) { + return; + } + const row = rows[rightSelectionIndex] ?? rows[0]; + if (!row) return; + handleSetupRow(row, key.leftArrow ? -1 : 1); + return; + } + + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.upArrow) { const max = rightPane.kind === "models" ? rightPane.models.length : rightPane.kind === "effort" @@ -1374,7 +2461,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex((index) => (index <= 0 ? Math.max(0, max - 1) : index - 1)); return; } - if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.downArrow) { + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.downArrow) { const max = rightPane.kind === "models" ? rightPane.models.length : rightPane.kind === "effort" @@ -1383,7 +2470,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightSelectionIndex((index) => (max > 0 ? (index + 1) % max : 0)); return; } - if (rightOpen && rightPane.kind === "list" && rightPane.action && key.return) { + if (pane === "details" && rightOpen && rightPane.kind === "list" && rightPane.action && key.return) { const selectedId = rightPane.action.ids[rightSelectionIndex] ?? rightPane.action.ids[0] ?? null; if (!selectedId) return; if (rightPane.action.kind === "switch-lane") { @@ -1408,7 +2495,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Switched to chat ${session.title ?? session.sessionId}.`, "success"); return; } - if (rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort") && key.return) { const conn = connectionRef.current; const sessionId = activeSessionIdRef.current; if (!conn) { @@ -1451,107 +2538,110 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); return; } - if (key.upArrow && activeMentionRange && mentionSuggestions.length) { + + if (pane === "chat" && key.upArrow && activeMentionRange && mentionSuggestions.length) { setMentionIndex((index) => (index <= 0 ? mentionSuggestions.length - 1 : index - 1)); return; } - if (key.downArrow && activeMentionRange && mentionSuggestions.length) { + if (pane === "chat" && key.downArrow && activeMentionRange && mentionSuggestions.length) { setMentionIndex((index) => (index + 1) % mentionSuggestions.length); return; } - if (key.tab && activeMentionRange && mentionSuggestions.length) { + if (pane === "chat" && key.tab && activeMentionRange && mentionSuggestions.length) { insertMention(mentionSuggestions[mentionIndex] ?? mentionSuggestions[0]!); return; } - if (key.upArrow && slashRows.length) { + if (pane === "chat" && key.upArrow && slashRows.length) { setSlashIndex((index) => (index <= 0 ? slashRows.length - 1 : index - 1)); return; } - if (key.downArrow && slashRows.length) { + if (pane === "chat" && key.downArrow && slashRows.length) { setSlashIndex((index) => (index + 1) % slashRows.length); return; } - if (key.tab && slashRows.length) { + if (pane === "chat" && key.tab && slashRows.length) { insertSlashCommand(); return; } - if (drawerOpen && key.tab) { + if (pane === "chat" && key.downArrow && !activeMentionRange && !slashRows.length) { + selectFooterControl(footerControlRef.current ?? "drawer"); + setPaneFocus("chat"); + return; + } + + if (pane === "drawer" && drawerOpen && key.tab) { setDrawerSection((section) => section === "lanes" ? "chats" : "lanes"); return; } - if (drawerOpen && key.upArrow) { + if (pane === "drawer" && drawerOpen && key.upArrow) { if (drawerSection === "lanes") { const nextIndex = Math.max(0, selectedLaneIndex - 1); - setSelectedDrawerLaneId(drawerLaneRows[nextIndex]?.id ?? null); + const lane = drawerLaneRows[nextIndex] ?? null; + setSelectedDrawerLaneAction(lane ? null : "new-lane"); + setSelectedDrawerLaneId(lane?.id ?? null); } else { const nextIndex = Math.max(0, selectedChatIndex - 1); - setSelectedDrawerChatId(drawerLaneSessions[nextIndex]?.sessionId ?? null); + const session = drawerLaneSessions[nextIndex] ?? null; + setSelectedDrawerChatAction(session ? null : "new-chat"); + setSelectedDrawerChatId(session?.sessionId ?? null); } return; } - if (drawerOpen && key.downArrow) { + if (pane === "drawer" && drawerOpen && key.downArrow) { if (drawerSection === "lanes") { - const nextIndex = Math.min(Math.max(0, drawerLaneRows.length - 1), selectedLaneIndex + 1); - setSelectedDrawerLaneId(drawerLaneRows[nextIndex]?.id ?? null); + const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); + const lane = drawerLaneRows[nextIndex] ?? null; + setSelectedDrawerLaneAction(lane ? null : "new-lane"); + setSelectedDrawerLaneId(lane?.id ?? null); } else { - const nextIndex = Math.min(Math.max(0, drawerLaneSessions.length - 1), selectedChatIndex + 1); - setSelectedDrawerChatId(drawerLaneSessions[nextIndex]?.sessionId ?? null); + const nextIndex = Math.min(drawerLaneSessions.length, selectedChatIndex + 1); + const session = drawerLaneSessions[nextIndex] ?? null; + setSelectedDrawerChatAction(session ? null : "new-chat"); + setSelectedDrawerChatId(session?.sessionId ?? null); } return; } - if (drawerOpen && key.return) { + if (pane === "drawer" && drawerOpen && key.return) { if (drawerSection === "lanes") { + if (selectedDrawerLaneAction === "new-lane" || selectedLaneIndex >= drawerLaneRows.length) { + openNewLaneForm(); + setRightOpen(true); + return; + } const lane = drawerLaneRows[selectedLaneIndex]; if (lane) { selectActiveLaneId(lane.id); setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); + setSelectedDrawerLaneAction(null); const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); + setSelectedDrawerChatAction(null); setDrawerSection(session ? "chats" : "lanes"); addNotice(`Switched to lane ${lane.name}.`, "success"); } } else { + if (selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerLaneSessions.length) { + openNewChatSetup(); + setRightOpen(true); + return; + } const session = drawerLaneSessions[selectedChatIndex]; if (session) { selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); setSelectedDrawerLaneId(session.laneId); + setSelectedDrawerLaneAction(null); selectActiveSessionId(session.sessionId); setSelectedDrawerChatId(session.sessionId); + setSelectedDrawerChatAction(null); } } return; } - if (key.ctrl && input === "b") { - setDrawerOpen((value) => !value); - return; - } - if (key.ctrl && input === "j") { - setRightOpen((value) => !value); - return; - } - if (key.escape) { - if (desktopDriving) setDesktopDriving(false); - else if (rightOpen) setRightOpen(false); - else if (drawerOpen) setDrawerOpen(false); - else setPrompt(""); - return; - } - if (key.ctrl && input === "c") { - const conn = connectionRef.current; - const sessionId = activeSessionIdRef.current; - if (streaming && conn && sessionId) { - void interruptChat(conn, sessionId) - .then(() => addNotice("Interrupted chat.", "info")) - .catch((err) => addNotice(err instanceof Error ? err.message : String(err), "error")); - return; - } - exit(); - return; - } - if (key.return && !prompt.trim() && latestFailedLineId && !pendingApproval && rightPane.kind !== "form" && !slashRows.length) { + + if (pane === "chat" && key.return && !prompt.trim() && latestFailedLineId && !pendingApproval && rightPane.kind !== "form" && !slashRows.length) { setExpandedLineIds((prev) => { const next = new Set(prev); if (next.has(latestFailedLineId)) next.delete(latestFailedLineId); @@ -1561,48 +2651,50 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } const linePrefix = inputBeforeLineBreak(input); - if (key.return || linePrefix != null) { + if (textInputActive && (key.return || linePrefix != null)) { const suffix = linePrefix == null ? "" : printableInput(linePrefix); void submitPrompt(`${prompt}${suffix}`); return; } - if (key.backspace || key.delete) { + if (textInputActive && (key.backspace || key.delete)) { handlePromptChange(prompt.slice(0, -1)); return; } - if (!key.ctrl && input) { + if (textInputActive && !key.ctrl && input) { const suffix = printableInput(input); if (suffix) handlePromptChange(`${prompt}${suffix}`); } }); const handlePromptChange = useCallback((value: string) => { - if (value === "?") { + setFormDiscardArmed(false); + if (activePaneRef.current === "chat" && value === "?") { setRightPane({ kind: "help", title: "Help" }); - setRightOpen(true); + focusDetails(); setPrompt(""); return; } - if (rightPane.kind === "form" && activeFormField) { + if (activePaneRef.current === "chat") { + chatDraftRef.current = value; + } + if (activePaneRef.current === "details" && rightPane.kind === "form" && activeFormField) { setFormValues((prev) => ({ ...prev, [activeFormField.name]: value })); } setPrompt(value); - }, [activeFormField, rightPane]); + }, [activeFormField, focusDetails, rightPane]); const centerWidth = Math.max(40, columns - (drawerOpen ? 30 : 0) - (rightOpen ? 40 : 0)); const laneName = activeLane?.name ?? "main"; - const chromeRows = 5 - + (desktopDriving ? 1 : 0) - + (streaming ? 1 : 0) - + (contextPercent != null ? 1 : 0) - + (pendingApproval && !pendingApproval.highStakes ? 3 : 0) - + (error ? 1 : 0); - const chatMaxRows = Math.max(4, rows - chromeRows); + const promptFocused = (activePane === "chat" && footerControl == null) || (activePane === "details" && rightPane.kind === "form"); + const drawerFooterSelected = footerControl === "drawer"; + const detailsFooterSelected = footerControl === "details"; + const statusRows = streaming ? 1 : 0; + const chatRowBudget = Math.max(4, rows - 12 - statusRows); if (error && !connection) { return ( - ade code failed to start + ade-code failed to start {error} ); @@ -1613,22 +2705,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath }
- {desktopDriving ? ( - Desktop is driving this chat; transcript is syncing here. - ) : null} {streaming ? ( ● streaming live{tokenSummary ? ` · ${tokenSummary}` : ""} · ctrl-c interrupts ) : null} - {contextPercent != null ? ( - - context {contextPercent}% {"█".repeat(Math.max(1, Math.round(contextPercent / 10))).padEnd(10, "░")} - {tokenSummary && !streaming ? ` · ${tokenSummary}` : ""} - - ) : null} {drawerOpen ? ( ) : null} @@ -1652,8 +2733,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } activeSession={activeSession} projectName={projectName} laneName={laneName} + lane={activeLane} expandedLineIds={expandedLineIds} - maxRows={chatMaxRows} + maxRows={chatRowBudget} /> @@ -1665,20 +2747,35 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } formValues={formValues} activeFormField={formFieldIndex} selectedIndex={rightSelectionIndex} + focused={activePane === "details"} /> ) : null} {error ? {error} : null} - + {prompt} - - [ {drawerOpen ? "▴" : "▾"} lanes & chats ^b ] [ {rightOpen ? "◂" : "▸"} right pane ^j ] / commands - + + ); } diff --git a/apps/ade-cli/src/tuiClient/cli.tsx b/apps/ade-cli/src/tuiClient/cli.tsx index 1444f93c..5087c2ac 100644 --- a/apps/ade-cli/src/tuiClient/cli.tsx +++ b/apps/ade-cli/src/tuiClient/cli.tsx @@ -48,8 +48,10 @@ Usage: ade code --print-state Keys: - ctrl-b toggle lanes and chats - ctrl-j toggle right pane + ctrl-o open or focus lanes and chats + ctrl-p open or focus details + shift-tab cycle pane focus + esc return or cancel the active pane ? help when it is the first and only prompt character / command palette `); diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 37281592..4d33df99 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -14,6 +14,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/push", description: "Push the active lane branch", placement: "inline" }, { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, { name: "/end", description: "End the active chat runtime", placement: "inline" }, + { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline" }, { name: "/open", description: "Open this ADE context in desktop", placement: "inline" }, { name: "/quit", description: "Exit ade code", placement: "inline" }, { name: "/remember", description: "Write durable ADE memory", placement: "inline", argumentHint: "" }, @@ -50,6 +51,12 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/ade", description: "Run an ADE action or force a TUI command", placement: "right", argumentHint: " [json]" }, ]; +const ADE_OWNED_SINGLE_WORD_COMMANDS = new Set( + BUILTIN_COMMANDS + .filter((command) => command.placement === "inline" && !command.name.includes(" ")) + .map((command) => command.name), +); + export type ParsedCommand = { name: string; args: string; @@ -83,6 +90,18 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ } const exactUserCommand = userCommands.find((command) => command.name === first) ?? null; + const adeOwnedSingleWordCommand = candidates.find((command) => + command.name === first && ADE_OWNED_SINGLE_WORD_COMMANDS.has(command.name) + ); + if (adeOwnedSingleWordCommand) { + return { + name: first, + args: trimmed.slice(first.length).trim(), + spec: adeOwnedSingleWordCommand, + userCommand: null, + }; + } + if (exactUserCommand) { return { name: first, @@ -127,6 +146,7 @@ export function paletteCommands( userCommands: AgentChatSlashCommand[] = [], ): Array<{ name: string; description: string; source: "ade" | "user"; argumentHint?: string }> { const normalizedQuery = query.trim().toLowerCase(); + const queryToken = normalizedQuery.replace(/^\//, ""); const builtins = BUILTIN_COMMANDS.map((command) => ({ name: command.name, description: command.description, @@ -139,12 +159,27 @@ export function paletteCommands( source: "user" as const, argumentHint: command.argumentHint, })); - return [...builtins, ...users] - .filter((command) => { - if (!normalizedQuery || normalizedQuery === "/") return true; - return `${command.name} ${command.description}`.toLowerCase().includes(normalizedQuery.replace(/^\//, "")); - }) - .slice(0, 9); + // Dedupe by name: when both ADE and a runtime/user catalog define the same + // command, prefer the runtime/user entry so SDK-native behavior wins. + const byName = new Map(); + for (const command of builtins) byName.set(command.name, command); + for (const command of users) byName.set(command.name, command); + const merged = [...byName.values()]; + const filtered = !queryToken + ? merged + : merged.filter((command) => `${command.name} ${command.description}`.toLowerCase().includes(queryToken)); + // Rank: name-prefix matches first, then name-substring, then description matches, then alphabetical. + filtered.sort((a, b) => { + if (queryToken) { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + const aPrefix = aName.startsWith(`/${queryToken}`) ? 0 : aName.includes(queryToken) ? 1 : 2; + const bPrefix = bName.startsWith(`/${queryToken}`) ? 0 : bName.includes(queryToken) ? 1 : 2; + if (aPrefix !== bPrefix) return aPrefix - bPrefix; + } + return a.name.localeCompare(b.name); + }); + return filtered.slice(0, 30); } export function commandPlacement(command: ParsedCommand): CommandPlacement { diff --git a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx new file mode 100644 index 00000000..195f57fe --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; + +const ROWS = [ + "█▀█ █▀▄ █▀▀", + "█▀█ █ █ █▀ ", + "▀ ▀ ▀▀▀ ▀▀▀", +]; + +const SHADOW = " ▒▒ ▒▒ ▒▒"; + +export function AdeWordmark() { + return ( + + {ROWS.map((row, index) => ( + + {row} + + ))} + + {SHADOW} + + + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 5e16bcef..a1e240df 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1,108 +1,219 @@ import React from "react"; import { Box, Text } from "ink"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; +import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { LocalNotice } from "../types"; import { renderChatLines, type RenderedChatLine } from "../format"; +import { theme } from "../theme"; +import { AdeWordmark } from "./AdeWordmark"; +import { laneIconGlyph } from "./Header"; -const COLORS = { - user: "#A78BFA", - assistant: "white", - tool: "cyan", - error: "red", - notice: "gray", - reasoning: "gray", - approval: "yellow", -} as const; +function estimatedRows(line: RenderedChatLine): number { + const bodyRows = Math.max(1, line.body.split(/\r?\n/).length); + let rows = bodyRows + (line.header ? 1 : 0); + if (line.tone === "user") rows += 2; // round border adds 2 rows + return rows; +} + +function fitToRows(lines: RenderedChatLine[], maxRows?: number): RenderedChatLine[] { + if (!maxRows || maxRows <= 0) return lines; + const visible: RenderedChatLine[] = []; + let rows = 0; + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]!; + const nextRows = estimatedRows(line); + if (visible.length && rows + nextRows > maxRows) break; + visible.unshift(line); + rows += nextRows; + } + return visible; +} + +const HERO_INNER_WIDTH = 48; +const HERO_DIVIDER = "─".repeat(HERO_INNER_WIDTH); + +function HeroDivider() { + return {HERO_DIVIDER}; +} + +function HeroSuggestion({ command, label }: { command: string; label: string }) { + return ( + + + {command} + {label ? ( + <> + + {label} + + ) : null} + + ); +} export function BootHero({ projectName, laneName, + lane, }: { projectName: string; laneName: string; + lane?: LaneSummary | null; }) { + const laneColor = theme.lane(lane ?? null); + const laneGlyph = laneIconGlyph(lane?.icon ?? null); + const showProject = projectName.trim() && projectName.trim().toLowerCase() !== "ade"; return ( - - ██▄ ██▄ ██▀ - █ █ █ █ █▀ - ██▀ ██▀ ██▄ - code · v0.1 - {projectName} · {laneName} - type to chat · / for commands - try: inspect the current diff - try: @file then ask for a focused review - try: /status or /new chat + + + + + + + ade code + · v0.1 + + + {laneGlyph} {laneName} + {lane?.branchRef ? ( + ⎇ {lane.branchRef} + ) : null} + {!lane?.branchRef && showProject ? ( + {projectName} + ) : null} + + + + + type to chat + + + / + commands + @ + files + ? + help + + + + + + + + + + ); } -function clipBodyToRows(body: string, rows: number): string { - if (rows <= 0) return ""; - const lines = body.split(/\r?\n/); - if (lines.length <= rows) return body; - return lines.slice(-rows).join("\n"); -} +type ChatLineProps = { + line: RenderedChatLine; + prevTone: RenderedChatLine["tone"] | null; +}; -function rowCount(line: RenderedChatLine): number { - return (line.header ? 1 : 0) + Math.max(1, line.body.split(/\r?\n/).length); -} +function ChatLineComponent({ line, prevTone }: ChatLineProps) { + const isChatTurn = line.tone === "user" || line.tone === "assistant"; + const speakerChanged = prevTone !== line.tone; + const showSpacer = isChatTurn && speakerChanged && prevTone !== null; -function visibleRows(lines: RenderedChatLine[], maxRows: number): RenderedChatLine[] { - if (maxRows <= 0) return []; - const visible: RenderedChatLine[] = []; - let remaining = maxRows; - for (let index = lines.length - 1; index >= 0 && remaining > 0; index -= 1) { - const line = lines[index]!; - const needed = rowCount(line); - if (needed <= remaining) { - visible.unshift(line); - remaining -= needed; - continue; - } - const headerRows = line.header ? 1 : 0; - const bodyRows = Math.max(0, remaining - headerRows); - if (bodyRows > 0) { - visible.unshift({ - ...line, - body: clipBodyToRows(line.body, bodyRows), - }); - } - break; + if (line.tone === "user") { + return ( + + {showSpacer ? : null} + {line.header ? ( + + {line.header} + + ) : null} + + + {line.body} + + + + ); } - return visible; + + if (line.tone === "tool" || line.tone === "error") { + const isErrorTone = line.tone === "error"; + const lines = line.body.split(/\r?\n/); + return ( + + {lines.map((text, index) => ( + + {text} + + ))} + + ); + } + + if (line.tone === "reasoning" || line.tone === "notice" || line.tone === "approval") { + return ( + + {line.header ? {line.header} : null} + {line.body} + + ); + } + + // assistant + return ( + + {showSpacer ? : null} + {line.header ? ( + {line.header} + ) : null} + {line.body} + + ); } +const ChatLine = React.memo(ChatLineComponent, (prev, next) => ( + prev.line === next.line && prev.prevTone === next.prevTone +)); + export function ChatView({ events, notices, activeSession, projectName, laneName, + lane, expandedLineIds, - maxLines = 64, - maxRows = 24, + maxRows, }: { events: AgentChatEventEnvelope[]; notices: LocalNotice[]; activeSession: AgentChatSessionSummary | null; projectName: string; laneName: string; + lane?: LaneSummary | null; expandedLineIds?: Set; - maxLines?: number; maxRows?: number; }) { - const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines }); + const lines = fitToRows(renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 80 }), maxRows); if (!lines.length) { - return ; + return ; } - const clippedLines = visibleRows(lines, maxRows); return ( - {clippedLines.map((line) => ( - - {line.header ? {line.header} : null} - {line.body} - + {lines.map((line, index) => ( + 0 ? lines[index - 1]!.tone : null} /> ))} ); diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index 7982ae51..345266df 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -7,18 +7,6 @@ import { formatLaneLabel, formatSessionLabel } from "../format"; const PURPLE = "#A78BFA"; const AMBER = "#F59E0B"; -function laneColor(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string | undefined { - if (laneId === activeLaneId) return AMBER; - if (laneId === browsingLaneId) return "white"; - return undefined; -} - -function laneMarker(laneId: string, activeLaneId: string | null, browsingLaneId: string | null): string { - if (laneId === activeLaneId) return "●"; - if (laneId === browsingLaneId) return "◐"; - return "○"; -} - export function Drawer({ lanes, sessions, @@ -27,6 +15,7 @@ export function Drawer({ browsingLaneId, selectedLaneIndex, selectedChatIndex, + focused = false, }: { lanes: LaneSummary[]; sessions: AgentChatSessionSummary[]; @@ -35,18 +24,22 @@ export function Drawer({ browsingLaneId: string | null; selectedLaneIndex: number; selectedChatIndex: number; + focused?: boolean; }) { const browsingLane = lanes.find((lane) => lane.id === browsingLaneId) ?? null; const laneSessions = sessions.filter((session) => session.laneId === browsingLaneId).slice(0, 12); + const laneRows = lanes.slice(0, 10); return ( - - LANES - {lanes.slice(0, 10).map((lane, index) => ( - - {index === selectedLaneIndex ? "›" : " "} {laneMarker(lane.id, activeLaneId, browsingLaneId)} {formatLaneLabel(lane).slice(0, 20)} + + LANES{focused ? " · focused" : ""} + {laneRows.map((lane, index) => ( + + {index === selectedLaneIndex ? "›" : " "} {lane.id === activeLaneId ? "●" : lane.id === browsingLaneId ? "◐" : "○"} {formatLaneLabel(lane).slice(0, 20)} ))} - + new lane + + {selectedLaneIndex === laneRows.length ? "›" : " "} + new lane + {"─".repeat(24)} CHATS · {browsingLane?.name ?? "no lane"} {laneSessions.length === 0 ? ( @@ -56,8 +49,10 @@ export function Drawer({ {index === selectedChatIndex ? "›" : " "} {session.sessionId === activeSessionId ? "●" : " "} {formatSessionLabel(session).slice(0, 20)} ))} - + new chat - enter opens selected · arrows move + + {selectedChatIndex === laneSessions.length ? "›" : " "} + new chat + + enter switches · + opens details ); } diff --git a/apps/ade-cli/src/tuiClient/components/FooterControls.tsx b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx new file mode 100644 index 00000000..a7f6d503 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/FooterControls.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { Box, Text } from "ink"; +import { theme } from "../theme"; + +function Toggle({ + label, + hint, + open, + focused, +}: { + label: string; + hint: string; + open: boolean; + focused: boolean; +}) { + const arrow = open ? "▾" : "▸"; + if (focused) { + return ( + + {` ${arrow} ${label} ${hint} `} + + ); + } + return ( + + {`[${arrow} ${label} ${hint}]`} + + ); +} + +function Hint({ keyLabel, action }: { keyLabel: string; action: string }) { + return ( + <> + {keyLabel} + {` ${action}`} + + ); +} + +export function FooterControls({ + drawerOpen, + rightOpen, + drawerFocused, + detailsFocused, + footerControlActive, +}: { + drawerOpen: boolean; + rightOpen: boolean; + drawerFocused: boolean; + detailsFocused: boolean; + footerControlActive: boolean; +}) { + return ( + + + + + + + + {footerControlActive ? ( + ↵ toggle ← → choose ↑ exit + ) : ( + <> + + + + + + + + + )} + + + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/Header.tsx b/apps/ade-cli/src/tuiClient/components/Header.tsx index b2f8407a..6318653b 100644 --- a/apps/ade-cli/src/tuiClient/components/Header.tsx +++ b/apps/ade-cli/src/tuiClient/components/Header.tsx @@ -1,41 +1,56 @@ import React from "react"; import { Box, Text } from "ink"; -import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; -import type { AdeCodeModelState, RuntimeMode } from "../types"; +import type { LaneIcon, LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import { formatLaneLabel } from "../format"; +import { theme } from "../theme"; -const PURPLE = "#A78BFA"; -const AMBER = "#F59E0B"; +const LANE_ICON_GLYPH: Record, string> = { + star: "★", + flag: "⚑", + bolt: "↯", + shield: "▣", + tag: "❯", +}; -export function Header({ - projectName, - lane, - model, - mode, - tuiCount, -}: { - projectName: string; - lane: LaneSummary | null; - model: AdeCodeModelState; - mode: RuntimeMode | "connecting"; - tuiCount: number; -}) { - let modeColor: string = "gray"; - if (mode === "attached") modeColor = "green"; - else if (mode === "embedded") modeColor = "yellow"; - const modelLabel = model.reasoningEffort ? `${model.displayName} ${model.reasoningEffort}` : model.displayName; +export function laneIconGlyph(icon: LaneIcon | null | undefined): string { + if (!icon) return "▎"; + return LANE_ICON_GLYPH[icon] ?? "▎"; +} + +export function Header({ projectName, lane }: { projectName: string; lane: LaneSummary | null }) { + const laneColor = theme.lane(lane); + const showProject = projectName.trim() && projectName.trim().toLowerCase() !== "ade"; return ( - - ▌ ADE - - {projectName} - - {formatLaneLabel(lane)} - - {modelLabel} - - {mode} - {` · ⏵ ${tuiCount} tui${tuiCount === 1 ? "" : "s"}`} + + + {" ADE "} + {showProject ? ( + <> + {" "} + {projectName} + + ) : null} + {lane ? ( + <> + {" "} + {laneIconGlyph(lane.icon)} {formatLaneLabel(lane)} + + ) : null} + {lane?.branchRef ? ( + <> + {" "} + ⎇ {lane.branchRef} + + ) : null} + ); } diff --git a/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx new file mode 100644 index 00000000..5affc2a4 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/components/ModelStatus.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Box, Text } from "ink"; +import type { AdeCodeProvider } from "../types"; +import { theme } from "../theme"; + +const BAR_CELLS = 10; + +function meterColor(percent: number): string { + if (percent >= 95) return theme.color.danger; + if (percent >= 80) return theme.color.warning; + return theme.color.accent; +} + +function ContextMeter({ percent, summary }: { percent: number; summary: string | null }) { + const filled = Math.max(0, Math.min(BAR_CELLS, Math.round((percent / 100) * BAR_CELLS))); + const empty = BAR_CELLS - filled; + const color = meterColor(percent); + return ( + + {percent}% + {"█".repeat(filled)} + {"░".repeat(empty)} + {summary ? {` · ${summary}`} : null} + + ); +} + +export function ModelStatus({ + provider, + displayName, + reasoningEffort, + permissionLabel, + fastMode, + draftChatActive, + contextPercent, + tokenSummary, +}: { + provider: AdeCodeProvider; + displayName: string; + reasoningEffort: string | null; + permissionLabel: string; + fastMode?: boolean; + draftChatActive?: boolean; + contextPercent?: number | null; + tokenSummary?: string | null; +}) { + const brand = theme.provider(provider); + return ( + + + {brand.glyph} {brand.label} + · + {displayName} + · + {reasoningEffort ?? "no reasoning"} + · + {permissionLabel} + {fastMode ? ( + <> + · + fast + + ) : null} + {draftChatActive ? ( + <> + · + next chat + + ) : null} + + {contextPercent != null ? ( + + ) : null} + + ); +} diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 8b3918d0..3f339e64 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -1,13 +1,32 @@ import React from "react"; import { Box, Text } from "ink"; -import type { RightPaneContent } from "../types"; +import type { ProviderReadinessRow, RightPaneContent } from "../types"; +import { theme } from "../theme"; + +const STATUS_DOT: Record = { + ready: "●", + unknown: "◐", + unavailable: "○", +}; + +function statusColor(status: ProviderReadinessRow["status"]): string { + if (status === "ready") return theme.color.success; + if (status === "unknown") return theme.color.warning; + return theme.color.mutedFg; +} + +function tailTruncate(value: string, max: number): string { + if (value.length <= max) return value; + return `…${value.slice(value.length - (max - 1))}`; +} function HelpPane() { return ( Help - ctrl-b toggles lanes and chats - ctrl-j toggles this pane + ctrl-o opens or focuses lanes and chats + ctrl-p opens or focuses setup + shift-tab cycles pane focus esc closes the active side pane ctrl-c interrupts a running chat; press again to quit / opens commands, @ opens references, tab inserts selected @@ -21,14 +40,17 @@ export function RightPane({ formValues = {}, activeFormField = 0, selectedIndex = 0, + focused = false, }: { content: RightPaneContent; formValues?: Record; activeFormField?: number; selectedIndex?: number; + focused?: boolean; }) { return ( - + + SETUP{focused ? " · focused" : ""} {content.kind === "empty" ? ( Run /status, /diff, /model, or /help. ) : null} @@ -91,6 +113,120 @@ export function RightPane({ arrows move · enter applies ) : null} + {content.kind === "new-chat-setup" ? ( + + New chat + Lane: {content.laneLabel} + + {content.rows.map((row, index) => ( + + + {index === selectedIndex ? "›" : " "} {row.label}: {row.value} + + {index === selectedIndex && row.detail ? {row.detail} : null} + + ))} + + up/down rows · left/right change · enter activates + + ) : null} + {content.kind === "model-setup" ? ( + + + MODEL + + {content.rows.filter((row) => row.cyclable === true).map((row) => { + const index = content.rows.indexOf(row); + const selected = index === selectedIndex; + const labelColor = selected ? theme.color.accent : row.disabled ? "gray" : undefined; + const isProviderRow = row.kind === "provider"; + const valueColor = isProviderRow + ? theme.provider(content.activeProvider).color + : row.disabled + ? "gray" + : undefined; + const rightHint = row.disabled ? null : "‹ ›"; + const cursorGlyph = selected ? "›" : " "; + const paddedLabel = row.label.padEnd(12, " "); + return ( + + + + {cursorGlyph} {paddedLabel} + + {isProviderRow ? `${theme.provider(content.activeProvider).glyph} ` : ""}{row.value} + + + {rightHint ? ( + + {rightHint} + + ) : null} + + {selected && row.detail ? {row.detail} : null} + + ); + })} + + {content.rows.filter((row) => row.cyclable !== true).map((row) => { + const index = content.rows.indexOf(row); + const selected = index === selectedIndex; + const glyph = row.kind === "refresh-status" ? "↻" : row.kind === "open-settings" ? "↗" : "→"; + const labelColor = selected ? theme.color.accent : row.disabled ? "gray" : undefined; + const valueColor = row.disabled ? "gray" : theme.color.mutedFg; + const cursorGlyph = selected ? "›" : " "; + const showRunValue = row.kind !== "refresh-status"; + return ( + + + + {cursorGlyph} {glyph} {row.label} + {showRunValue ? {row.value} : null} + + {row.disabled ? null : ( + + )} + + {selected && row.detail ? {row.detail} : null} + + ); + })} + + + PROVIDERS + {content.providerRows.map((row, providerIdx) => { + const absoluteIndex = content.rows.length + providerIdx; + const providerSelected = absoluteIndex === selectedIndex; + const brand = theme.provider(row.provider); + const isActive = row.provider === content.activeProvider; + const cursorGlyph = providerSelected ? "›" : " "; + return ( + + + + {cursorGlyph} + {brand.glyph} {row.label} + {isActive ? active : null} + + {STATUS_DOT[row.status]} + + {providerSelected ? ( + + {row.modelCount} models + {row.status === "ready" ? tailTruncate(row.detail, 30) : row.detail} + + ) : null} + + ); + })} + + + + ↑↓ ←→ enter{content.checkedAt ? ` · ${content.checkedAt.slice(11, 19)}` : ""} + + + + ) : null} {content.kind === "form" ? ( {content.title} @@ -103,7 +239,7 @@ export function RightPane({ ); })} - tab moves fields · enter submits · / runs a command + arrows move fields · enter submits · esc cancels ) : null} diff --git a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx index 9e2dbad5..9f757f5f 100644 --- a/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx +++ b/apps/ade-cli/src/tuiClient/components/SlashPalette.tsx @@ -3,6 +3,8 @@ import { Box, Text } from "ink"; import type { AgentChatSlashCommand } from "../../../../desktop/src/shared/types/chat"; import { paletteCommands } from "../commands"; +const VISIBLE_ROWS = 9; + export function SlashPalette({ query, userCommands, @@ -13,17 +15,32 @@ export function SlashPalette({ selectedIndex: number; }) { const rows = paletteCommands(query, userCommands); - if (!query.startsWith("/")) return null; + if (!query.startsWith("/") || !rows.length) return null; + const total = rows.length; + const safeIndex = Math.max(0, Math.min(selectedIndex, total - 1)); + const half = Math.floor(VISIBLE_ROWS / 2); + let start = Math.max(0, safeIndex - half); + let end = Math.min(total, start + VISIBLE_ROWS); + start = Math.max(0, end - VISIBLE_ROWS); + const window = rows.slice(start, end); + const aboveCount = start; + const belowCount = total - end; return ( - {rows.map((row, index) => ( - - {index === selectedIndex ? "›" : " "} - {row.source} - {row.name.padEnd(16)} - {row.description} - - ))} + {aboveCount ? ↑ {aboveCount} more : null} + {window.map((row, index) => { + const absoluteIndex = start + index; + const selected = absoluteIndex === safeIndex; + return ( + + {selected ? "›" : " "} + {row.source} + {row.name.padEnd(16)} + {row.description} + + ); + })} + {belowCount ? ↓ {belowCount} more : null} ); } diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 27bbe58a..e7580e60 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -1,5 +1,6 @@ import path from "node:path"; -import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; +import { getModelById } from "../../../desktop/src/shared/modelRegistry"; +import type { AgentChatEventEnvelope, AgentChatProvider, AgentChatSessionSummary } from "../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; import type { LocalNotice } from "./types"; @@ -51,6 +52,10 @@ export type RenderedChatLine = { body: string; }; +type TimelineEntry = + | { kind: "notice"; timestamp: string; index: number; notice: LocalNotice } + | { kind: "event"; timestamp: string; index: number; envelope: AgentChatEventEnvelope }; + export function chatEventLineId(envelope: AgentChatEventEnvelope, index = 0): string { return `${envelope.sequence ?? index}:${envelope.event.type}:${envelope.timestamp}`; } @@ -63,6 +68,28 @@ function isFailedExpandableEvent(envelope: AgentChatEventEnvelope): boolean { return false; } +function providerEventLabel(provider: AgentChatProvider | null | undefined): string { + if (provider === "claude") return "Claude"; + if (provider === "codex") return "Codex"; + if (provider === "opencode") return "OpenCode"; + if (provider === "cursor") return "Cursor"; + if (provider === "droid") return "Droid"; + return "ADE"; +} + +function stripTerminalCodes(value: string): string { + return value + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\[[0-9;]*m\]?/g, "") + .trim(); +} + +function sessionModelLabel(session: AgentChatSessionSummary | null): string { + const descriptor = session?.modelId ? getModelById(session.modelId) : undefined; + if (descriptor) return descriptor.displayName; + return stripTerminalCodes(session?.model ?? "") || "model"; +} + function multiLine(value: unknown, maxLines = 18): string { if (typeof value === "string") return value.split(/\r?\n/).slice(0, maxLines).join("\n"); return renderObject(value, maxLines); @@ -84,15 +111,42 @@ export function renderChatLines(args: { maxLines?: number; }): RenderedChatLine[] { const lines: RenderedChatLine[] = []; - for (const notice of args.notices) { - lines.push({ - id: notice.id, - tone: notice.tone === "error" ? "error" : "notice", - header: `- ade code · ${timeLabel(notice.timestamp)} ${"-".repeat(20)}`, - body: notice.text, - }); - } - for (const [index, envelope] of args.events.entries()) { + const timeline: TimelineEntry[] = [ + ...args.events.map((envelope, index): TimelineEntry => ({ + kind: "event", + timestamp: envelope.timestamp, + index, + envelope, + })), + ...args.notices.map((notice, index): TimelineEntry => ({ + kind: "notice", + timestamp: notice.timestamp, + index, + notice, + })), + ].sort((a, b) => { + const aTime = new Date(a.timestamp).getTime(); + const bTime = new Date(b.timestamp).getTime(); + const safeATime = Number.isNaN(aTime) ? 0 : aTime; + const safeBTime = Number.isNaN(bTime) ? 0 : bTime; + if (safeATime !== safeBTime) return safeATime - safeBTime; + if (a.kind !== b.kind) return a.kind === "event" ? -1 : 1; + return a.index - b.index; + }); + + for (const entry of timeline) { + if (entry.kind === "notice") { + const notice = entry.notice; + lines.push({ + id: notice.id, + tone: notice.tone === "error" ? "error" : "notice", + header: `ADE Code · ${timeLabel(notice.timestamp)}`, + body: notice.text, + }); + continue; + } + + const { envelope, index } = entry; const event = envelope.event; const id = chatEventLineId(envelope, index); const expanded = args.expandedLineIds?.has(id) ?? false; @@ -100,7 +154,7 @@ export function renderChatLines(args: { lines.push({ id, tone: "user", - header: `- you · ${timeLabel(envelope.timestamp)} ${"-".repeat(32)}`, + header: `you · ${timeLabel(envelope.timestamp)}`, body: event.displayText ?? event.text, }); continue; @@ -109,7 +163,7 @@ export function renderChatLines(args: { lines.push({ id, tone: "assistant", - header: `- ade · ${timeLabel(envelope.timestamp)} · ${args.activeSession?.model ?? "model"} ${"-".repeat(18)}`, + header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)} · ${sessionModelLabel(args.activeSession)}`, body: event.text, }); continue; @@ -178,7 +232,7 @@ export function renderChatLines(args: { lines.push({ id, tone: "notice", - body: `- context compacted · ${event.trigger}${preTokens} ${"-".repeat(24)}`, + body: `context compacted · ${event.trigger}${preTokens}`, }); continue; } @@ -186,11 +240,44 @@ export function renderChatLines(args: { lines.push({ id, tone: "notice", + header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)}`, body: singleLine((event as { message?: unknown }).message, 160), }); } } - return lines.slice(-(args.maxLines ?? 80)); + return coalesceLines(lines).slice(-(args.maxLines ?? 80)); +} + +function headerSpeakerKey(header: string | undefined): string { + if (!header) return ""; + const first = header.split("·")[0]; + return first ? first.trim() : ""; +} + +function smartConcat(prev: string, next: string): string { + if (!prev) return next; + if (!next) return prev; + if (/\s$/.test(prev) || /^\s/.test(next)) return `${prev}${next}`; + if (/\n$/.test(prev) || /^\n/.test(next)) return `${prev}${next}`; + return `${prev} ${next}`; +} + +function coalesceLines(lines: RenderedChatLine[]): RenderedChatLine[] { + const out: RenderedChatLine[] = []; + for (const line of lines) { + const last = out[out.length - 1]; + if ( + last + && line.tone === "assistant" + && last.tone === "assistant" + && headerSpeakerKey(line.header) === headerSpeakerKey(last.header) + ) { + out[out.length - 1] = { ...last, body: smartConcat(last.body, line.body) }; + continue; + } + out.push(line); + } + return out; } export function formatLaneLabel(lane: LaneSummary | null): string { @@ -202,9 +289,7 @@ export function formatLaneLabel(lane: LaneSummary | null): string { export function formatSessionLabel(session: AgentChatSessionSummary): string { const label = (session.title ?? session.goal ?? session.summary ?? session.sessionId).trim(); - let state = ""; - if (session.awaitingInput) state = " ?"; - else if (session.status === "active") state = " ●"; + const state = session.awaitingInput ? " ?" : session.status === "active" ? " ●" : ""; return `${label}${state}`; } @@ -220,9 +305,7 @@ export function renderObject(value: unknown, maxLines = 24): string { export function summarizeDiffChanges(value: unknown): Array<{ path: string; additions?: number; deletions?: number; body?: string }> { const record = value && typeof value === "object" ? value as Record : {}; - let files: unknown[] = []; - if (Array.isArray(record.files)) files = record.files; - else if (Array.isArray(record.changes)) files = record.changes; + const files = Array.isArray(record.files) ? record.files : Array.isArray(record.changes) ? record.changes : []; return files .map((entry) => { const item = entry && typeof entry === "object" ? entry as Record : {}; diff --git a/apps/ade-cli/src/tuiClient/theme.ts b/apps/ade-cli/src/tuiClient/theme.ts new file mode 100644 index 00000000..ec622727 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/theme.ts @@ -0,0 +1,78 @@ +import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { AdeCodeProvider } from "./types"; +import type { RenderedChatLine } from "./format"; + +/** + * Centralised design tokens for the ade-code TUI. + * + * Mirrors the ADE desktop renderer where it matters: accent #A78BFA (purple), + * lane.color for lane chips, and per-provider brand colors and glyphs that map + * the SVG marks used in the desktop ProviderLogos to single-cell BMP glyphs + * safe for Ink's string-width handling. + */ + +const ACCENT = "#A78BFA"; +const ACCENT_DIM = "#6D5DBF"; +const FG = "white"; +const MUTED_FG = "gray"; +const SUCCESS = "#22C55E"; +const WARNING = "#F59E0B"; +const DANGER = "#EF4444"; +const TOOL = "cyan"; +const REASONING = "gray"; +const NOTICE = "gray"; +const APPROVAL = "#F59E0B"; +const ERROR = DANGER; + +export type Tone = RenderedChatLine["tone"]; + +const TONE_COLORS: Record = { + user: ACCENT, + assistant: FG, + tool: TOOL, + error: ERROR, + notice: NOTICE, + reasoning: REASONING, + approval: APPROVAL, +}; + +type ProviderTheme = { + glyph: string; + color: string; + label: string; +}; + +const PROVIDER_THEME: Record = { + claude: { glyph: "◆", color: "#D97757", label: "Claude" }, + codex: { glyph: "◇", color: "#10A37F", label: "Codex" }, + cursor: { glyph: "▲", color: FG, label: "Cursor" }, + droid: { glyph: "▣", color: "#22D3EE", label: "Droid" }, + opencode: { glyph: "◈", color: ACCENT, label: "OpenCode" }, +}; + +const FALLBACK_PROVIDER: ProviderTheme = { glyph: "•", color: MUTED_FG, label: "Agent" }; + +export const theme = { + color: { + accent: ACCENT, + accentDim: ACCENT_DIM, + fg: FG, + mutedFg: MUTED_FG, + border: MUTED_FG, + borderFocused: ACCENT, + success: SUCCESS, + warning: WARNING, + danger: DANGER, + tool: TOOL, + }, + tone(tone: Tone): string { + return TONE_COLORS[tone] ?? FG; + }, + provider(provider: AdeCodeProvider | null | undefined): ProviderTheme { + if (!provider) return FALLBACK_PROVIDER; + return PROVIDER_THEME[provider] ?? FALLBACK_PROVIDER; + }, + lane(lane: LaneSummary | null | undefined): string { + return lane?.color || ACCENT; + }, +} as const; diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index e51a7c59..71dbf99a 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -1,7 +1,17 @@ import type { AppNavigationRequest, AppNavigationResult } from "../../../desktop/src/shared/types/core"; import type { + AgentChatClaudePermissionMode, + AgentChatCodexApprovalPolicy, + AgentChatCodexConfigSource, + AgentChatCodexSandbox, + AgentChatCursorConfigValue, + AgentChatDroidPermissionMode, AgentChatEventEnvelope, + AgentChatInteractionMode, AgentChatModelInfo, + AgentChatOpenCodePermissionMode, + AgentChatPermissionMode, + AgentChatProvider, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, @@ -36,6 +46,7 @@ export type AdeCodeConnection = { projectRoot: string; workspaceRoot: string; socketPath: string | null; + fallbackReason?: string | null; request(method: string, params?: unknown): Promise; tool(name: string, args?: Record): Promise; action(domain: string, action: string, args?: Record): Promise; @@ -44,12 +55,52 @@ export type AdeCodeConnection = { close(): Promise; }; +export type AdeCodeProvider = Extract; + export type AdeCodeModelState = { - provider: "codex" | "claude" | "opencode" | "cursor" | "droid"; + provider: AdeCodeProvider; model: string; modelId: string | null; displayName: string; reasoningEffort: string | null; + codexFastMode: boolean; + permissionMode: AgentChatPermissionMode; + interactionMode: AgentChatInteractionMode; + claudePermissionMode: AgentChatClaudePermissionMode; + codexApprovalPolicy: AgentChatCodexApprovalPolicy; + codexSandbox: AgentChatCodexSandbox; + codexConfigSource: AgentChatCodexConfigSource; + opencodePermissionMode: AgentChatOpenCodePermissionMode; + droidPermissionMode: AgentChatDroidPermissionMode; + cursorModeId: string | null; + cursorConfigValues: Record; +}; + +export type ProviderReadinessRow = { + provider: AdeCodeProvider; + label: string; + status: "ready" | "unavailable" | "unknown"; + detail: string; + modelCount: number; +}; + +export type SetupPaneRowKind = + | "provider" + | "model" + | "reasoning" + | "permission" + | "codex-fast" + | "refresh-status" + | "open-settings" + | "apply"; + +export type SetupPaneRow = { + kind: SetupPaneRowKind; + label: string; + value: string; + detail?: string; + disabled?: boolean; + cyclable?: boolean; }; export type RightPaneContent = @@ -70,10 +121,24 @@ export type RightPaneContent = | { kind: "diff"; title: string; files: Array<{ path: string; additions?: number; deletions?: number; body?: string }> } | { kind: "models"; models: AgentChatModelInfo[]; activeModelId: string | null } | { kind: "effort"; efforts: string[]; activeEffort: string | null } + | { + kind: "new-chat-setup"; + laneId: string; + laneLabel: string; + rows: SetupPaneRow[]; + } + | { + kind: "model-setup"; + rows: SetupPaneRow[]; + providerRows: ProviderReadinessRow[]; + activeProvider: AdeCodeProvider; + checkedAt: string | null; + desktopAttached: boolean; + } | { kind: "form"; title: string; - command: "new-chat" | "new-lane" | "rename" | "pr-open"; + command: "new-lane" | "rename" | "pr-open"; fields: Array<{ name: string; label: string; @@ -119,9 +184,7 @@ export type ShellData = { models: AgentChatModelInfo[]; modelState: AdeCodeModelState; rightPane: RightPaneContent; - tuiCount: number; contextPercent: number | null; - desktopDriving: boolean; streaming: boolean; }; diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 5ee0ed15..b8db308d 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -596,7 +596,7 @@ describe("runtime AI actions", () => { } as unknown as Parameters[0]; const aiService = getAdeActionDomainServices(runtime).ai as Record; - for (const action of ["getStatus", "storeApiKey", "deleteApiKey", "listApiKeys"]) { + for (const action of ["getStatus", "getOpenCodeRuntimeDiagnostics", "storeApiKey", "deleteApiKey", "listApiKeys"]) { expect(aiService[action]).toEqual(expect.any(Function)); expect(listAllowedAdeActionNames("ai", aiService)).toContain(action); } diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index a4844626..83499bb3 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -441,6 +441,7 @@ export const ADE_ACTION_ALLOWLIST: Partial buildAiSettingsStatus(aiIntegrationService, args), + getOpenCodeRuntimeDiagnostics: async () => { + const { getOpenCodeRuntimeSnapshot } = await import("../opencode/openCodeRuntime"); + return getOpenCodeRuntimeSnapshot(); + }, verifyApiKeyConnection: (args?: { provider?: string }) => aiIntegrationService.verifyApiKeyConnection(requireNonEmptyString(args?.provider, "provider")), storeApiKey: (args?: { provider?: string; key?: string }) => diff --git a/apps/desktop/src/main/services/ai/aiSettingsStatus.ts b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts new file mode 100644 index 00000000..62ec2b46 --- /dev/null +++ b/apps/desktop/src/main/services/ai/aiSettingsStatus.ts @@ -0,0 +1,138 @@ +import type { + AiFeatureKey, + AiSettingsStatus, +} from "../../../shared/types"; + +export const AI_USAGE_FEATURE_KEYS: AiFeatureKey[] = [ + "narratives", + "conflict_proposals", + "commit_messages", + "pr_descriptions", + "terminal_summaries", + "memory_consolidation", + "mission_planning", + "orchestrator", + "initial_context", +]; + +type AiSettingsStatusSource = { + getStatus(args?: { force?: boolean; refreshOpenCodeInventory?: boolean }): Promise & Partial>>; + getDailyUsageBatch(features: AiFeatureKey[]): Map; + getFeatureFlag(feature: AiFeatureKey): boolean; + getDailyBudgetLimit(feature: AiFeatureKey): number | null; +}; + +export function isDatabaseClosedError(error: unknown): boolean { + return error instanceof Error && /database closed/i.test(error.message); +} + +export function getUnavailableAiStatus(): AiSettingsStatus { + return { + mode: "guest", + availableProviders: { + claude: false, + codex: false, + cursor: false, + droid: false, + }, + models: { + claude: [], + codex: [], + cursor: [], + droid: [], + }, + detectedAuth: [], + providerConnections: { + claude: { + provider: "claude", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + codex: { + provider: "codex", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + cursor: { + provider: "cursor", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + droid: { + provider: "droid", + authAvailable: false, + runtimeDetected: false, + runtimeAvailable: false, + usageAvailable: false, + path: null, + blocker: "AI integration service unavailable.", + lastCheckedAt: new Date(0).toISOString(), + sources: [], + }, + }, + features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ + feature, + enabled: false, + dailyUsage: 0, + dailyLimit: null, + })), + runtimeConnections: {}, + availableModelIds: [], + opencodeBinaryInstalled: false, + opencodeBinarySource: "missing", + opencodeInventoryError: null, + opencodeProviders: [], + }; +} + +export async function buildAiSettingsStatus( + service: AiSettingsStatusSource | null | undefined, + args?: { force?: boolean; refreshOpenCodeInventory?: boolean }, +): Promise { + if (!service) { + return getUnavailableAiStatus(); + } + const status = await service.getStatus({ + force: args?.force === true, + refreshOpenCodeInventory: args?.refreshOpenCodeInventory === true, + }); + const usageBatch = service.getDailyUsageBatch(AI_USAGE_FEATURE_KEYS); + return { + mode: status.mode, + availableProviders: status.availableProviders, + models: status.models, + detectedAuth: status.detectedAuth, + providerConnections: status.providerConnections, + runtimeConnections: status.runtimeConnections, + availableModelIds: status.availableModelIds, + opencodeBinaryInstalled: status.opencodeBinaryInstalled, + opencodeBinarySource: status.opencodeBinarySource, + opencodeInventoryError: status.opencodeInventoryError, + opencodeProviders: status.opencodeProviders, + apiKeyStore: status.apiKeyStore, + features: AI_USAGE_FEATURE_KEYS.map((feature) => ({ + feature, + enabled: service.getFeatureFlag(feature), + dailyUsage: usageBatch.get(feature) ?? 0, + dailyLimit: service.getDailyBudgetLimit(feature), + })), + }; +} diff --git a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts index 13870c0d..a25ae203 100644 --- a/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts +++ b/apps/desktop/src/main/services/ai/claudeRuntimeProbe.ts @@ -11,7 +11,7 @@ import { resolveClaudeCodeExecutable } from "./claudeCodeExecutable"; const PROBE_TIMEOUT_MS = 20_000; const PROBE_CACHE_TTL_MS = 30_000; export const CLAUDE_RUNTIME_AUTH_ERROR = - "Claude Code is detected, but ADE chat could not authenticate it. Run /login in chat or sign in with `claude auth login`, then refresh AI settings."; + "Claude Code is detected, but ADE chat could not authenticate it. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; const DEFAULT_RUNTIME_FAILURE = "Claude Code is installed, but ADE could not confirm that the Claude chat runtime can start from this app session."; diff --git a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts index b1c69124..67739983 100644 --- a/apps/desktop/src/main/services/ai/providerConnectionStatus.ts +++ b/apps/desktop/src/main/services/ai/providerConnectionStatus.ts @@ -152,7 +152,7 @@ export async function buildProviderConnections( cli: claudeCli, localCreds: claudeLocalCreds, label: "Claude", - loginHint: "claude auth login", + loginHint: "claude auth login or set ANTHROPIC_API_KEY", health: claudeRuntimeHealth, }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7ab7f319..004d1462 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3101,7 +3101,7 @@ describe("createAgentChatService", () => { expect(clearCmd!.source).toBe("local"); }); - it("includes /login command for claude sessions", async () => { + it("does not advertise /login as a Claude SDK command", async () => { const { service } = createService(); const session = await service.createSession({ laneId: "lane-1", @@ -3111,8 +3111,7 @@ describe("createAgentChatService", () => { const commands = service.getSlashCommands({ sessionId: session.id }); const loginCmd = commands.find((c: any) => c.name === "/login"); - expect(loginCmd).toBeDefined(); - expect(loginCmd!.source).toBe("sdk"); + expect(loginCmd).toBeUndefined(); }); it("includes project Claude Code command files before SDK init completes", async () => { @@ -3146,7 +3145,7 @@ describe("createAgentChatService", () => { ])); }); - it("keeps reserved local Claude commands ahead of filesystem commands", async () => { + it("does not let a filesystem /login command replace provider auth guidance", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); fs.writeFileSync(path.join(commandsDir, "login.md"), [ @@ -3167,10 +3166,7 @@ describe("createAgentChatService", () => { const commands = service.getSlashCommands({ sessionId: session.id }); const loginCmd = commands.find((c: any) => c.name === "/login"); - expect(loginCmd).toMatchObject({ - description: "Sign in to Claude Code for this chat runtime", - source: "sdk", - }); + expect(loginCmd).toBeUndefined(); }); it("does not include /login for opencode sessions", async () => { @@ -3260,6 +3256,38 @@ describe("createAgentChatService", () => { }); }); + it("does not forward Claude /login into the Agent SDK", async () => { + const send = vi.fn().mockResolvedValue(undefined); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-login-command", + slash_commands: ["/login"], + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-login-command", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await expect(service.sendMessage({ + sessionId: session.id, + text: "/login", + })).rejects.toThrow("/login is not an SDK-dispatchable command"); + expect(send).not.toHaveBeenCalledWith("/login"); + }); + it("expands project Claude command files before sending to the SDK", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ade7e5d2..6badd73c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -545,7 +545,6 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/hooks", description: "View hook configurations for tool events.", source: "sdk" }, { name: "/ide", description: "Manage IDE integrations and show status.", source: "sdk" }, { name: "/init", description: "Initialize project with a CLAUDE.md guide.", source: "sdk" }, - { name: "/login", description: "Sign in to Claude Code for this chat runtime", source: "sdk" }, { name: "/logout", description: "Sign out from Anthropic.", source: "sdk" }, { name: "/mcp", description: "Manage MCP server connections and OAuth authentication.", source: "sdk" }, { name: "/memory", description: "Edit CLAUDE.md memory files and memory settings.", source: "sdk" }, @@ -569,6 +568,11 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); +const CLAUDE_LOGIN_NOT_SDK_COMMAND = "ADE Claude chat is hosted through the Claude Agent SDK, and /login is not an SDK-dispatchable command. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; + +function isDispatchableClaudeSdkSlashCommand(command: { name: string }): boolean { + return command.name !== "/login"; +} type PendingOpenCodeApproval = { category: "bash" | "write"; @@ -11433,22 +11437,39 @@ export function createAgentChatService(args: { }; const projectSlashCommands = (() => { try { - return discoverClaudeSlashCommands(managed.laneWorktreePath); + return discoverClaudeSlashCommands(managed.laneWorktreePath).filter(isDispatchableClaudeSdkSlashCommand); } catch { return []; } })(); + const projectCommandFiles = projectSlashCommands.filter((cmd) => cmd.source === "command"); + const projectSkillFiles = projectSlashCommands.filter((cmd) => cmd.source === "skill"); const slashCommandsSection = projectSlashCommands.length ? [ "", - "## Project slash commands", - "The user can invoke custom slash commands defined in `.claude/commands/*.md` (project scope) and `~/.claude/commands/*.md` (user scope). When the user sends a message that is exactly `/` or `/ `, ADE auto-expands the command body into the message before it reaches you — so in that case you will already see the expanded instructions, not the literal `/`.", - "When the user references a command mid-sentence (e.g. \"please run /audit\", \"can you do a /security-review\") the message is not auto-expanded. In that case, read the matching file at `.claude/commands/.md` (prefer project scope; fall back to user scope) and follow its instructions as if the user had run it.", - "Available commands in this workspace:", - ...projectSlashCommands.map((cmd) => { - const desc = cmd.description.trim(); - return desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; - }), + "## Project slash commands and skills", + "ADE walks up from the lane worktree to discover `.claude/commands/*.md` (slash commands) and `.claude/skills//SKILL.md` (skills) at every ancestor directory plus `~/.claude/`. The Claude Agent SDK only auto-discovers `/.claude/` and `~/.claude/`, so ADE injects the rest here.", + "**User-invoked (`/`):** When the user sends a message that is exactly `/` or `/ `, ADE pre-expands the file's body (commands take precedence over same-named skills) and substitutes `$ARGUMENTS` before it reaches you. You'll see the expanded instructions, not the literal `/`.", + "**Mid-sentence reference:** When the user mentions a command/skill mid-sentence (e.g. \"please /audit this\", \"can you do a /security-review\") the message is NOT auto-expanded. Read the file at the path below and follow it.", + "**Autonomous skill use:** If, while working on a task, you decide a discovered skill applies (its description matches the situation), Read its SKILL.md file and follow it as if it had been invoked. Don't ask the user — just use the skill when warranted.", + ...(projectCommandFiles.length ? [ + "", + "Commands (file-backed prompts):", + ...projectCommandFiles.map((cmd) => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }), + ] : []), + ...(projectSkillFiles.length ? [ + "", + "Skills (autonomously usable when relevant):", + ...projectSkillFiles.map((cmd) => { + const desc = cmd.description.trim(); + const head = desc.length ? `- ${cmd.name} — ${desc}` : `- ${cmd.name}`; + return `${head}\n file: ${cmd.filePath}`; + }), + ] : []), ] : []; opts.systemPrompt = { @@ -12740,14 +12761,15 @@ export function createAgentChatService(args: { ); } }); - const allowClaudeLoginCommand = managed.session.provider === "claude" && slashCommand === "/login"; + if (managed.session.provider === "claude" && slashCommand === "/login") { + throw new Error(CLAUDE_LOGIN_NOT_SDK_COMMAND); + } const claudeRuntimeHealth = managed.session.provider === "claude" ? getProviderRuntimeHealth("claude") : null; if ( managed.session.provider === "claude" && claudeRuntimeHealth?.state === "auth-failed" - && !allowClaudeLoginCommand ) { throw new Error(claudeRuntimeHealth.message ?? CLAUDE_RUNTIME_AUTH_ERROR); } @@ -18395,18 +18417,22 @@ export function createAgentChatService(args: { // Claude SDK commands plus filesystem-backed Claude Code commands/skills. if (provider === "claude") { - const runtimeCommands: AgentChatSlashCommand[] = (managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); - const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath).map((cmd: { name: string; description: string; argumentHint?: string }) => ({ - name: cmd.name, - description: cmd.description, - argumentHint: cmd.argumentHint, - source: "sdk" as const, - })); + const runtimeCommands: AgentChatSlashCommand[] = (managed.runtime?.kind === "claude" ? managed.runtime.slashCommands : []) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + const projectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); return mergeSlashCommands([projectCommands, CLAUDE_BUILT_IN_SLASH_COMMANDS, runtimeCommands]); } diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 0ab3f7b6..7d8c28d3 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -33,7 +33,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/automate", description: "Generate comprehensive test suites", @@ -54,7 +54,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/frontend:test", description: "Run frontend tests", @@ -84,7 +84,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9:visible", description: "Visible nested command", @@ -117,7 +117,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/fix-issue", description: "Fix a GitHub issue", @@ -125,6 +125,47 @@ describe("discoverClaudeSlashCommands", () => { ]); }); + it("walks up parent directories to discover .claude/commands at workspace root from a lane subdir", () => { + const workspaceCommands = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(workspaceCommands, { recursive: true }); + fs.writeFileSync(path.join(workspaceCommands, "audit.md"), [ + "---", + "description: Workspace-root audit", + "---", + "", + "Audit.", + "", + ].join("\n")); + const laneWorktree = path.join(tmpRoot, "lanes", "feature-x", "worktree"); + fs.mkdirSync(laneWorktree, { recursive: true }); + + expect(discoverClaudeSlashCommands(laneWorktree)).toMatchObject([ + { + name: "/audit", + description: "Workspace-root audit", + source: "command", + }, + ]); + }); + + it("walks up parent directories for resolveClaudeSlashCommandInvocation as well", () => { + const workspaceCommands = path.join(tmpRoot, ".claude", "commands"); + fs.mkdirSync(workspaceCommands, { recursive: true }); + fs.writeFileSync(path.join(workspaceCommands, "audit.md"), [ + "---", + "description: Workspace-root audit", + "---", + "", + "Audit $ARGUMENTS.", + "", + ].join("\n")); + const laneWorktree = path.join(tmpRoot, "lanes", "feature-y", "worktree"); + fs.mkdirSync(laneWorktree, { recursive: true }); + + expect(resolveClaudeSlashCommandInvocation(laneWorktree, "/audit the model pane")?.promptText) + .toBe("Audit the model pane."); + }); + it("includes personal commands and lets project commands with the same name win", () => { fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); fs.mkdirSync(path.join(tmpRoot, ".claude", "commands"), { recursive: true }); @@ -145,7 +186,7 @@ describe("discoverClaudeSlashCommands", () => { "", ].join("\n")); - expect(discoverClaudeSlashCommands(tmpRoot)).toEqual([ + expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { name: "/ship", description: "Project ship", @@ -189,4 +230,77 @@ describe("resolveClaudeSlashCommandInvocation", () => { expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/help")).toBeNull(); expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/missing")).toBeNull(); }); + + it("falls back to a skill SKILL.md when no command file matches", () => { + const skillDir = path.join(tmpRoot, ".claude", "skills", "audit"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: audit", + "description: Audit recent work", + "---", + "", + "Audit the work for $ARGUMENTS.", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/audit slash menu")?.promptText) + .toBe("Audit the work for slash menu."); + }); + + it("prefers a command file over a same-named skill", () => { + const cmdDir = path.join(tmpRoot, ".claude", "commands"); + const skillDir = path.join(tmpRoot, ".claude", "skills", "ship"); + fs.mkdirSync(cmdDir, { recursive: true }); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(cmdDir, "ship.md"), "Command body $ARGUMENTS\n"); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: ship", + "description: Ship", + "---", + "", + "Skill body $ARGUMENTS", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/ship now")?.promptText) + .toBe("Command body now"); + }); + + it("walks up ancestors to resolve a skill at workspace root from a lane subdir", () => { + const skillDir = path.join(tmpRoot, ".claude", "skills", "audit"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: audit", + "description: Audit", + "---", + "", + "Run audit on $ARGUMENTS.", + "", + ].join("\n")); + const lane = path.join(tmpRoot, "lanes", "feat", "wt"); + fs.mkdirSync(lane, { recursive: true }); + + expect(resolveClaudeSlashCommandInvocation(lane, "/audit X")?.promptText) + .toBe("Run audit on X."); + }); + + it("ignores skills marked user-invocable: false", () => { + const skillDir = path.join(tmpRoot, ".claude", "skills", "internal"); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), [ + "---", + "name: internal", + "description: Internal", + "user-invocable: false", + "---", + "", + "Hidden body.", + "", + ].join("\n")); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/internal")).toBeNull(); + }); }); diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index 24bae7e4..81e61c4a 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -7,6 +7,8 @@ export type DiscoveredClaudeSlashCommand = { name: string; description: string; argumentHint?: string; + source: "command" | "skill"; + filePath: string; }; export type ResolvedClaudeSlashCommandInvocation = { @@ -109,6 +111,8 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma name, description: maybeString(frontmatter.description) ?? firstMarkdownParagraph(content), argumentHint: maybeArgumentHint(frontmatter["argument-hint"]) ?? maybeArgumentHint(frontmatter.argumentHint), + source: "command", + filePath: entryPath, }); } }; @@ -195,16 +199,39 @@ function discoverSkills(skillsDir: string): DiscoveredClaudeSlashCommand[] { name, description: maybeString(frontmatter.description) ?? firstMarkdownParagraph(content), argumentHint: maybeArgumentHint(frontmatter["argument-hint"]) ?? maybeArgumentHint(frontmatter.argumentHint), + source: "skill", + filePath: skillPath, }); } return commands; } +function ancestorClaudeRoots(cwd: string): string[] { + const roots: string[] = []; + const seen = new Set(); + const home = os.homedir(); + let current = path.resolve(cwd); + let depth = 0; + while (depth < 25) { + const candidate = path.join(current, ".claude"); + if (!seen.has(candidate)) { + seen.add(candidate); + roots.push(candidate); + } + const parent = path.dirname(current); + if (parent === current) break; + if (current === home) break; + current = parent; + depth += 1; + } + return roots; +} + export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashCommand[] { const roots = [ path.join(os.homedir(), ".claude"), - path.join(cwd, ".claude"), + ...ancestorClaudeRoots(cwd), ]; const byName = new Map(); @@ -221,6 +248,43 @@ export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashC return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); } +function resolveSkillFile(skillsDir: string, commandName: string): string | null { + if (!fs.existsSync(skillsDir)) return null; + const target = commandName.replace(/^\//, "").toLowerCase(); + if (!target.length) return null; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + } catch { + return null; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = path.join(skillsDir, entry.name, "SKILL.md"); + if (!fs.existsSync(skillPath)) continue; + let content = ""; + try { + content = fs.readFileSync(skillPath, "utf8"); + } catch { + continue; + } + const frontmatter = readFrontmatter(content) as { name?: unknown; "user-invocable"?: unknown; userInvocable?: unknown }; + if (frontmatter["user-invocable"] === false || frontmatter.userInvocable === false) continue; + const declaredName = maybeString(frontmatter.name); + const candidateNames = new Set(); + const dirNormalized = normalizeSlashCommandName(entry.name); + if (dirNormalized) candidateNames.add(dirNormalized.toLowerCase()); + if (declaredName) { + const fmNormalized = normalizeSlashCommandName(declaredName); + if (fmNormalized) candidateNames.add(fmNormalized.toLowerCase()); + } + if (candidateNames.has(`/${target}`) || candidateNames.has(target)) { + return skillPath; + } + } + return null; +} + export function resolveClaudeSlashCommandInvocation( cwd: string, input: string, @@ -234,17 +298,23 @@ export function resolveClaudeSlashCommandInvocation( const argumentsText = match[2]?.trim() ?? ""; const roots = [ path.join(os.homedir(), ".claude"), - path.join(cwd, ".claude"), + ...ancestorClaudeRoots(cwd), ]; - let commandFile: string | null = null; + // Prefer command files; fall back to user-invocable skills (SKILL.md). + let resolvedFile: string | null = null; for (const root of roots) { - commandFile = resolveLegacyCommandFile(path.join(root, "commands"), name) ?? commandFile; + resolvedFile = resolveLegacyCommandFile(path.join(root, "commands"), name) ?? resolvedFile; + } + if (!resolvedFile) { + for (const root of roots) { + resolvedFile = resolveSkillFile(path.join(root, "skills"), name) ?? resolvedFile; + } } - if (!commandFile) return null; + if (!resolvedFile) return null; try { - const content = fs.readFileSync(commandFile, "utf8"); + const content = fs.readFileSync(resolvedFile, "utf8"); const body = stripFrontmatter(content).trim(); if (!body.length) return null; const hasPlaceholder = /\$ARGUMENTS/.test(body); diff --git a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts index f66e8058..8956438c 100644 --- a/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/codexSlashCommandDiscovery.ts @@ -123,10 +123,24 @@ function resolvePromptFile(promptsDir: string, commandName: string): string | nu } function codexPromptRoots(cwd: string): string[] { - return [ - path.join(os.homedir(), ".codex", "prompts"), - path.join(cwd, ".codex", "prompts"), - ]; + const roots: string[] = [path.join(os.homedir(), ".codex", "prompts")]; + const seen = new Set(roots); + const home = os.homedir(); + let current = path.resolve(cwd); + let depth = 0; + while (depth < 25) { + const candidate = path.join(current, ".codex", "prompts"); + if (!seen.has(candidate)) { + seen.add(candidate); + roots.push(candidate); + } + const parent = path.dirname(current); + if (parent === current) break; + if (current === home) break; + current = parent; + depth += 1; + } + return roots; } export function discoverCodexSlashCommands(cwd: string): DiscoveredCodexSlashCommand[] { diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts index 9f20fbeb..27a989fa 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts @@ -281,6 +281,7 @@ export type CursorSdkTurnEndedTokensMeta = { turnId: string; itemId?: string; runtime?: AgentChatRuntime; + contextWindow?: number; }; export function mapTurnEndedTokensToEvent( @@ -311,5 +312,6 @@ export function mapTurnEndedTokensToEvent( ...(outputTokens != null ? { outputTokens } : {}), ...(cacheReadTokens != null ? { cacheReadTokens } : {}), ...(cacheWriteTokens != null ? { cacheWriteTokens } : {}), + ...(meta.contextWindow != null ? { contextWindow: meta.contextWindow } : {}), }; } diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 99fababd..c35faede 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -51,8 +51,8 @@ const CLI_TOOLS: Array<{ { cli: "claude", label: "Claude Code", - description: "Anthropic CLI subscription", - loginCmd: "claude auth login", + description: "Claude Agent SDK runtime", + loginCmd: "claude auth login or set ANTHROPIC_API_KEY", installHint: "npm install -g @anthropic-ai/claude-code", }, { diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index c9da2ada..ea08447e 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -307,6 +307,7 @@ export type AgentChatEvent = outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; + contextWindow?: number; } | { type: "cloud_artifact"; diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index af47b318..e618d472 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -17,7 +17,7 @@ It is a client. The runtime, lanes, chats, transcripts, PRs, processes, and proo | `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | | `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, navigation DTOs aligned with `apps/desktop/src/shared/types`. | -| `apps/ade-cli/src/tuiClient/components/` | `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`. | +| `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`. | | `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input). Imported per-module so ade-cli typecheck stays scoped. | | `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | | `apps/desktop/src/shared/adeLayout.ts` | Resolves project-scoped `.ade` paths. | @@ -63,7 +63,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt `apps/ade-cli/src/tuiClient/app.tsx` is the Ink root. Layout: -- **Header** — project name, active lane, chat session, model + reasoning effort badge, token / cost counter (`latestTokenStats`). +- **Header** — project name, active lane, branch, and the terminal client frame. - **Drawer** (toggled with the configured shortcut) — two sections: Lanes and Chats. Selecting a lane in the Lanes pane switches the active lane and filters the Chats pane to that lane's sessions. Lane and chat selection drive the right pane's context. - **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. Tool calls collapse into expandable blocks; the most recent expandable failure id is tracked so `Enter` can drill into it. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Pending tool approvals surface as `ApprovalPrompt`. @@ -147,6 +147,13 @@ ade --socket /tmp/ade-runtime-dev.sock code After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli.cjs` and `dist/tuiClient/cli.mjs` exist for packaged and linked use. During repo development, `npm run dev:code` runs the source TUI against the shared dev runtime at `/tmp/ade-runtime-dev.sock`. +## Chat setup + +- `+ new chat` opens a draft setup view in the details pane; it does not create a backend chat until the first prompt is sent from the middle composer. +- `/model` opens the model setup view. It can switch provider, model, reasoning, and permission settings, refresh provider readiness through `ai.getStatus`, and open desktop Settings > AI Providers for full configuration. +- `/login` delegates only to provider CLIs that can authenticate in the current terminal: Claude (`claude auth login`), Codex (`codex login`), and OpenCode (`opencode auth login`). Cursor chat is `@cursor/sdk` and needs `CURSOR_API_KEY` or desktop Settings > AI Providers. Droid chat runs Factory Droid over ACP and needs `FACTORY_API_KEY` or Factory's interactive `droid` login. +- The middle composer shows the selected provider, model, reasoning, and permission mode under the prompt so draft changes on the right are visible before the chat starts. + ## Related docs - [ADE CLI](../../../apps/ade-cli/README.md) — runtime daemon, install paths, service manager, full CLI surface. From d02eb192b99f8688136e10bb01d0bd0411f15f25 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 10 May 2026 13:35:07 -0400 Subject: [PATCH 3/7] Add project slash-command discovery and TUI updates Introduce project-level slash-command discovery (discoverProjectSlashCommands) and wire it into ade-code's API. Add UI state persistence and chat scroll offset handling (new state.ts, save/load last chat by lane), plus right-pane auto-refresh logic to surface git/pr status and file lists. Update formatting/parsing to expose assistant markdown blocks and adjust chat rendering tests and behavior (wrapping, viewport scrolling, indents, dedupe slash-command casing). Expand .claude automation docs with parity passes (Docs/Mobile/CLI/TUI) and simplify finalize pipeline language. Include related test updates and small desktop service adjustments for command discovery. --- .claude/commands/automate.md | 313 ++++++++++++++ .claude/commands/finalize.md | 267 +----------- .../src/tuiClient/__tests__/ChatView.test.tsx | 78 +++- .../src/tuiClient/__tests__/adeApi.test.ts | 52 ++- .../src/tuiClient/__tests__/commands.test.ts | 15 + .../src/tuiClient/__tests__/format.test.ts | 28 +- apps/ade-cli/src/tuiClient/adeApi.ts | 24 ++ apps/ade-cli/src/tuiClient/app.tsx | 317 +++++++++++++- apps/ade-cli/src/tuiClient/commands.ts | 23 +- .../src/tuiClient/components/AdeWordmark.tsx | 13 +- .../src/tuiClient/components/ChatView.tsx | 404 ++++++++++++------ .../src/tuiClient/components/Drawer.tsx | 60 ++- .../src/tuiClient/components/RightPane.tsx | 58 ++- apps/ade-cli/src/tuiClient/format.ts | 109 ++++- apps/ade-cli/src/tuiClient/state.ts | 37 ++ apps/ade-cli/src/tuiClient/types.ts | 9 + .../services/chat/agentChatService.test.ts | 108 ++++- .../main/services/chat/agentChatService.ts | 47 +- .../chat/claudeSlashCommandDiscovery.test.ts | 51 ++- .../chat/claudeSlashCommandDiscovery.ts | 38 +- 20 files changed, 1569 insertions(+), 482 deletions(-) create mode 100644 apps/ade-cli/src/tuiClient/state.ts diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index 17d5e9a8..f35d329b 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -158,6 +158,312 @@ Fix until passing before moving to the next. --- +## Parity Passes (4–7) + +After the test-suite work above, run four parity reviewers that keep docs, iOS, the CLI, and the TUI in lockstep with the desktop changes on this branch. They are independent of one another and of Passes 1–3. + +**Preferred: TeamCreate** for these four passes so progress is tracked and a single completion event surfaces the batch. Per the global git-worktrees policy, do not pass worktree isolation. Fallback: parallel `Agent` calls in a single tool-call round if TeamCreate is unavailable. + +--- + +## Pass 4: DOCS + +The internal docs live under `docs/` with this structure (rebuilt; do NOT confuse with the public Mintlify site at repo root `docs.json` + `*.mdx`): + +``` +docs/ +├── README.md # navigation map +├── PRD.md # product entry point — links to every feature +├── ARCHITECTURE.md # consolidated system architecture +├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only) +└── features/ + ├── agents/ ├── memory/ + ├── automations/ ├── missions/ + ├── chat/ ├── onboarding-and-settings/ + ├── computer-use/ ├── project-home/ + ├── conflicts/ ├── pull-requests/ + ├── context-packs/ ├── sync-and-multi-device/ + ├── cto/ ├── terminals-and-sessions/ + ├── files-and-editor/ └── workspace-graph/ + ├── history/ + ├── lanes/ + └── linear-integration/ +``` + +Each `features//` contains a `README.md` (overview + source file map at top) plus 1–4 detail `*.md` files. + +Spawn a general-purpose agent with this prompt: + +``` +You are the documentation updater for the ADE project. + +Analyze all changes on the current branch vs main and update relevant internal +docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files) +is out of scope — do NOT touch it. + +Step 1: Get changed files + git diff main --name-only + git diff main --stat | tail -30 + +Step 2: Map changed source to internal docs + +| Source Directory | Doc Location | +|----------------------------------------------------|----------------------------------------------------| +| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | +| apps/desktop/src/main/services/projects/ | docs/features/project-home/ | +| apps/desktop/src/main/services/proof/ | docs/features/proof.md | +| apps/desktop/src/main/services/review/ | docs/features/pull-requests/ | +| apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ | +| apps/desktop/src/main/services/lanes/ | docs/features/lanes/ | +| apps/desktop/src/main/services/memory/ | docs/features/memory/ | +| apps/desktop/src/main/services/cto/ | docs/features/cto/ (+ linear-integration/) | +| apps/desktop/src/main/services/ai/ | docs/features/chat/ + features/agents/ | +| apps/desktop/src/main/services/chat/ | docs/features/chat/ | +| apps/desktop/src/main/services/automations/ | docs/features/automations/ | +| apps/desktop/src/main/services/computerUse/ | docs/features/computer-use/ | +| apps/desktop/src/main/services/context/ | docs/features/context-packs/ | +| apps/desktop/src/main/services/conflicts/ | docs/features/conflicts/ | +| apps/desktop/src/main/services/files/ | docs/features/files-and-editor/ | +| apps/desktop/src/main/services/history/ | docs/features/history/ | +| apps/desktop/src/main/services/onboarding/ | docs/features/onboarding-and-settings/ | +| apps/desktop/src/main/services/pty/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/sessions/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/processes/ | docs/features/terminals-and-sessions/ | +| apps/desktop/src/main/services/sync/ | docs/features/sync-and-multi-device/ | +| apps/desktop/src/main/services/config/ | docs/features/onboarding-and-settings/ | +| apps/desktop/src/main/services/ipc/ | docs/ARCHITECTURE.md (IPC section) | +| apps/desktop/src/main/services/git/ | docs/ARCHITECTURE.md (Git engine section) + lanes/ | +| apps/desktop/src/preload/ | docs/ARCHITECTURE.md (IPC contract) | +| apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc | +| apps/desktop/src/renderer/components// | docs/features// | +| apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) | +| apps/ade-cli/ | docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) + docs/features/agents/ | +| .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) | +| apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md | +| apps/web/ | docs/ARCHITECTURE.md (Apps & Processes) | + +Step 3: Update docs in place +- Prefer editing existing docs over creating new ones. +- If a feature gets a genuinely new sub-concept worth its own page, add a new detail doc inside the existing features// folder. +- Keep each README.md's "Source file map" section current — it is the primary way an agent orients itself. +- Rewrite prose to reflect current reality (not a changelog of what changed). +- Remove outdated information. +- Do NOT add changelog sections, "Updated on X" notes, or dated markers. +- Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated. + +Step 4: Run doc validation + node scripts/validate-docs.mjs + +This validator only covers the Mintlify site. For internal docs, self-check: + - Every features//README.md still has a "Source file map" section. + - PRD.md links resolve (grep for broken relative links). + +Report what docs were updated and what was changed. +``` + +--- + +## Pass 5: MOBILE parity + +Spawn a general-purpose agent with this prompt: + +``` +You are the mobile parity reviewer for the ADE project. + +Analyze all work on the current branch vs main, including changes that are +already under review and any simplifications made during `/finalize`. Determine +whether the iOS companion app under `apps/ios/` needs matching updates. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + git log main..HEAD --oneline + +Step 2: Identify cross-platform changes +- Shared contracts: apps/desktop/src/shared/**, preload IPC types, sync payloads, + PR mobile snapshots, chat/session models, lane summaries, config schemas. +- Desktop behavior with a mobile surface: PR workflows, lanes, Work chat, + files, sync/multi-device, settings exposed on iOS, model/session controls. +- Renderer-only desktop preferences are only mobile-applicable when the iOS app + has the same user-facing concept and a native implementation path. + +Step 3: Inspect iOS equivalents +- Search `apps/ios/ADE` and `apps/ios/ADETests` for the affected model, view, + service, or workflow names. +- If the branch adds or changes a host/mobile contract, update Swift Codable + models and iOS tests as needed. +- If the branch changes user-facing behavior that iOS already exposes, update + the SwiftUI view using native iOS controls and existing ADE design patterns. +- If the change is not applicable to iOS, explain why in the report. + +Step 4: Apply required iOS updates +- Keep edits scoped to `apps/ios/` unless a shared contract fix is required. +- Prefer existing SwiftUI patterns and native controls. +- Preserve Dynamic Type, VoiceOver labels, and 44x44 tap targets. +- Add or update targeted tests in `apps/ios/ADETests` for contract changes. + +Step 5: Validate what you touched +- At minimum: `xcrun swiftc -parse ` when a full Xcode + build/test run is unavailable. +- Prefer an iOS build/test when the local simulator/runtime environment supports it. + +Report: +- iOS files changed, or "No iOS changes required" +- Why each desktop/shared change was applicable or not applicable to mobile +- Validation run and any environment limitations +``` + +--- + +## Pass 6: CLI parity + +The `apps/ade-cli/` package is the agent-facing surface for ADE. Every desktop +action should be reachable either through a typed subcommand (`ade lanes …`, +`ade prs …`, `ade chat …`, `ade tests …`, `ade run …`, `ade proof …`) or +through the generic `ade actions run ` registry exposed by +`adeRpcServer.ts`. When a feature branch adds, renames, or removes a desktop +feature, the CLI silently drifts unless someone updates it in the same PR. +This agent closes that gap. + +Spawn a general-purpose agent with this prompt: + +``` +You are the ADE CLI parity reviewer. + +The ADE CLI (apps/ade-cli) is the primary agent-facing interface to the ADE +desktop app. Its goal is to surface every meaningful action inside ADE +desktop — either as a typed subcommand or via the generic +`ade actions run ` registry. When desktop changes, the CLI +must change with it. Your job is to detect drift on this branch and patch +apps/ade-cli/ so the CLI stays in lockstep with desktop. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + git log main..HEAD --oneline + +Step 2: Identify CLI-relevant desktop changes +Treat anything under these paths as a candidate for new / changed / removed +CLI surface: +- apps/desktop/src/main/services/** (each service is a candidate action + domain — lanes, prs, chat, tests, proof, run, git, files, missions, + automations, computerUse, context, conflicts, history, memory, onboarding, + pty, sessions, processes, sync, config, cto, ai) +- apps/desktop/src/preload/** and apps/desktop/src/shared/** (IPC and + shared contracts the CLI ultimately calls through) +- New domains/actions registered with the action registry on either side + +Step 3: Map each candidate to the CLI +- Typed subcommands live in apps/ade-cli/src/cli.ts (~3300 lines), a + case-based dispatcher. Existing cases include lanes, git-status, prs-list, + chat-list, tests-runs, proof-list, actions-list, action-result, etc. + Locate the closest existing case block and either extend it or add a + sibling case alongside it. +- The RPC + actions-registry surface lives in + apps/ade-cli/src/adeRpcServer.ts (~6500 lines), with a no-desktop fallback + in apps/ade-cli/src/headlessLinearServices.ts. New service actions usually + need wiring in one or both so `ade actions run ` resolves + them whether or not the desktop socket is up. +- The user-facing inventory lives in apps/ade-cli/README.md under + "CLI surface". Keep it accurate whenever a typed command is added, + renamed, or removed. + +Step 4: Apply auto-fix edits — scoped to apps/ade-cli/ only +- New feature: add a typed subcommand if the desktop feature is a distinct + user-facing workflow (lane / PR / chat / test / run / proof / mission / + automation / etc.). If it is just a new low-level service action, ensure + it is reachable via the actions registry and skip a typed wrapper. +- Renamed or behavior-changed feature: update the existing case to match + new parameters, IPC names, or output shape. Keep flag names stable when + possible — flag any breaking renames in the report. +- Removed feature: delete the dead case and any registry wiring. Do NOT + leave a stub. Drop the corresponding README line. +- Reuse existing patterns: match surrounding cases for argv parsing, + --text / --json output mode, error formatting, and --lane / --project-root + argument handling. Do not invent new dispatch styles. + +Step 5: Validate locally before reporting + cd apps/ade-cli && npm run typecheck + cd apps/ade-cli && npm test + +If tests fail in files you did not touch, leave them — Phase 3 handles +test-suite drift. Do not rewrite unrelated tests. + +Out of scope: +- Do NOT edit anything under apps/desktop/. +- Do NOT touch docs/ — the docs agent owns that. +- Do NOT refactor unrelated CLI code. + +Report: +- apps/ade-cli/ files changed (or "no CLI changes required") +- For each branch change: desktop change → CLI change, or why not applicable +- Any breaking flag / command renames +- typecheck and test results +``` + +--- + +## Pass 7: TUI parity + +`apps/ade-cli/src/tuiClient/` is the terminal client for `ade code`. It surfaces lanes, chats, git state, and slash commands in a 28-col Drawer + 38-col RightPane Ink UI. When desktop or ade-cli changes, the TUI must stay in lockstep — most commonly because a new git/lane/PR action becomes available, a slash command is renamed, or a lane summary field is added. + +Spawn a general-purpose agent with this prompt: + +``` +You are the ADE TUI parity reviewer. + +`apps/ade-cli/src/tuiClient/` is the terminal client for `ade code`. It surfaces lanes, chats, git +state, and slash commands in a 28-col Drawer + 38-col RightPane Ink UI. +When desktop or ade-cli changes, the TUI must stay in lockstep — most +commonly because a new git/lane/PR action becomes available, a slash +command is renamed, or a lane summary field is added. + +Step 1: Get branch context + git diff main --name-only + git diff main --stat | tail -30 + +Step 2: Identify TUI-relevant changes. Treat as candidates: +- apps/desktop/src/shared/types/lanes.ts, /chat, /sync — TUI imports these directly. +- apps/ade-cli/src/adeRpcServer.ts new actions — should appear in BUILTIN_COMMANDS or via /ade. +- New IPC handlers in window.ade.git/.lanes/.app/.prs — TUI may want a slash command + right-pane action wrapper. + +Step 3: Map to ade-code surface +- Slash commands: apps/ade-cli/src/tuiClient/commands.ts BUILTIN_COMMANDS. +- Slash dispatch: apps/ade-cli/src/tuiClient/app.tsx (search by name pattern, e.g. `if (name === "/push")`). +- Sidebar rendering: apps/ade-cli/src/tuiClient/components/Drawer.tsx. +- Right pane content kinds: apps/ade-cli/src/tuiClient/components/RightPane.tsx + types.ts (RightPaneContent union). +- ADE API calls: apps/ade-cli/src/tuiClient/adeApi.ts. + +Step 4: Apply auto-fix edits — scoped to apps/ade-cli/src/tuiClient/ only. +- New action: add a BUILTIN_COMMANDS entry + dispatch case. Mirror existing + shape (placement, argumentHint). +- Renamed action: rename in commands.ts and the dispatch handler. Keep the + user-facing slash name stable when possible — flag breaking renames. +- Removed action: delete the BUILTIN_COMMANDS entry and its dispatch case. +- New LaneSummary or AgentChatSessionSummary fields: surface in Drawer.tsx + if relevant to lane/chat list rendering, or in lane-details RightPane + block if relevant to status. + +Step 5: Validate + cd apps/ade-cli && npm run typecheck + cd apps/ade-cli && npx vitest run src/tuiClient + +Out of scope: +- Do NOT edit apps/desktop/ or apps/ios/. +- Do NOT edit unrelated apps/ade-cli code unless the `ade code` launcher in apps/ade-cli/src/cli.ts must change with the TUI. +- Do NOT touch docs/ — the docs agent owns that. + +Report: +- apps/ade-cli/src/tuiClient/ files changed (or "no TUI changes required") +- For each branch change: source change → TUI change, or why not applicable +- Any breaking slash-command renames +- typecheck and test results +``` + +Wait for all four parity agents to complete before moving to Verification. + +--- + ## Verification After all three passes: @@ -241,6 +547,13 @@ Added: - - Or "none — feature was visual / fully covered by consolidation" +Parity: +- Docs: — validation PASS / blocked +- Mobile: — validation PASS / blocked +- CLI: — typecheck + tests PASS / blocked +- TUI: — typecheck + tests PASS / blocked +- Breaking flag/command/slash renames: + Verification: - Affected files: PASS ( tests) - Shard re-run: PASS diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index d49150e8..d8c05864 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -45,10 +45,10 @@ Outputs are exactly two things: the Phase 4 summary, and fatal-error messages (t ## Pipeline Overview ``` -Phase 1: Analyze code changes and batch simplification work (lead) -Phase 2: Parallel execution (simplify + docs + mobile + cli) (agents) -Phase 3: CI sync + local verification (lead) -Phase 4: Summary (lead) +Phase 1: Analyze & Prepare Code Simplification (lead) +Phase 2: Code Simplification (agents) +Phase 3: CI sync + local verification (lead) +Phase 4: Summary (lead) ``` --- @@ -96,13 +96,9 @@ git diff main --name-only | sort > /tmp/finalize-branch-files.txt --- -## Phase 2: Parallel Execution +## Phase 2: Code Simplification -**Preferred orchestration: `TeamCreate`.** Spawn the four agents below as one team so progress is tracked, inboxes catch cross-agent messages, and a single completion event surfaces the whole batch. Per the global git-worktrees policy, do **not** pass worktree isolation — all agents work in the main directory. - -Fallback: if `TeamCreate` is unavailable in the current harness (or if running outside Claude entirely), spawn them as parallel `Agent` calls in a single tool-call round and aggregate their reports manually before Phase 3. - -### Simplifier agents (1-3 based on batch size) +Spawn 1–3 simplifier agents based on batch size from Phase 1c. Use TeamCreate when available; parallel Agent calls otherwise. Per the global git-worktrees policy, do **not** pass worktree isolation — all agents work in the main directory. Use `subagent_type: "code-simplifier:code-simplifier"` for each batch (note the full namespaced form — plain `"code-simplifier"` is not a valid agent type). @@ -114,238 +110,9 @@ Prompt each with: - **Diff-only scope**: `git diff main -- ` first; if zero diff, do not edit (a previous run tried to simplify files it thought were modified, and wasted time on unchanged code). - **Typecheck after every file**: `cd apps/desktop && npx tsc --noEmit -p . 2>&1 | head -20`. -### Doc updater agent - -The internal docs live under `docs/` with this structure (rebuilt; do NOT confuse with the public Mintlify site at repo root `docs.json` + `*.mdx`): - -``` -docs/ -├── README.md # navigation map -├── PRD.md # product entry point — links to every feature -├── ARCHITECTURE.md # consolidated system architecture -├── OPTIMIZATION_OPPORTUNITIES.md # backlog (append-only) -└── features/ - ├── agents/ ├── memory/ - ├── automations/ ├── missions/ - ├── chat/ ├── onboarding-and-settings/ - ├── computer-use/ ├── project-home/ - ├── conflicts/ ├── pull-requests/ - ├── context-packs/ ├── sync-and-multi-device/ - ├── cto/ ├── terminals-and-sessions/ - ├── files-and-editor/ └── workspace-graph/ - ├── history/ - ├── lanes/ - └── linear-integration/ -``` - -Each `features//` contains a `README.md` (overview + source file map at top) plus 1–4 detail `*.md` files. - -Spawn a general-purpose agent with this prompt: - -``` -You are the documentation updater for the ADE project. - -Analyze all changes on the current branch vs main and update relevant internal -docs under `docs/`. The public Mintlify site (docs.json + root-level .mdx files) -is out of scope — do NOT touch it. - -Step 1: Get changed files - git diff main --name-only - git diff main --stat | tail -30 - -Step 2: Map changed source to internal docs - -| Source Directory | Doc Location | -|----------------------------------------------------|----------------------------------------------------| -| apps/desktop/src/main/services/orchestrator/ | docs/features/missions/ | -| apps/desktop/src/main/services/projects/ | docs/features/project-home/ | -| apps/desktop/src/main/services/proof/ | docs/features/proof.md | -| apps/desktop/src/main/services/review/ | docs/features/pull-requests/ | -| apps/desktop/src/main/services/prs/ | docs/features/pull-requests/ | -| apps/desktop/src/main/services/lanes/ | docs/features/lanes/ | -| apps/desktop/src/main/services/memory/ | docs/features/memory/ | -| apps/desktop/src/main/services/cto/ | docs/features/cto/ (+ linear-integration/) | -| apps/desktop/src/main/services/ai/ | docs/features/chat/ + features/agents/ | -| apps/desktop/src/main/services/chat/ | docs/features/chat/ | -| apps/desktop/src/main/services/automations/ | docs/features/automations/ | -| apps/desktop/src/main/services/computerUse/ | docs/features/computer-use/ | -| apps/desktop/src/main/services/context/ | docs/features/context-packs/ | -| apps/desktop/src/main/services/conflicts/ | docs/features/conflicts/ | -| apps/desktop/src/main/services/files/ | docs/features/files-and-editor/ | -| apps/desktop/src/main/services/history/ | docs/features/history/ | -| apps/desktop/src/main/services/onboarding/ | docs/features/onboarding-and-settings/ | -| apps/desktop/src/main/services/pty/ | docs/features/terminals-and-sessions/ | -| apps/desktop/src/main/services/sessions/ | docs/features/terminals-and-sessions/ | -| apps/desktop/src/main/services/processes/ | docs/features/terminals-and-sessions/ | -| apps/desktop/src/main/services/sync/ | docs/features/sync-and-multi-device/ | -| apps/desktop/src/main/services/config/ | docs/features/onboarding-and-settings/ | -| apps/desktop/src/main/services/ipc/ | docs/ARCHITECTURE.md (IPC section) | -| apps/desktop/src/main/services/git/ | docs/ARCHITECTURE.md (Git engine section) + lanes/ | -| apps/desktop/src/preload/ | docs/ARCHITECTURE.md (IPC contract) | -| apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc | -| apps/desktop/src/renderer/components// | docs/features// | -| apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) | -| apps/ade-cli/ | docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) + docs/features/agents/ | -| .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) | -| apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md | -| apps/web/ | docs/ARCHITECTURE.md (Apps & Processes) | - -Step 3: Update docs in place -- Prefer editing existing docs over creating new ones. -- If a feature gets a genuinely new sub-concept worth its own page, add a new detail doc inside the existing features// folder. -- Keep each README.md's "Source file map" section current — it is the primary way an agent orients itself. -- Rewrite prose to reflect current reality (not a changelog of what changed). -- Remove outdated information. -- Do NOT add changelog sections, "Updated on X" notes, or dated markers. -- Do NOT modify docs/OPTIMIZATION_OPPORTUNITIES.md via this agent — it is append-only and human-curated. - -Step 4: Run doc validation - node scripts/validate-docs.mjs - -This validator only covers the Mintlify site. For internal docs, self-check: - - Every features//README.md still has a "Source file map" section. - - PRD.md links resolve (grep for broken relative links). - -Report what docs were updated and what was changed. -``` - -### Mobile parity agent - -Spawn a general-purpose agent with this prompt: +Wait for all simplifier agents to complete before moving to Phase 3. -``` -You are the mobile parity reviewer for the ADE project. - -Analyze all work on the current branch vs main, including changes that are -already under review and any simplifications made during `/finalize`. Determine -whether the iOS companion app under `apps/ios/` needs matching updates. - -Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 - git log main..HEAD --oneline - -Step 2: Identify cross-platform changes -- Shared contracts: apps/desktop/src/shared/**, preload IPC types, sync payloads, - PR mobile snapshots, chat/session models, lane summaries, config schemas. -- Desktop behavior with a mobile surface: PR workflows, lanes, Work chat, - files, sync/multi-device, settings exposed on iOS, model/session controls. -- Renderer-only desktop preferences are only mobile-applicable when the iOS app - has the same user-facing concept and a native implementation path. - -Step 3: Inspect iOS equivalents -- Search `apps/ios/ADE` and `apps/ios/ADETests` for the affected model, view, - service, or workflow names. -- If the branch adds or changes a host/mobile contract, update Swift Codable - models and iOS tests as needed. -- If the branch changes user-facing behavior that iOS already exposes, update - the SwiftUI view using native iOS controls and existing ADE design patterns. -- If the change is not applicable to iOS, explain why in the report. - -Step 4: Apply required iOS updates -- Keep edits scoped to `apps/ios/` unless a shared contract fix is required. -- Prefer existing SwiftUI patterns and native controls. -- Preserve Dynamic Type, VoiceOver labels, and 44x44 tap targets. -- Add or update targeted tests in `apps/ios/ADETests` for contract changes. - -Step 5: Validate what you touched -- At minimum: `xcrun swiftc -parse ` when a full Xcode - build/test run is unavailable. -- Prefer an iOS build/test when the local simulator/runtime environment supports it. - -Report: -- iOS files changed, or "No iOS changes required" -- Why each desktop/shared change was applicable or not applicable to mobile -- Validation run and any environment limitations -``` - -### CLI parity agent - -The `apps/ade-cli/` package is the agent-facing surface for ADE. Every desktop -action should be reachable either through a typed subcommand (`ade lanes …`, -`ade prs …`, `ade chat …`, `ade tests …`, `ade run …`, `ade proof …`) or -through the generic `ade actions run ` registry exposed by -`adeRpcServer.ts`. When a feature branch adds, renames, or removes a desktop -feature, the CLI silently drifts unless someone updates it in the same PR. -This agent closes that gap. - -Spawn a general-purpose agent with this prompt: - -``` -You are the ADE CLI parity reviewer. - -The ADE CLI (apps/ade-cli) is the primary agent-facing interface to the ADE -desktop app. Its goal is to surface every meaningful action inside ADE -desktop — either as a typed subcommand or via the generic -`ade actions run ` registry. When desktop changes, the CLI -must change with it. Your job is to detect drift on this branch and patch -apps/ade-cli/ so the CLI stays in lockstep with desktop. - -Step 1: Get branch context - git diff main --name-only - git diff main --stat | tail -30 - git log main..HEAD --oneline - -Step 2: Identify CLI-relevant desktop changes -Treat anything under these paths as a candidate for new / changed / removed -CLI surface: -- apps/desktop/src/main/services/** (each service is a candidate action - domain — lanes, prs, chat, tests, proof, run, git, files, missions, - automations, computerUse, context, conflicts, history, memory, onboarding, - pty, sessions, processes, sync, config, cto, ai) -- apps/desktop/src/preload/** and apps/desktop/src/shared/** (IPC and - shared contracts the CLI ultimately calls through) -- New domains/actions registered with the action registry on either side - -Step 3: Map each candidate to the CLI -- Typed subcommands live in apps/ade-cli/src/cli.ts (~3300 lines), a - case-based dispatcher. Existing cases include lanes, git-status, prs-list, - chat-list, tests-runs, proof-list, actions-list, action-result, etc. - Locate the closest existing case block and either extend it or add a - sibling case alongside it. -- The RPC + actions-registry surface lives in - apps/ade-cli/src/adeRpcServer.ts (~6500 lines), with a no-desktop fallback - in apps/ade-cli/src/headlessLinearServices.ts. New service actions usually - need wiring in one or both so `ade actions run ` resolves - them whether or not the desktop socket is up. -- The user-facing inventory lives in apps/ade-cli/README.md under - "CLI surface". Keep it accurate whenever a typed command is added, - renamed, or removed. - -Step 4: Apply auto-fix edits — scoped to apps/ade-cli/ only -- New feature: add a typed subcommand if the desktop feature is a distinct - user-facing workflow (lane / PR / chat / test / run / proof / mission / - automation / etc.). If it is just a new low-level service action, ensure - it is reachable via the actions registry and skip a typed wrapper. -- Renamed or behavior-changed feature: update the existing case to match - new parameters, IPC names, or output shape. Keep flag names stable when - possible — flag any breaking renames in the report. -- Removed feature: delete the dead case and any registry wiring. Do NOT - leave a stub. Drop the corresponding README line. -- Reuse existing patterns: match surrounding cases for argv parsing, - --text / --json output mode, error formatting, and --lane / --project-root - argument handling. Do not invent new dispatch styles. - -Step 5: Validate locally before reporting - cd apps/ade-cli && npm run typecheck - cd apps/ade-cli && npm test - -If tests fail in files you did not touch, leave them — Phase 3 handles -test-suite drift. Do not rewrite unrelated tests. - -Out of scope: -- Do NOT edit anything under apps/desktop/. -- Do NOT touch docs/ — the docs agent owns that. -- Do NOT refactor unrelated CLI code. - -Report: -- apps/ade-cli/ files changed (or "no CLI changes required") -- For each branch change: desktop change → CLI change, or why not applicable -- Any breaking flag / command renames -- typecheck and test results -``` - -Wait for all agents to complete. +Docs, mobile, CLI, and TUI parity reviewers have moved to `/automate` — they should run before `/finalize`. Do not re-spawn them here. --- @@ -539,22 +306,6 @@ Do not report "PR clean" from `/finalize` alone. - Files simplified: X - Key changes: [brief list] -### Documentation: -- Docs updated: [list] -- Docs checked but unchanged: [list] -- Doc validation: PASS - -### Mobile Parity: -- iOS changes: [list or "none required"] -- Applicability notes: [brief list] -- Validation: PASS / blocked with reason - -### CLI Parity: -- apps/ade-cli files changed: [list or "none required"] -- Desktop change → CLI change mapping: [brief list] -- Breaking flag/command renames: [list or "none"] -- Validation (typecheck + tests): PASS / blocked with reason - ### CI Verification: - Lock files in sync: PASS - Typecheck (desktop): PASS @@ -581,4 +332,4 @@ Do not report "PR clean" from `/finalize` alone. ## Completion Checklist -Before marking complete: every Phase 3 step (3a–3j) must report PASS in the Phase 4 summary, and all four Phase 2 agents (simplify, docs, mobile, cli) must have reported back. Remote PR review is **not** declared clean by `/finalize` — handoff to `/shipLane` (Phase 3j) is mandatory after push. +Before marking complete: every Phase 3 step (3a–3j) must report PASS in the Phase 4 summary, and Phase 2 simplifier agents must have reported back. Remote PR review is **not** declared clean by `/finalize` — handoff to `/shipLane` (Phase 3j) is mandatory after push. diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 27cb5848..4c2f3557 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -17,7 +17,10 @@ const session: AgentChatSessionSummary = { summary: null, }; -function renderEvents(events: AgentChatEventEnvelope[]): string { +function renderEvents( + events: AgentChatEventEnvelope[], + options: { maxRows?: number; scrollOffsetRows?: number; width?: number } = {}, +): string { const result = render( , ); return result.lastFrame() ?? ""; @@ -34,13 +40,15 @@ describe("ChatView", () => { it("renders a bordered hero card with the ADE wordmark when the chat is empty", () => { const frame = renderEvents([]); expect(frame).toMatch(/[╭╮╯╰]/); - expect(frame).toContain("█▀█"); + expect(frame).toContain("██████"); expect(frame).toContain("ade code"); expect(frame).toContain("v0.1"); + expect(frame).toContain("Project"); + expect(frame).toContain("Lane"); + expect(frame).toContain("Branch"); expect(frame).toContain("Primary"); expect(frame).toContain("type to chat"); - expect(frame).toContain("›"); - expect(frame).toContain("inspect the current diff"); + expect(frame).toContain("commands"); }); it("right-aligns user messages inside an accent-bordered bubble", () => { @@ -78,6 +86,66 @@ describe("ChatView", () => { expect(frame).not.toMatch(/[╭╮╯╰]/); }); + it("renders markdown-like assistant output into readable blocks", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { + type: "text", + text: [ + "## Fix plan", + "", + "- Trace commands", + "1. Patch renderer", + "", + "```ts", + "const ok = true;", + "```", + ].join("\n"), + }, + }, + ], { width: 60 }); + expect(frame).toContain("Fix plan"); + expect(frame).toContain("• Trace commands"); + expect(frame).toContain("1. Patch renderer"); + expect(frame).toContain("│ const ok = true;"); + }); + + it("wraps long assistant paragraphs to the supplied width", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "text", text: "This paragraph should wrap cleanly across more than one terminal row instead of flattening into an unreadable single line." }, + }, + ], { width: 42 }); + expect(frame).toContain("This paragraph should wrap cleanly"); + expect(frame).toContain("across more than one terminal row"); + }); + + it("shows the bottom viewport by default and older rows when scrolled", () => { + const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: `2026-01-01T12:00:${String(index).padStart(2, "0")}.000Z`, + sequence: index + 1, + event: index % 2 === 0 + ? { type: "user_message", text: `user row ${index + 1}` } + : { type: "text", text: `assistant row ${index + 1}` }, + })); + const bottom = renderEvents(events, { maxRows: 5, width: 80 }); + expect(bottom).toContain("assistant row 12"); + expect(bottom).not.toContain("user row 1"); + expect(bottom).toContain("↑ older messages"); + + const older = renderEvents(events, { maxRows: 5, scrollOffsetRows: 8, width: 80 }); + expect(older).toContain("row"); + expect(older).toContain("↓ newer messages"); + expect(older).not.toContain("assistant row 12"); + }); + it("indents tool call output", () => { const frame = renderEvents([ { @@ -90,7 +158,7 @@ describe("ChatView", () => { const lines = frame.split(/\r?\n/).filter((line) => line.includes("run git branch")); expect(lines.length).toBeGreaterThan(0); for (const line of lines) { - expect(line.startsWith(" ")).toBe(true); + expect(line.startsWith(" ")).toBe(true); } }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 0e708127..5c1821ed 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -1,8 +1,26 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +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 { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, latestTokenStats, sendChatMessage } from "../adeApi"; +import { createChatSession, DEFAULT_CODEX_REASONING_EFFORT, discoverProjectSlashCommands, latestTokenStats, sendChatMessage } from "../adeApi"; import type { AdeCodeConnection } from "../types"; +const tmpPaths: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const tmpPath of tmpPaths.splice(0)) { + fs.rmSync(tmpPath, { recursive: true, force: true }); + } +}); + +function makeTmpRoot(prefix: string): string { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpPaths.push(tmpPath); + return tmpPath; +} + function envelope( sequence: number, event: AgentChatEventEnvelope["event"], @@ -78,6 +96,36 @@ describe("latestTokenStats", () => { }); }); +describe("discoverProjectSlashCommands", () => { + it("prefers project .claude command metadata over same-named global Codex prompts", () => { + const projectRoot = makeTmpRoot("ade-code-project-commands-"); + const homeRoot = makeTmpRoot("ade-code-home-prompts-"); + vi.spyOn(os, "homedir").mockReturnValue(homeRoot); + const commandsDir = path.join(projectRoot, ".claude", "commands"); + const promptsDir = path.join(homeRoot, ".codex", "prompts"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "automate.md"), [ + "---", + "description: Project ADE automate", + "---", + "", + "Run project automate.", + "", + ].join("\n")); + fs.writeFileSync(path.join(promptsDir, "automate.md"), "# Global Codex automate\n"); + + const commands = discoverProjectSlashCommands(projectRoot); + expect(commands.filter((command) => command.name.toLowerCase() === "/automate")).toHaveLength(1); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/automate", + description: "Project ADE automate", + }), + ])); + }); +}); + describe("createChatSession", () => { it("defaults Codex chats to GPT-5.5 low reasoning", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 653f55d6..adcc1816 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -85,6 +85,21 @@ describe("commands", () => { expect(clearRows[0]?.description).toBe("Start a new conversation with empty context"); }); + it("dedupes slash command case variants and keeps runtime casing", () => { + const rows = paletteCommands("/ship", [ + { name: "/shipLane", description: "Ship the lane", source: "sdk" }, + { name: "/shiplane", description: "Duplicate lower-case command", source: "sdk" }, + ]); + expect(rows.filter((row) => row.name.toLowerCase() === "/shiplane")).toHaveLength(1); + expect(rows.find((row) => row.name.toLowerCase() === "/shiplane")?.name).toBe("/shiplane"); + + const parsed = parseCommand("/shipLane now", [ + { name: "/shiplane", description: "Ship the lane", source: "sdk" }, + ]); + expect(parsed?.userCommand?.name).toBe("/shiplane"); + expect(parsed?.args).toBe("now"); + }); + it("returns more than 9 results for empty/short queries", () => { const userCommands = Array.from({ length: 20 }, (_, i) => ({ name: `/sdk-cmd-${i}`, diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 972ffa32..18438c09 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -1,7 +1,30 @@ import { describe, expect, it } from "vitest"; -import { latestExpandableFailureId, renderChatLines, renderObject } from "../format"; +import { latestExpandableFailureId, parseAssistantMarkdown, renderChatLines, renderObject } from "../format"; describe("renderChatLines", () => { + it("parses assistant markdown into stable blocks", () => { + expect(parseAssistantMarkdown([ + "# Heading", + "", + "Paragraph text", + "", + "- Bullet", + "1. Numbered", + "> Quote", + "", + "```sh", + "npm test", + "```", + ].join("\n"))).toEqual([ + { kind: "heading", level: 1, text: "Heading" }, + { kind: "paragraph", text: "Paragraph text" }, + { kind: "bullet", text: "Bullet" }, + { kind: "numbered", number: "1", text: "Numbered" }, + { kind: "quote", text: "Quote" }, + { kind: "code", language: "sh", lines: ["npm test"] }, + ]); + }); + it("renders compact rule-separated chat turns", () => { const lines = renderChatLines({ activeSession: null, @@ -200,6 +223,9 @@ describe("renderChatLines", () => { expect(lines).toHaveLength(1); expect(lines[0]?.tone).toBe("assistant"); expect(lines[0]?.body).toBe("I'm Codex, running as a GPT-5 based software engineering agent."); + expect(lines[0]?.blocks).toEqual([ + { kind: "paragraph", text: "I'm Codex, running as a GPT-5 based software engineering agent." }, + ]); expect(lines[0]?.header).toMatch(/^Codex /); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 1732c5e9..5cfeee39 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -25,6 +25,8 @@ import type { } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import { discoverClaudeSlashCommands } from "../../../desktop/src/main/services/chat/claudeSlashCommandDiscovery"; +import { discoverCodexSlashCommands } from "../../../desktop/src/main/services/chat/codexSlashCommandDiscovery"; import type { AdeCodeConnection, ChatHistorySnapshot, CreatedChat, NavigateRequest, NavigateResult } from "./types"; export const DEFAULT_CODEX_REASONING_EFFORT = "low"; @@ -60,6 +62,28 @@ export async function getSlashCommands( return await connection.action("chat", "getSlashCommands", { sessionId }); } +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + +export function discoverProjectSlashCommands(workspaceRoot: string): AgentChatSlashCommand[] { + const byName = new Map(); + const add = (command: { name: string; description: string; argumentHint?: string }) => { + if (command.name === "/login") return; + const key = slashCommandKey(command.name); + if (byName.has(key)) return; + byName.set(key, { + name: command.name, + description: command.description, + argumentHint: command.argumentHint, + source: "sdk", + }); + }; + for (const command of discoverClaudeSlashCommands(workspaceRoot)) add(command); + for (const command of discoverCodexSlashCommands(workspaceRoot)) add(command); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); +} + export async function getAvailableModels( connection: AdeCodeConnection, provider: AgentChatProvider, diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 974ed453..0d46a160 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -27,6 +27,7 @@ import { DEFAULT_CODEX_REASONING_EFFORT, approveToolUse, createChatSession, + discoverProjectSlashCommands, getAvailableModels, getAiSettingsStatus, getChatHistory, @@ -50,7 +51,7 @@ import { connectToAde } from "./connection"; import { Drawer } from "./components/Drawer"; import { ChatView } from "./components/ChatView"; import { Header } from "./components/Header"; -import { RightPane } from "./components/RightPane"; +import { LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; import { SlashPalette } from "./components/SlashPalette"; import { MentionPalette } from "./components/MentionPalette"; import { ApprovalPrompt } from "./components/ApprovalPrompt"; @@ -60,6 +61,7 @@ import { theme } from "./theme"; import { chooseInitialLane } from "./project"; import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from "./format"; import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; +import { loadAdeCodeState, saveAdeCodeState } from "./state"; import { buildLinearToolRequest } from "./linearCommands"; import { buildPendingInputAnswers, latestPendingApproval } from "./pendingInput"; import type { @@ -656,6 +658,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const [streaming, setStreaming] = useState(false); const [clearedAt, setClearedAt] = useState(null); const [expandedLineIds, setExpandedLineIds] = useState>(() => new Set()); + const [chatScrollOffsetRows, setChatScrollOffsetRows] = useState(0); const [mentionSuggestions, setMentionSuggestions] = useState([]); const [mentionIndex, setMentionIndex] = useState(0); const [selectedMentions, setSelectedMentions] = useState([]); @@ -680,29 +683,62 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const promptRef = useRef(""); const lastLocalSendAtRef = useRef(0); const eventCountRef = useRef(0); + const chatScrollOffsetRowsRef = useRef(0); const heartbeatRef = useRef(null); const draftSeededFromHistoryRef = useRef(false); const attachProbeInFlightRef = useRef(false); + const lastChatByLaneRef = useRef>(new Map(Object.entries(loadAdeCodeState().lastChatByLane))); + const lastChatByLaneWriteTimerRef = useRef(null); + + const persistLastChatByLane = useCallback(() => { + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + } + lastChatByLaneWriteTimerRef.current = setTimeout(() => { + lastChatByLaneWriteTimerRef.current = null; + const lastChatByLane: Record = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeState({ lastChatByLane }); + }, 500); + }, []); + + const setChatScrollOffset = useCallback((value: number | ((previous: number) => number)) => { + setChatScrollOffsetRows((previous) => { + const next = Math.max(0, typeof value === "function" ? value(previous) : value); + chatScrollOffsetRowsRef.current = next; + return next; + }); + }, []); const selectActiveLaneId = useCallback((laneId: string | null) => { + if (activeLaneIdRef.current !== laneId) setChatScrollOffset(0); activeLaneIdRef.current = laneId; setActiveLaneId(laneId); - }, []); + }, [setChatScrollOffset]); const selectActiveSessionId = useCallback((sessionId: string | null) => { + if (activeSessionIdRef.current !== sessionId) setChatScrollOffset(0); if (sessionId) { draftChatActiveRef.current = false; setDraftChatActive(false); setSelectedDrawerChatAction(null); + const laneId = activeLaneIdRef.current; + if (laneId && lastChatByLaneRef.current.get(laneId) !== sessionId) { + lastChatByLaneRef.current.set(laneId, sessionId); + persistLastChatByLane(); + } } activeSessionIdRef.current = sessionId; setActiveSessionId(sessionId); - }, []); + }, [persistLastChatByLane, setChatScrollOffset]); const setDraftChatMode = useCallback((active: boolean) => { + setChatScrollOffset(0); draftChatActiveRef.current = active; setDraftChatActive(active); - }, []); + }, [setChatScrollOffset]); const setPaneFocus = useCallback((pane: PaneFocus) => { activePaneRef.current = pane; @@ -895,6 +931,119 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } }, [activeLane?.name, activeLaneId, aiStatusCheckedAt, mode, modelSetupRows, modelState.provider, newChatSetupRows, providerReadinessRows, rightPane.kind]); + useEffect(() => { + if (activePane !== "details" || !rightOpen) return; + if (!activeLane || !activeLaneId) return; + if (rightPane.kind !== "empty" && rightPane.kind !== "lane-details") return; + + let cancelled = false; + const lane = activeLane; + const laneId = activeLaneId; + + const refresh = async () => { + const conn = connectionRef.current; + if (!conn) return; + try { + const [syncRes, changesRes, prsRes] = await Promise.all([ + conn.action<{ ahead?: number; behind?: number; upstreamRef?: string | null }>("git", "getSyncStatus", { laneId }).catch(() => null), + conn.actionList<{ staged: { path: string; kind: string }[]; unstaged: { path: string; kind: string }[] }>("diff", "getChanges", [laneId]).catch(() => null), + conn.action>>("pr", "listAll", { laneId }).catch(() => [] as Array>), + ]); + if (cancelled) return; + + const ahead = typeof syncRes?.ahead === "number" ? syncRes.ahead : 0; + const behind = typeof syncRes?.behind === "number" ? syncRes.behind : 0; + const remote = typeof syncRes?.upstreamRef === "string" ? syncRes.upstreamRef : null; + + const staged = changesRes?.staged ?? []; + const unstaged = changesRes?.unstaged ?? []; + const fileMap = new Map(); + const toStatus = (kind: string): "M" | "A" | "D" | "?" => { + if (kind === "added" || kind === "untracked") return kind === "untracked" ? "?" : "A"; + if (kind === "deleted") return "D"; + if (kind === "modified" || kind === "renamed") return "M"; + return "?"; + }; + for (const file of staged) { + fileMap.set(file.path, { path: file.path, status: toStatus(file.kind), staged: true }); + } + for (const file of unstaged) { + if (!fileMap.has(file.path)) { + fileMap.set(file.path, { path: file.path, status: toStatus(file.kind), staged: false }); + } + } + const files = [...fileMap.values()]; + + const activePr = prsRes[0] ?? null; + let pr: { number: number; state: "open" | "closed" | "merged"; url: string; checksPassed: number; checksTotal: number } | null = null; + if (activePr) { + const number = typeof activePr.githubPrNumber === "number" + ? activePr.githubPrNumber + : typeof activePr.number === "number" + ? activePr.number + : null; + const url = typeof activePr.githubUrl === "string" + ? activePr.githubUrl + : typeof activePr.url === "string" + ? activePr.url + : ""; + const rawState = typeof activePr.state === "string" ? activePr.state : "open"; + const state: "open" | "closed" | "merged" = + rawState === "merged" ? "merged" : rawState === "closed" ? "closed" : "open"; + const prId = typeof activePr.id === "string" ? activePr.id : typeof activePr.prId === "string" ? activePr.prId : ""; + let checksPassed = 0; + let checksTotal = 0; + if (prId) { + const checks = await conn.actionList>("pr", "getChecks", [prId]).catch(() => null); + if (!cancelled && Array.isArray(checks)) { + checksTotal = checks.length; + checksPassed = checks.filter((check) => check.status === "completed" && check.conclusion === "success").length; + } + } + if (number != null && url) { + pr = { number, state, url, checksPassed, checksTotal }; + } + } + + if (cancelled) return; + setRightPane((prev) => { + if (cancelled) return prev; + if (prev.kind !== "lane-details" && prev.kind !== "empty") return prev; + const previousIndex = prev.kind === "lane-details" ? prev.selectedActionIndex : 0; + const previousShowFiles = prev.kind === "lane-details" ? prev.showFiles : false; + const maxIndex = LANE_DETAIL_ACTIONS.length - 1 + (pr ? 1 : 0); + return { + kind: "lane-details", + lane, + git: { + staged: staged.length, + unstaged: unstaged.length, + total: files.length, + ahead, + behind, + remote, + }, + files, + pr, + showFiles: previousShowFiles, + selectedActionIndex: Math.max(0, Math.min(previousIndex, maxIndex)), + }; + }); + } catch { + // best-effort — leave the existing pane content alone on transient errors + } + }; + + void refresh(); + const interval = setInterval(() => { + void refresh(); + }, 5000); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [activeLane, activeLaneId, activePane, rightOpen, rightPane.kind]); + useEffect(() => { if (!drawerLaneId || !lanes.some((lane) => lane.id === drawerLaneId)) { setDrawerLaneId(activeLaneId); @@ -1183,7 +1332,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } const configSession = nextSession ?? (!draftSeededFromHistoryRef.current ? seedSession : null); const nextProvider = configSession?.provider ?? modelState.provider ?? "codex"; - const nextCommands = await getSlashCommands(conn, nextSessionId).catch(() => []); + const commandSessionId = nextSessionId ?? configSession?.sessionId ?? null; + const remoteCommands = commandSessionId ? await getSlashCommands(conn, commandSessionId).catch(() => []) : []; + const projectCommands = discoverProjectSlashCommands(project.workspaceRoot); + const nextCommands = remoteCommands.length ? remoteCommands : projectCommands; const nextModels = await getAvailableModels(conn, nextProvider).catch(() => []); const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) ?? nextModels.find((model) => model.isDefault) @@ -1273,6 +1425,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } cancelled = true; heartbeatRef.current?.stop(); heartbeatRef.current = null; + if (lastChatByLaneWriteTimerRef.current) { + clearTimeout(lastChatByLaneWriteTimerRef.current); + lastChatByLaneWriteTimerRef.current = null; + const lastChatByLane: Record = {}; + for (const [laneId, sessionId] of lastChatByLaneRef.current) { + lastChatByLane[laneId] = sessionId; + } + saveAdeCodeState({ lastChatByLane }); + } const conn = connectionRef.current; connectionRef.current = null; void conn?.close().catch(() => {}); @@ -1798,7 +1959,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } : result; setRightPane({ kind: "details", title: `ADE ${domain}.${action}`, body: renderObject(body, 24) }); } - }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions]); + }, [activeLane?.name, activeSession?.sessionId, activeSession?.title, addNotice, ensureActiveSession, focusDetails, lanes, mode, modelState.modelId, models, openForm, openModelSetup, openNewChatSetup, openNewLaneForm, project, refreshState, selectActiveLaneId, selectActiveSessionId, sessions, setChatScrollOffset]); const runInlineCommand = useCallback(async (name: string, args: string) => { const conn = connectionRef.current; @@ -1812,6 +1973,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (name === "/clear") { setClearedAt(new Date().toISOString()); setEvents([]); + setChatScrollOffset(0); addNotice("Local transcript view cleared. The durable chat remains in ADE.", "info"); return; } @@ -1884,6 +2046,24 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Push complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); return; } + if (name === "/pull") { + if (!laneId) { + addNotice("No active lane is selected.", "error"); + return; + } + const result = await conn.action("git", "pull", { laneId }); + addNotice(`Pull complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } + if (name === "/stage all") { + if (!laneId) { + addNotice("No active lane is selected.", "error"); + return; + } + const result = await conn.action("git", "stageAll", { laneId }); + addNotice(`Stage all complete: ${renderObject(result, 4).replace(/\n/g, " ")}`, "success"); + return; + } if (name === "/remember") { if (!args) { addNotice("Usage: /remember ", "error"); @@ -1943,7 +2123,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(result.message ?? "Desktop route unavailable from this runtime.", "error"); } } - }, [activeSession?.provider, addNotice, exit, loadProviderModels, modelState.provider, project, refreshAiSetupStatus, refreshState, socketPath]); + }, [activeSession?.provider, addNotice, exit, loadProviderModels, modelState.provider, project, refreshAiSetupStatus, refreshState, setChatScrollOffset, socketPath]); const submitRightForm = useCallback(async ( form: Extract, @@ -2030,6 +2210,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } setPrompt(""); promptRef.current = ""; + setChatScrollOffset(0); if (activePaneRef.current === "chat") { chatDraftRef.current = ""; } @@ -2124,7 +2305,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setError(message); addNotice(message, "error"); } - }, [activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); + }, [activeFormField, addNotice, answerPendingInput, ensureActiveSession, formValues, pendingApproval, refreshState, resolvePendingApproval, rightPane, runInlineCommand, runRightCommand, selectedMentions, setChatScrollOffset, slashCommands, slashIndex, slashRows, streaming, submitRightForm]); const insertMention = useCallback((suggestion: MentionSuggestion) => { const range = activeMention(prompt); @@ -2452,6 +2633,70 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + if (pane === "details" && rightOpen && rightPane.kind === "lane-details") { + const laneDetails = rightPane; + const maxIndex = LANE_DETAIL_ACTIONS.length - 1 + (laneDetails.pr ? 1 : 0); + if (key.upArrow) { + setRightPane((prev) => prev.kind === "lane-details" + ? { ...prev, selectedActionIndex: Math.max(0, prev.selectedActionIndex - 1) } + : prev); + return; + } + if (key.downArrow) { + setRightPane((prev) => prev.kind === "lane-details" + ? { ...prev, selectedActionIndex: Math.min(maxIndex, prev.selectedActionIndex + 1) } + : prev); + return; + } + if (input === "t" && !key.ctrl && !key.meta) { + setRightPane((prev) => prev.kind === "lane-details" ? { ...prev, showFiles: !prev.showFiles } : prev); + return; + } + if (key.return) { + const index = laneDetails.selectedActionIndex; + if (index < LANE_DETAIL_ACTIONS.length) { + const action = LANE_DETAIL_ACTIONS[index]; + if (action) { + const text = action.slashCommand === "/commit" ? `${action.slashCommand} ` : action.slashCommand; + setPrompt(text); + promptRef.current = text; + chatDraftRef.current = text; + focusChat(); + } + return; + } + if (laneDetails.pr) { + const url = laneDetails.pr.url; + const bridge = (globalThis as { window?: { ade?: { app?: { openExternal?: (url: string) => unknown } } } }).window; + const opener = bridge?.ade?.app?.openExternal; + if (typeof opener === "function") { + try { + opener(url); + addNotice("Opening PR in browser…", "info"); + return; + } catch { + // fall through to platform open + } + } + if (process.platform === "darwin" && url) { + spawn("open", [url], { stdio: "ignore", detached: true }).unref(); + addNotice("Opening PR in browser…", "info"); + return; + } + if (process.platform === "linux" && url) { + spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref(); + addNotice("Opening PR in browser…", "info"); + return; + } + setPrompt("/pr open"); + promptRef.current = "/pr open"; + void submitPrompt("/pr open"); + return; + } + return; + } + } + if (pane === "details" && rightOpen && (rightPane.kind === "models" || rightPane.kind === "effort" || (rightPane.kind === "list" && rightPane.action)) && key.upArrow) { const max = rightPane.kind === "models" ? rightPane.models.length @@ -2539,6 +2784,30 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return; } + const pageUp = Boolean((key as { pageUp?: boolean }).pageUp); + const pageDown = Boolean((key as { pageDown?: boolean }).pageDown); + const home = Boolean((key as { home?: boolean }).home); + const end = Boolean((key as { end?: boolean }).end); + if (pane === "chat" && !activeMentionRange && !slashRows.length) { + const pageRows = Math.max(1, chatRowBudget - 2); + if (pageUp || (key.ctrl && input === "u")) { + setChatScrollOffset((offset) => offset + (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + return; + } + if (pageDown || (key.ctrl && input === "d")) { + setChatScrollOffset((offset) => offset - (key.ctrl ? Math.max(1, Math.floor(pageRows / 2)) : pageRows)); + return; + } + if (home) { + setChatScrollOffset((offset) => Math.max(offset, 100_000)); + return; + } + if (end) { + setChatScrollOffset(0); + return; + } + } + if (pane === "chat" && key.upArrow && activeMentionRange && mentionSuggestions.length) { setMentionIndex((index) => (index <= 0 ? mentionSuggestions.length - 1 : index - 1)); return; @@ -2579,6 +2848,11 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const lane = drawerLaneRows[nextIndex] ?? null; setSelectedDrawerLaneAction(lane ? null : "new-lane"); setSelectedDrawerLaneId(lane?.id ?? null); + } else if (selectedChatIndex <= 0) { + setDrawerSection("lanes"); + const lastLane = drawerLaneRows[drawerLaneRows.length - 1] ?? null; + setSelectedDrawerLaneAction("new-lane"); + setSelectedDrawerLaneId(lastLane?.id ?? null); } else { const nextIndex = Math.max(0, selectedChatIndex - 1); const session = drawerLaneSessions[nextIndex] ?? null; @@ -2589,10 +2863,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } } if (pane === "drawer" && drawerOpen && key.downArrow) { if (drawerSection === "lanes") { - const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); - const lane = drawerLaneRows[nextIndex] ?? null; - setSelectedDrawerLaneAction(lane ? null : "new-lane"); - setSelectedDrawerLaneId(lane?.id ?? null); + if (selectedLaneIndex >= drawerLaneRows.length) { + setDrawerSection("chats"); + const firstSession = drawerLaneSessions[0] ?? null; + setSelectedDrawerChatAction(firstSession ? null : "new-chat"); + setSelectedDrawerChatId(firstSession?.sessionId ?? null); + } else { + const nextIndex = Math.min(drawerLaneRows.length, selectedLaneIndex + 1); + const lane = drawerLaneRows[nextIndex] ?? null; + setSelectedDrawerLaneAction(lane ? null : "new-lane"); + setSelectedDrawerLaneId(lane?.id ?? null); + } } else { const nextIndex = Math.min(drawerLaneSessions.length, selectedChatIndex + 1); const session = drawerLaneSessions[nextIndex] ?? null; @@ -2614,11 +2895,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setDrawerLaneId(lane.id); setSelectedDrawerLaneId(lane.id); setSelectedDrawerLaneAction(null); - const session = newestSession(sessions.filter((entry) => entry.laneId === lane.id)); + const laneSessions = sessions.filter((entry) => entry.laneId === lane.id); + const lastSessionId = lastChatByLaneRef.current.get(lane.id); + const session = + laneSessions.find((s) => s.sessionId === lastSessionId) + ?? newestSession(laneSessions); selectActiveSessionId(session?.sessionId ?? null); setSelectedDrawerChatId(session?.sessionId ?? null); - setSelectedDrawerChatAction(null); - setDrawerSection(session ? "chats" : "lanes"); + setSelectedDrawerChatAction(session ? null : "new-chat"); + setDrawerSection("chats"); addNotice(`Switched to lane ${lane.name}.`, "success"); } } else { @@ -2736,6 +3021,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } lane={activeLane} expandedLineIds={expandedLineIds} maxRows={chatRowBudget} + scrollOffsetRows={chatScrollOffsetRows} + width={centerWidth} /> diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 4d33df99..3146a80c 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -12,6 +12,8 @@ export type BuiltinCommand = { export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/commit", description: "Commit lane changes", placement: "inline", argumentHint: "[message]" }, { name: "/push", description: "Push the active lane branch", placement: "inline" }, + { name: "/pull", description: "Pull the active lane branch", placement: "inline" }, + { name: "/stage all", description: "Stage all changes in the active lane", placement: "inline" }, { name: "/clear", description: "Clear the local terminal transcript view", placement: "inline" }, { name: "/end", description: "End the active chat runtime", placement: "inline" }, { name: "/login", description: "Sign in to the active CLI-backed provider from this terminal", placement: "inline" }, @@ -68,10 +70,15 @@ function normalizeSlashName(value: string): string { return value.trim().replace(/\s+/g, " "); } +function slashCommandKey(value: string): string { + return normalizeSlashName(value).toLowerCase(); +} + export function parseCommand(input: string, userCommands: AgentChatSlashCommand[] = []): ParsedCommand | null { const trimmed = input.trim(); if (!trimmed.startsWith("/")) return null; const [first = ""] = trimmed.split(/\s+/, 1); + const firstKey = slashCommandKey(first); const candidates = [...BUILTIN_COMMANDS] .sort((left, right) => right.name.length - left.name.length); @@ -89,13 +96,13 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ } } - const exactUserCommand = userCommands.find((command) => command.name === first) ?? null; + const exactUserCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; const adeOwnedSingleWordCommand = candidates.find((command) => - command.name === first && ADE_OWNED_SINGLE_WORD_COMMANDS.has(command.name) + slashCommandKey(command.name) === firstKey && ADE_OWNED_SINGLE_WORD_COMMANDS.has(command.name) ); if (adeOwnedSingleWordCommand) { return { - name: first, + name: adeOwnedSingleWordCommand.name, args: trimmed.slice(first.length).trim(), spec: adeOwnedSingleWordCommand, userCommand: null, @@ -104,7 +111,7 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ if (exactUserCommand) { return { - name: first, + name: exactUserCommand.name, args: trimmed.slice(first.length).trim(), spec: null, userCommand: exactUserCommand, @@ -123,10 +130,10 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ } } - const userCommand = userCommands.find((command) => command.name === first) ?? null; + const userCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; if (userCommand) { return { - name: first, + name: userCommand.name, args: trimmed.slice(first.length).trim(), spec: null, userCommand, @@ -162,8 +169,8 @@ export function paletteCommands( // Dedupe by name: when both ADE and a runtime/user catalog define the same // command, prefer the runtime/user entry so SDK-native behavior wins. const byName = new Map(); - for (const command of builtins) byName.set(command.name, command); - for (const command of users) byName.set(command.name, command); + for (const command of builtins) byName.set(slashCommandKey(command.name), command); + for (const command of users) byName.set(slashCommandKey(command.name), command); const merged = [...byName.values()]; const filtered = !queryToken ? merged diff --git a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx index 195f57fe..efa7fbe1 100644 --- a/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx +++ b/apps/ade-cli/src/tuiClient/components/AdeWordmark.tsx @@ -3,13 +3,13 @@ import { Box, Text } from "ink"; import { theme } from "../theme"; const ROWS = [ - "█▀█ █▀▄ █▀▀", - "█▀█ █ █ █▀ ", - "▀ ▀ ▀▀▀ ▀▀▀", + " ████ █████ ██████", + "██ ██ ██ ██ ██ ", + "██████ ██ ██ █████ ", + "██ ██ ██ ██ ██ ", + "██ ██ █████ ██████", ]; -const SHADOW = " ▒▒ ▒▒ ▒▒"; - export function AdeWordmark() { return ( @@ -18,9 +18,6 @@ export function AdeWordmark() { {row} ))} - - {SHADOW} - ); } diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index a1e240df..6381c748 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -3,51 +3,108 @@ import { Box, Text } from "ink"; import type { AgentChatEventEnvelope, AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import type { LocalNotice } from "../types"; -import { renderChatLines, type RenderedChatLine } from "../format"; +import { renderChatLines, type AssistantMarkdownBlock, type RenderedChatLine } from "../format"; import { theme } from "../theme"; import { AdeWordmark } from "./AdeWordmark"; import { laneIconGlyph } from "./Header"; -function estimatedRows(line: RenderedChatLine): number { - const bodyRows = Math.max(1, line.body.split(/\r?\n/).length); - let rows = bodyRows + (line.header ? 1 : 0); - if (line.tone === "user") rows += 2; // round border adds 2 rows - return rows; +const HERO_TARGET_HALO_WIDTH = 56; +const HERO_MIN_HALO_WIDTH = 28; +const HERO_WORDMARK_MIN_USABLE = 24; +const DEFAULT_VIEW_WIDTH = 88; + +type RenderedChatRow = { + id: string; + text: string; + tone: RenderedChatLine["tone"] | "indicator"; + color?: string; + dim?: boolean; + bold?: boolean; +}; + +function textWidth(value: string): number { + return [...value].length; +} + +function repeat(value: string, count: number): string { + return value.repeat(Math.max(0, count)); } -function fitToRows(lines: RenderedChatLine[], maxRows?: number): RenderedChatLine[] { - if (!maxRows || maxRows <= 0) return lines; - const visible: RenderedChatLine[] = []; - let rows = 0; - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]!; - const nextRows = estimatedRows(line); - if (visible.length && rows + nextRows > maxRows) break; - visible.unshift(line); - rows += nextRows; +function padRight(value: string, width: number): string { + return `${value}${repeat(" ", width - textWidth(value))}`; +} + +function alignRight(value: string, width: number): string { + return `${repeat(" ", width - textWidth(value))}${value}`; +} + +function hardWrapWord(word: string, width: number): string[] { + if (width <= 1) return [word]; + const chars = [...word]; + const chunks: string[] = []; + for (let index = 0; index < chars.length; index += width) { + chunks.push(chars.slice(index, index + width).join("")); } - return visible; + return chunks; } -const HERO_INNER_WIDTH = 48; -const HERO_DIVIDER = "─".repeat(HERO_INNER_WIDTH); +function wrapText(value: string, width: number, firstPrefix = "", restPrefix = firstPrefix): string[] { + const availableFirst = Math.max(1, width - textWidth(firstPrefix)); + const availableRest = Math.max(1, width - textWidth(restPrefix)); + const rows: string[] = []; + for (const rawLine of value.split(/\r?\n/)) { + const words = rawLine.trim().split(/\s+/).filter(Boolean); + if (!words.length) { + rows.push(firstPrefix); + continue; + } + let prefix = firstPrefix; + let limit = availableFirst; + let current = ""; + for (const word of words) { + if (textWidth(word) > limit) { + if (current) { + rows.push(`${prefix}${current}`); + prefix = restPrefix; + limit = availableRest; + current = ""; + } + const chunks = hardWrapWord(word, limit); + for (const chunk of chunks.slice(0, -1)) { + rows.push(`${prefix}${chunk}`); + prefix = restPrefix; + limit = availableRest; + } + current = chunks[chunks.length - 1] ?? ""; + continue; + } + const next = current ? `${current} ${word}` : word; + if (textWidth(next) > limit && current) { + rows.push(`${prefix}${current}`); + prefix = restPrefix; + limit = availableRest; + current = word; + } else { + current = next; + } + } + if (current) rows.push(`${prefix}${current}`); + } + return rows; +} -function HeroDivider() { - return {HERO_DIVIDER}; +function HeroDivider({ width }: { width: number }) { + return {"─".repeat(Math.max(4, width))}; } -function HeroSuggestion({ command, label }: { command: string; label: string }) { +function HeroMetaRow({ label, value, color }: { label: string; value: string; color?: string }) { return ( - - - {command} - {label ? ( - <> - - {label} - - ) : null} - + + + {label} + + {value} + ); } @@ -55,138 +112,224 @@ export function BootHero({ projectName, laneName, lane, + width = DEFAULT_VIEW_WIDTH, }: { projectName: string; laneName: string; lane?: LaneSummary | null; + width?: number; }) { const laneColor = theme.lane(lane ?? null); const laneGlyph = laneIconGlyph(lane?.icon ?? null); - const showProject = projectName.trim() && projectName.trim().toLowerCase() !== "ade"; + const trimmedProject = projectName.trim(); + const projectLabel = trimmedProject || "—"; + const branchLabel = lane?.branchRef?.trim() || "—"; + + // Outer halo border + inner card border = 4 chars of horizontal chrome. + // Card border 2 + paddingX 4 + inner paddingX 2 = 8 chars between halo edge + // and content. Clamp so we don't blow out narrow terminals. + const haloWidth = Math.max(HERO_MIN_HALO_WIDTH, Math.min(HERO_TARGET_HALO_WIDTH, width - 2)); + const cardWidth = haloWidth - 4; + const usableWidth = Math.max(4, cardWidth - 8); + const showWordmark = usableWidth >= HERO_WORDMARK_MIN_USABLE; + return ( - - - - - ade code - · v0.1 - - - {laneGlyph} {laneName} - {lane?.branchRef ? ( - ⎇ {lane.branchRef} - ) : null} - {!lane?.branchRef && showProject ? ( - {projectName} - ) : null} + + + + {showWordmark ? ( + + ) : ( + A · D · E + )} + + + ade code + · v0.1 + + + + + + + + + + type to chat + + + / + commands + @ + files + ? + help + - - - - type to chat - - - / - commands - @ - files - ? - help - - - - - - - - ); } -type ChatLineProps = { - line: RenderedChatLine; - prevTone: RenderedChatLine["tone"] | null; -}; +function markdownRows(blocks: AssistantMarkdownBlock[], width: number, id: string): RenderedChatRow[] { + const rows: RenderedChatRow[] = []; + const pushWrapped = ( + text: string, + firstPrefix = "", + restPrefix = firstPrefix, + options: Partial = {}, + ) => { + for (const wrapped of wrapText(text, width, firstPrefix, restPrefix)) { + rows.push({ id, tone: "assistant", text: wrapped, color: theme.color.fg, ...options }); + } + }; -function ChatLineComponent({ line, prevTone }: ChatLineProps) { + for (const block of blocks) { + if (rows.length) rows.push({ id, tone: "assistant", text: "" }); + if (block.kind === "heading") { + pushWrapped(block.text, "", "", { color: theme.color.accent, bold: true }); + continue; + } + if (block.kind === "bullet") { + pushWrapped(block.text, "• ", " "); + continue; + } + if (block.kind === "numbered") { + const prefix = `${block.number}. `; + pushWrapped(block.text, prefix, repeat(" ", textWidth(prefix))); + continue; + } + if (block.kind === "quote") { + pushWrapped(block.text, "> ", "> ", { dim: true }); + continue; + } + if (block.kind === "code") { + const label = block.language ? ` ${block.language}` : ""; + rows.push({ id, tone: "assistant", text: ` ┌${repeat("─", Math.max(1, Math.min(width - 5, 24)))}${label}`, color: theme.color.border, dim: true }); + for (const codeLine of block.lines.length ? block.lines : [""]) { + const available = Math.max(1, width - 4); + const chunks = hardWrapWord(codeLine || " ", available); + for (const chunk of chunks) { + rows.push({ id, tone: "assistant", text: ` │ ${chunk}`, color: theme.color.tool, dim: true }); + } + } + rows.push({ id, tone: "assistant", text: " └", color: theme.color.border, dim: true }); + continue; + } + if (block.kind === "hr") { + rows.push({ id, tone: "assistant", text: repeat("─", Math.min(width, 72)), color: theme.color.border, dim: true }); + continue; + } + pushWrapped(block.text); + } + return rows; +} + +function rowsForLine(line: RenderedChatLine, prevTone: RenderedChatLine["tone"] | null, width: number): RenderedChatRow[] { const isChatTurn = line.tone === "user" || line.tone === "assistant"; const speakerChanged = prevTone !== line.tone; const showSpacer = isChatTurn && speakerChanged && prevTone !== null; + const rows: RenderedChatRow[] = []; + const push = (row: Omit) => rows.push({ id: line.id, ...row }); + if (showSpacer) push({ tone: line.tone, text: "" }); if (line.tone === "user") { - return ( - - {showSpacer ? : null} - {line.header ? ( - - {line.header} - - ) : null} - - - {line.body} - - - - ); + const bubbleWidth = Math.max(12, Math.min(width - 4, 78)); + const contentWidth = Math.max(1, bubbleWidth - 4); + if (line.header) push({ tone: "user", text: alignRight(line.header, width), dim: true }); + const bodyRows = wrapText(line.body, contentWidth); + push({ tone: "user", text: alignRight(`╭${repeat("─", bubbleWidth - 2)}╮`, width), color: theme.color.accent }); + for (const bodyRow of bodyRows) { + push({ tone: "user", text: alignRight(`│ ${padRight(bodyRow, contentWidth)} │`, width), color: theme.color.fg }); + } + push({ tone: "user", text: alignRight(`╰${repeat("─", bubbleWidth - 2)}╯`, width), color: theme.color.accent }); + return rows; } if (line.tone === "tool" || line.tone === "error") { const isErrorTone = line.tone === "error"; - const lines = line.body.split(/\r?\n/); - return ( - - {lines.map((text, index) => ( - - {text} - - ))} - - ); + for (const text of line.body.split(/\r?\n/)) { + for (const wrapped of wrapText(text, width, " ", " ")) { + push({ tone: line.tone, text: wrapped, color: isErrorTone ? theme.color.danger : theme.color.tool, dim: !isErrorTone }); + } + } + return rows; } if (line.tone === "reasoning" || line.tone === "notice" || line.tone === "approval") { - return ( - - {line.header ? {line.header} : null} - {line.body} - - ); + if (line.header) push({ tone: line.tone, text: line.header, color: theme.tone(line.tone), dim: true }); + for (const wrapped of wrapText(line.body, width)) { + push({ tone: line.tone, text: wrapped, color: theme.tone(line.tone), dim: line.tone !== "approval" }); + } + return rows; } // assistant + if (line.header) push({ tone: "assistant", text: line.header, dim: true }); + if (line.blocks?.length) { + rows.push(...markdownRows(line.blocks, width, line.id)); + } else { + for (const wrapped of wrapText(line.body, width)) { + push({ tone: "assistant", text: wrapped, color: theme.color.fg }); + } + } + return rows; +} + +function rowsForLines(lines: RenderedChatLine[], width: number): RenderedChatRow[] { + return lines.flatMap((line, index) => rowsForLine(line, index > 0 ? lines[index - 1]!.tone : null, width)); +} + +function sliceRows(rows: RenderedChatRow[], maxRows?: number, scrollOffsetRows = 0): RenderedChatRow[] { + if (!maxRows || maxRows <= 0 || rows.length <= maxRows) return rows; + let offset = Math.max(0, Math.min(scrollOffsetRows, rows.length - maxRows)); + for (let attempt = 0; attempt < 2; attempt += 1) { + const end = rows.length - offset; + const start = Math.max(0, end - maxRows); + const hasOlder = start > 0; + const hasNewer = end < rows.length; + const contentRows = Math.max(1, maxRows - (hasOlder ? 1 : 0) - (hasNewer ? 1 : 0)); + const nextEnd = rows.length - offset; + const nextStart = Math.max(0, nextEnd - contentRows); + const nextHasOlder = nextStart > 0; + const nextHasNewer = nextEnd < rows.length; + if (nextHasOlder === hasOlder && nextHasNewer === hasNewer) { + const visible = rows.slice(nextStart, nextEnd); + return [ + ...(nextHasOlder ? [{ id: "older-indicator", tone: "indicator" as const, text: "↑ older messages", dim: true }] : []), + ...visible, + ...(nextHasNewer ? [{ id: "newer-indicator", tone: "indicator" as const, text: "↓ newer messages", dim: true }] : []), + ]; + } + offset = Math.max(0, Math.min(offset, rows.length - contentRows)); + } + return rows.slice(-maxRows); +} + +function ChatRow({ row }: { row: RenderedChatRow }) { return ( - - {showSpacer ? : null} - {line.header ? ( - {line.header} - ) : null} - {line.body} - + + {row.text} + ); } -const ChatLine = React.memo(ChatLineComponent, (prev, next) => ( - prev.line === next.line && prev.prevTone === next.prevTone -)); - export function ChatView({ events, notices, @@ -196,6 +339,8 @@ export function ChatView({ lane, expandedLineIds, maxRows, + scrollOffsetRows = 0, + width = DEFAULT_VIEW_WIDTH, }: { events: AgentChatEventEnvelope[]; notices: LocalNotice[]; @@ -205,15 +350,18 @@ export function ChatView({ lane?: LaneSummary | null; expandedLineIds?: Set; maxRows?: number; + scrollOffsetRows?: number; + width?: number; }) { - const lines = fitToRows(renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 80 }), maxRows); + const lines = renderChatLines({ events, notices, activeSession, expandedLineIds, maxLines: 200 }); if (!lines.length) { - return ; + return ; } + const rows = sliceRows(rowsForLines(lines, Math.max(24, width - 2)), maxRows, scrollOffsetRows); return ( - {lines.map((line, index) => ( - 0 ? lines[index - 1]!.tone : null} /> + {rows.map((row, index) => ( + ))} ); diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index 345266df..80a7bfd0 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Text } from "ink"; +import { Box, Text, useStdout } from "ink"; import type { AgentChatSessionSummary } from "../../../../desktop/src/shared/types/chat"; import type { LaneSummary } from "../../../../desktop/src/shared/types/lanes"; import { formatLaneLabel, formatSessionLabel } from "../format"; @@ -26,33 +26,47 @@ export function Drawer({ selectedChatIndex: number; focused?: boolean; }) { - const browsingLane = lanes.find((lane) => lane.id === browsingLaneId) ?? null; + const { stdout } = useStdout(); + const panelHeight = stdout?.rows ?? 40; const laneSessions = sessions.filter((session) => session.laneId === browsingLaneId).slice(0, 12); - const laneRows = lanes.slice(0, 10); + // Adaptive 50% cap: LANES section uses up to half the column height (header + rows + "+ new lane"). + const lanesMaxRows = Math.max(2, Math.floor(panelHeight / 2) - 3); + const laneRows = lanes.slice(0, Math.min(10, lanesMaxRows)); return ( - LANES{focused ? " · focused" : ""} - {laneRows.map((lane, index) => ( - - {index === selectedLaneIndex ? "›" : " "} {lane.id === activeLaneId ? "●" : lane.id === browsingLaneId ? "◐" : "○"} {formatLaneLabel(lane).slice(0, 20)} + + LANES + {laneRows.map((lane, index) => ( + + {index === selectedLaneIndex ? "›" : " "} {lane.id === activeLaneId ? "●" : lane.id === browsingLaneId ? "◐" : "○"} {formatLaneLabel(lane).slice(0, 20)} + + ))} + + {selectedLaneIndex === laneRows.length ? "›" : " "} + new lane - ))} - - {selectedLaneIndex === laneRows.length ? "›" : " "} + new lane - - {"─".repeat(24)} - CHATS · {browsingLane?.name ?? "no lane"} - {laneSessions.length === 0 ? ( - No chats in lane. - ) : laneSessions.map((session, index) => ( - - {index === selectedChatIndex ? "›" : " "} {session.sessionId === activeSessionId ? "●" : " "} {formatSessionLabel(session).slice(0, 20)} + + + CHATS + {laneSessions.length === 0 ? ( + No chats in lane. + ) : laneSessions.map((session, index) => ( + + {index === selectedChatIndex ? "›" : " "} {formatSessionLabel(session).slice(0, 22)} + + ))} + + {selectedChatIndex === laneSessions.length ? "›" : " "} + new chat - ))} - - {selectedChatIndex === laneSessions.length ? "›" : " "} + new chat - - enter switches · + opens details + ); } diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 3f339e64..827e2eb0 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -9,6 +9,13 @@ const STATUS_DOT: Record = { unavailable: "○", }; +export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ label: string; slashCommand: string }> = [ + { label: "stage all", slashCommand: "/stage all" }, + { label: "commit", slashCommand: "/commit" }, + { label: "push", slashCommand: "/push" }, + { label: "pull", slashCommand: "/pull" }, +]; + function statusColor(status: ProviderReadinessRow["status"]): string { if (status === "ready") return theme.color.success; if (status === "unknown") return theme.color.warning; @@ -48,9 +55,10 @@ export function RightPane({ selectedIndex?: number; focused?: boolean; }) { + const paneTitle = content.kind === "lane-details" ? content.lane.name.toUpperCase() : "SETUP"; return ( - SETUP{focused ? " · focused" : ""} + {paneTitle}{focused ? " · focused" : ""} {content.kind === "empty" ? ( Run /status, /diff, /model, or /help. ) : null} @@ -102,6 +110,54 @@ export function RightPane({ arrows move · enter applies ) : null} + {content.kind === "lane-details" ? ( + + {content.lane.branchRef} + + {content.git.staged + content.git.unstaged > 0 ? "DIRTY" : "CLEAN"} ↑{content.git.ahead} ↓{content.git.behind} + + {content.git.remote ? {content.git.remote} : null} + + + + Changes + (t to toggle) + + {content.showFiles ? ( + content.files.length ? ( + content.files.slice(0, 8).map((file) => ( + {file.status} {file.path.slice(0, 26)}{file.staged ? " ●" : ""} + )) + ) : ( + No changes. + ) + ) : ( + <> + {content.git.staged} staged · {content.git.unstaged} unstaged + {content.git.total} files total + + )} + + + + Actions + {LANE_DETAIL_ACTIONS.map((action, index) => ( + + {index === content.selectedActionIndex ? "›" : " "} {action.label} + + ))} + + + {content.pr ? ( + + Pull request + + {content.selectedActionIndex === LANE_DETAIL_ACTIONS.length ? "›" : " "} #{content.pr.number} {content.pr.state} {content.pr.checksPassed}/{content.pr.checksTotal} ✓ + + + ) : null} + + ) : null} {content.kind === "effort" ? ( Effort diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index e7580e60..bb75ba01 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -50,12 +50,22 @@ export type RenderedChatLine = { tone: "user" | "assistant" | "tool" | "error" | "notice" | "reasoning" | "approval"; header?: string; body: string; + blocks?: AssistantMarkdownBlock[]; }; type TimelineEntry = | { kind: "notice"; timestamp: string; index: number; notice: LocalNotice } | { kind: "event"; timestamp: string; index: number; envelope: AgentChatEventEnvelope }; +export type AssistantMarkdownBlock = + | { kind: "paragraph"; text: string } + | { kind: "heading"; level: number; text: string } + | { kind: "bullet"; text: string } + | { kind: "numbered"; number: string; text: string } + | { kind: "quote"; text: string } + | { kind: "code"; language?: string; lines: string[] } + | { kind: "hr" }; + export function chatEventLineId(envelope: AgentChatEventEnvelope, index = 0): string { return `${envelope.sequence ?? index}:${envelope.event.type}:${envelope.timestamp}`; } @@ -95,6 +105,101 @@ function multiLine(value: unknown, maxLines = 18): string { return renderObject(value, maxLines); } +function isMarkdownBoundary(line: string): boolean { + const trimmed = line.trim(); + return ( + trimmed.length === 0 + || /^```/.test(trimmed) + || /^#{1,6}\s+/.test(trimmed) + || /^>\s?/.test(trimmed) + || /^[-*+]\s+/.test(trimmed) + || /^\d+[.)]\s+/.test(trimmed) + || /^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed) + ); +} + +export function parseAssistantMarkdown(text: string): AssistantMarkdownBlock[] { + const sourceLines = text.replace(/\r\n/g, "\n").split("\n"); + const blocks: AssistantMarkdownBlock[] = []; + let paragraph: string[] = []; + + const flushParagraph = () => { + const value = paragraph.join(" ").replace(/\s+/g, " ").trim(); + if (value.length) blocks.push({ kind: "paragraph", text: value }); + paragraph = []; + }; + + for (let index = 0; index < sourceLines.length; index += 1) { + const line = sourceLines[index] ?? ""; + const trimmed = line.trim(); + + if (!trimmed.length) { + flushParagraph(); + continue; + } + + const fence = /^```([\w.+-]*)\s*$/.exec(trimmed); + if (fence) { + flushParagraph(); + const codeLines: string[] = []; + const language = fence[1]?.trim() || undefined; + index += 1; + for (; index < sourceLines.length; index += 1) { + const codeLine = sourceLines[index] ?? ""; + if (/^```\s*$/.test(codeLine.trim())) break; + codeLines.push(codeLine.replace(/\s+$/g, "")); + } + blocks.push({ kind: "code", ...(language ? { language } : {}), lines: codeLines }); + continue; + } + + const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed); + if (heading) { + flushParagraph(); + blocks.push({ kind: "heading", level: heading[1]?.length ?? 1, text: heading[2]?.trim() ?? "" }); + continue; + } + + if (/^([-*_])(?:\s*\1){2,}\s*$/.test(trimmed)) { + flushParagraph(); + blocks.push({ kind: "hr" }); + continue; + } + + const quote = /^>\s?(.*)$/.exec(trimmed); + if (quote) { + flushParagraph(); + blocks.push({ kind: "quote", text: quote[1]?.trim() ?? "" }); + continue; + } + + const bullet = /^[-*+]\s+(.+)$/.exec(trimmed); + if (bullet) { + flushParagraph(); + blocks.push({ kind: "bullet", text: bullet[1]?.trim() ?? "" }); + continue; + } + + const numbered = /^(\d+)[.)]\s+(.+)$/.exec(trimmed); + if (numbered) { + flushParagraph(); + blocks.push({ kind: "numbered", number: numbered[1] ?? "1", text: numbered[2]?.trim() ?? "" }); + continue; + } + + if (paragraph.length && isMarkdownBoundary(sourceLines[index - 1] ?? "")) { + flushParagraph(); + } + paragraph.push(trimmed); + } + + flushParagraph(); + if (!blocks.length && text.trim().length) { + blocks.push({ kind: "paragraph", text: text.trim() }); + } + return blocks; +} + export function latestExpandableFailureId(events: AgentChatEventEnvelope[]): string | null { for (let index = events.length - 1; index >= 0; index -= 1) { const envelope = events[index]!; @@ -165,6 +270,7 @@ export function renderChatLines(args: { tone: "assistant", header: `${providerEventLabel(args.activeSession?.provider)} · ${timeLabel(envelope.timestamp)} · ${sessionModelLabel(args.activeSession)}`, body: event.text, + blocks: parseAssistantMarkdown(event.text), }); continue; } @@ -272,7 +378,8 @@ function coalesceLines(lines: RenderedChatLine[]): RenderedChatLine[] { && last.tone === "assistant" && headerSpeakerKey(line.header) === headerSpeakerKey(last.header) ) { - out[out.length - 1] = { ...last, body: smartConcat(last.body, line.body) }; + const body = smartConcat(last.body, line.body); + out[out.length - 1] = { ...last, body, blocks: parseAssistantMarkdown(body) }; continue; } out.push(line); diff --git a/apps/ade-cli/src/tuiClient/state.ts b/apps/ade-cli/src/tuiClient/state.ts new file mode 100644 index 00000000..c00638b0 --- /dev/null +++ b/apps/ade-cli/src/tuiClient/state.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export type AdeCodeState = { + lastChatByLane: Record; +}; + +const STATE_DIR = path.join(os.homedir(), ".ade"); +const STATE_PATH = path.join(STATE_DIR, "ade-code-state.json"); + +export function loadAdeCodeState(): AdeCodeState { + try { + const raw = fs.readFileSync(STATE_PATH, "utf8"); + const parsed = JSON.parse(raw) as Partial; + const lastChatByLane: Record = {}; + if (parsed && typeof parsed.lastChatByLane === "object" && parsed.lastChatByLane) { + for (const [laneId, sessionId] of Object.entries(parsed.lastChatByLane)) { + if (typeof laneId === "string" && typeof sessionId === "string") { + lastChatByLane[laneId] = sessionId; + } + } + } + return { lastChatByLane }; + } catch { + return { lastChatByLane: {} }; + } +} + +export function saveAdeCodeState(state: AdeCodeState): void { + try { + fs.mkdirSync(STATE_DIR, { recursive: true }); + fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), "utf8"); + } catch { + // best-effort persistence; ignore + } +} diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 71dbf99a..8cca09cf 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -146,6 +146,15 @@ export type RightPaneContent = placeholder?: string; initialValue?: string; }>; + } + | { + kind: "lane-details"; + lane: LaneSummary; + git: { staged: number; unstaged: number; total: number; ahead: number; behind: number; remote: string | null }; + files: { path: string; status: "M" | "A" | "D" | "?"; staged: boolean }[]; + pr: { number: number; state: "open" | "closed" | "merged"; url: string; checksPassed: number; checksTotal: number } | null; + showFiles: boolean; + selectedActionIndex: number; }; export type LocalNotice = { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 004d1462..42d91cbd 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1245,6 +1245,32 @@ describe("createAgentChatService", () => { expect(opts?.systemPrompt?.append).toContain("clean up old, stale, or finished processes"); }); + it("keeps Claude SDK project and user setting sources enabled for filesystem skills", async () => { + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-skills", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(unstable_v2_createSession).toHaveBeenCalled(); + }); + + const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { settingSources?: string[]; skills?: string[] } | undefined; + expect(opts?.settingSources).toEqual(expect.arrayContaining(["user", "project"])); + expect(opts?.skills).toBeUndefined(); + }); + it("appends discovered project slash commands to the Claude system prompt", async () => { const commandsDir = path.join(tmpRoot, ".claude", "commands"); fs.mkdirSync(commandsDir, { recursive: true }); @@ -1287,7 +1313,7 @@ describe("createAgentChatService", () => { const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { systemPrompt?: { append?: string } } | undefined; expect(opts?.systemPrompt?.append).toContain("## Project slash commands"); - expect(opts?.systemPrompt?.append).toContain("auto-expands the command body"); + expect(opts?.systemPrompt?.append).toContain("pre-expands the file's body"); expect(opts?.systemPrompt?.append).toContain("/audit — Audit recent work for bugs and gaps"); expect(opts?.systemPrompt?.append).toContain("/ship-lane — Drive a lane through CI + review"); }); @@ -3204,6 +3230,39 @@ describe("createAgentChatService", () => { }), ])); }); + + it("includes project Claude command files for Codex-backed sessions", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "shipLane.md"), [ + "---", + "description: Ship the active lane", + "---", + "", + "Ship lane.", + "", + ].join("\n")); + fs.writeFileSync(path.join(promptsDir, "shipLane.md"), "# Codex ship lane prompt\n"); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const commands = service.getSlashCommands({ sessionId: session.id }); + expect(commands.filter((command: any) => command.name.toLowerCase() === "/shiplane")).toHaveLength(1); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/shipLane", + description: "Ship the active lane", + source: "sdk", + }), + ])); + }); }); it("sends Claude provider slash commands as the raw SDK prompt", async () => { @@ -3385,6 +3444,53 @@ describe("createAgentChatService", () => { ])); }); + it("expands project Claude command files before sending to Codex", async () => { + const commandsDir = path.join(tmpRoot, ".claude", "commands"); + const promptsDir = path.join(tmpRoot, ".codex", "prompts"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.mkdirSync(promptsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "audit.md"), [ + "---", + "description: Audit recent work", + "---", + "", + "Audit the work.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + fs.writeFileSync(path.join(promptsDir, "audit.md"), [ + "Audit the Codex prompt.", + "", + "Focus: $ARGUMENTS", + "", + ].join("\n")); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + modelId: "openai/gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/audit command rendering", + }, { awaitDispatch: true }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start") as any; + expect(turnStartRequest.params.input).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: "Audit the work.\n\nFocus: command rendering", + }), + ])); + }); + it("keeps built-in Codex slash commands routed to the app server", async () => { const promptsDir = path.join(tmpRoot, ".codex", "prompts"); fs.mkdirSync(promptsDir, { recursive: true }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 6badd73c..e5e089de 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -566,10 +566,14 @@ const CLAUDE_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/usage", description: "Show session cost, plan usage limits, and activity stats.", source: "sdk" }, ]; -const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); -const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => command.name)); +const CODEX_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CODEX_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); +const CLAUDE_BUILT_IN_SLASH_COMMAND_NAMES = new Set(CLAUDE_BUILT_IN_SLASH_COMMANDS.map((command) => slashCommandKey(command.name))); const CLAUDE_LOGIN_NOT_SDK_COMMAND = "ADE Claude chat is hosted through the Claude Agent SDK, and /login is not an SDK-dispatchable command. Run `claude auth login` in a terminal or configure ANTHROPIC_API_KEY, then refresh AI settings."; +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + function isDispatchableClaudeSdkSlashCommand(command: { name: string }): boolean { return command.name !== "/login"; } @@ -11686,7 +11690,7 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime, commands: Array, ): void => { - const existing = new Map(runtime.slashCommands.map((command) => [command.name, command])); + const existing = new Map(runtime.slashCommands.map((command) => [slashCommandKey(command.name), command])); for (const command of commands .map((command) => { if (typeof command === "string") { @@ -11706,12 +11710,13 @@ export function createAgentChatService(args: { }; }) .filter((command): command is { name: string; description: string; argumentHint?: string } => Boolean(command))) { - existing.set(command.name, { - ...existing.get(command.name), + const key = slashCommandKey(command.name); + existing.set(key, { + ...existing.get(key), ...command, }); } - runtime.slashCommands = [...existing.values()].sort((a, b) => a.name.localeCompare(b.name)); + runtime.slashCommands = [...existing.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }; const deliverNextQueuedSteer = async ( @@ -12816,10 +12821,10 @@ export function createAgentChatService(args: { const providerHasPersistentGuidance = managed.session.provider === "claude"; const shouldInjectGuidance = !providerHasPersistentGuidance; const claudeRuntimeSlashCommandNames = managed.runtime?.kind === "claude" - ? new Set(managed.runtime.slashCommands.map((command) => command.name)) + ? new Set(managed.runtime.slashCommands.map((command) => slashCommandKey(command.name))) : new Set(); const codexRuntimeSlashCommandNames = managed.runtime?.kind === "codex" - ? new Set((managed.runtime as { slashCommands?: Array<{ name: string }> }).slashCommands?.map((command) => command.name) ?? []) + ? new Set((managed.runtime as { slashCommands?: Array<{ name: string }> }).slashCommands?.map((command) => slashCommandKey(command.name)) ?? []) : new Set(); const expandedClaudeSlashCommand = providerSlashCommand && managed.session.provider === "claude" @@ -12828,18 +12833,26 @@ export function createAgentChatService(args: { && !claudeRuntimeSlashCommandNames.has(slashCommand) ? resolveClaudeSlashCommandInvocation(managed.laneWorktreePath, trimmed) : null; + const expandedClaudeProjectSlashCommandForCodex = providerSlashCommand + && managed.session.provider === "codex" + && slashCommand != null + && !CODEX_BUILT_IN_SLASH_COMMAND_NAMES.has(slashCommand) + && !codexRuntimeSlashCommandNames.has(slashCommand) + ? resolveClaudeSlashCommandInvocation(managed.laneWorktreePath, trimmed) + : null; const expandedCodexSlashCommand = providerSlashCommand && managed.session.provider === "codex" && slashCommand != null && !CODEX_BUILT_IN_SLASH_COMMAND_NAMES.has(slashCommand) && !codexRuntimeSlashCommandNames.has(slashCommand) + && expandedClaudeProjectSlashCommandForCodex == null ? resolveCodexSlashCommandInvocation(managed.laneWorktreePath, trimmed) : null; const contextAttachmentPrompt = providerSlashCommand ? "" : buildChatContextAttachmentPrompt(publicContextAttachments); const promptText = providerSlashCommand - ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? trimmed + ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? expandedClaudeProjectSlashCommandForCodex?.promptText ?? trimmed : composeLaunchDirectives(trimmed, [ shouldInjectLaneDirective ? buildLaneWorktreeDirective({ @@ -12856,7 +12869,7 @@ export function createAgentChatService(args: { contextAttachmentPrompt || null, ]); const autoTitleSeed = providerSlashCommand - ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? null + ? expandedClaudeSlashCommand?.promptText ?? expandedCodexSlashCommand?.promptText ?? expandedClaudeProjectSlashCommandForCodex?.promptText ?? null : visibleText; if (!managed.autoTitleSeed && autoTitleSeed) { managed.autoTitleSeed = autoTitleSeed; @@ -18409,10 +18422,10 @@ export function createAgentChatService(args: { const merged = new Map(); for (const group of groups) { for (const command of group) { - merged.set(command.name, command); + merged.set(slashCommandKey(command.name), command); } } - return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); }; // Claude SDK commands plus filesystem-backed Claude Code commands/skills. @@ -18451,7 +18464,15 @@ export function createAgentChatService(args: { argumentHint: cmd.argumentHint, source: "sdk" as const, })); - return mergeSlashCommands([promptCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); + const claudeProjectCommands: AgentChatSlashCommand[] = discoverClaudeSlashCommands(managed.laneWorktreePath) + .filter(isDispatchableClaudeSdkSlashCommand) + .map((cmd: { name: string; description: string; argumentHint?: string }) => ({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: "sdk" as const, + })); + return mergeSlashCommands([promptCommands, claudeProjectCommands, CODEX_BUILT_IN_SLASH_COMMANDS, dynamicCommands]); } // OpenCode / Cursor — only local commands diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 7d8c28d3..034f2f66 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -42,7 +42,7 @@ describe("discoverClaudeSlashCommands", () => { ]); }); - it("namespaces nested project command files like Claude Code", () => { + it("uses nested project command basenames like Claude Code and keeps scope in the description", () => { const commandsDir = path.join(tmpRoot, ".claude", "commands", "frontend"); fs.mkdirSync(commandsDir, { recursive: true }); fs.writeFileSync(path.join(commandsDir, "test.md"), [ @@ -56,8 +56,8 @@ describe("discoverClaudeSlashCommands", () => { expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { - name: "/frontend:test", - description: "Run frontend tests", + name: "/test", + description: "frontend: Run frontend tests", }, ]); }); @@ -86,12 +86,42 @@ describe("discoverClaudeSlashCommands", () => { expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { - name: "/level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9:visible", - description: "Visible nested command", + name: "/visible", + description: "level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9: Visible nested command", }, ]); }); + it("preserves command filename casing and dedupes case variants by project precedence", () => { + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(tmpRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "shipLane.md"), [ + "---", + "description: Personal ship lane", + "---", + "", + "Personal.", + "", + ].join("\n")); + fs.writeFileSync(path.join(tmpRoot, ".claude", "commands", "shipLane.md"), [ + "---", + "description: Project ship lane", + "---", + "", + "Project.", + "", + ].join("\n")); + + const commands = discoverClaudeSlashCommands(tmpRoot); + expect(commands.filter((command) => command.name.toLowerCase() === "/shiplane")).toHaveLength(1); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ + name: "/shipLane", + description: "Project ship lane", + }), + ])); + }); + it("discovers invocable skills and hides non-user-invocable skills", () => { const visibleSkill = path.join(tmpRoot, ".claude", "skills", "fix-issue"); const hiddenSkill = path.join(tmpRoot, ".claude", "skills", "background-context"); @@ -166,6 +196,17 @@ describe("discoverClaudeSlashCommands", () => { .toBe("Audit the model pane."); }); + it("resolves nested commands by basename and keeps legacy colon names working", () => { + const nestedCommands = path.join(tmpRoot, ".claude", "commands", "frontend"); + fs.mkdirSync(nestedCommands, { recursive: true }); + fs.writeFileSync(path.join(nestedCommands, "component.md"), "Build component $ARGUMENTS.\n"); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/component button")?.promptText) + .toBe("Build component button."); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/frontend:component button")?.promptText) + .toBe("Build component button."); + }); + it("includes personal commands and lets project commands with the same name win", () => { fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); fs.mkdirSync(path.join(tmpRoot, ".claude", "commands"), { recursive: true }); diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index 81e61c4a..ef4e3e03 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -59,10 +59,14 @@ function stripFrontmatter(markdown: string): string { } function normalizeSlashCommandName(value: string): string | null { - const name = value.trim().replace(/\.md$/i, "").replace(/[^A-Za-z0-9_:-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase(); + const name = value.trim().replace(/\.md$/i, "").replace(/[^A-Za-z0-9_:-]+/g, "-").replace(/^-+|-+$/g, ""); return name.length ? `/${name}` : null; } +function slashCommandKey(value: string): string { + return value.trim().toLowerCase(); +} + function maybeString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } @@ -97,8 +101,9 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma } if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const relative = path.relative(commandsDir, entryPath).replace(/\.md$/i, ""); - const commandPath = relative.split(path.sep).filter(Boolean).join(":"); - const name = normalizeSlashCommandName(commandPath); + const parts = relative.split(path.sep).filter(Boolean); + const commandName = parts[parts.length - 1] ?? ""; + const name = normalizeSlashCommandName(commandName); if (!name) continue; let content = ""; try { @@ -107,9 +112,11 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma continue; } const frontmatter = readFrontmatter(content) as CommandFrontmatter; + const scope = parts.slice(0, -1).join(":"); + const description = maybeString(frontmatter.description) ?? firstMarkdownParagraph(content); commands.push({ name, - description: maybeString(frontmatter.description) ?? firstMarkdownParagraph(content), + description: scope && description ? `${scope}: ${description}` : description, argumentHint: maybeArgumentHint(frontmatter["argument-hint"]) ?? maybeArgumentHint(frontmatter.argumentHint), source: "command", filePath: entryPath, @@ -137,10 +144,11 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str } } // Slow path: discovery normalizes filenames (lowercase + slugified), so a - // file like `My Command.md` is exposed as `/my-command` but the literal - // path above won't find it. Walk the directory and match by normalized - // name so non-canonical filenames still resolve. - const targetName = commandName.toLowerCase(); + // file like `My Command.md` is exposed as `/My-Command` but the literal + // path above won't find it. Nested Claude commands are invoked by basename + // (`commands/frontend/component.md` -> `/component`), but keep the legacy + // colon path accepted for older ADE command references. + const targetName = slashCommandKey(commandName); let match: string | null = null; const visit = (dir: string, prefix: string[], depth: number): void => { if (match || depth > MAX_LEGACY_COMMAND_DEPTH) return; @@ -159,8 +167,12 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str } if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const commandPath = [...prefix, entry.name].join(":"); - const normalized = normalizeSlashCommandName(commandPath); - if (normalized && normalized.toLowerCase() === targetName) { + const normalizedPath = normalizeSlashCommandName(commandPath); + const normalizedBase = normalizeSlashCommandName(entry.name); + if ( + (normalizedPath && slashCommandKey(normalizedPath) === targetName) + || (normalizedBase && slashCommandKey(normalizedBase) === targetName) + ) { match = entryPath; return; } @@ -241,11 +253,11 @@ export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashC ...discoverSkills(path.join(root, "skills")), ]; for (const command of discovered) { - byName.set(command.name, command); + byName.set(slashCommandKey(command.name), command); } } - return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); } function resolveSkillFile(skillsDir: string, commandName: string): string | null { @@ -293,7 +305,7 @@ export function resolveClaudeSlashCommandInvocation( const match = trimmed.match(/^(\/[A-Za-z0-9][A-Za-z0-9_-]*(?::[A-Za-z0-9][A-Za-z0-9_-]*)*)(?:\s+([\s\S]*))?$/); if (!match) return null; - const name = match[1]?.toLowerCase(); + const name = match[1]; if (!name) return null; const argumentsText = match[2]?.trim() ?? ""; const roots = [ From b68955316edf4b7624a66ce183fe022c2d1ba2c4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 02:44:27 -0400 Subject: [PATCH 4/7] ship: prepare lane for review --- .claude/commands/automate.md | 5 +- .claude/commands/finalize.md | 4 +- apps/ade-cli/src/cli.test.ts | 9 ++++ apps/ade-cli/src/cli.ts | 12 ++++- .../src/services/sync/syncHostService.ts | 22 ++++++-- .../src/tuiClient/__tests__/ChatView.test.tsx | 8 ++- .../src/tuiClient/__tests__/commands.test.ts | 20 +++++-- apps/ade-cli/src/tuiClient/app.tsx | 52 +++++++++++-------- apps/ade-cli/src/tuiClient/commands.ts | 14 +++-- .../src/tuiClient/components/Drawer.tsx | 21 ++++++-- apps/ade-cli/vitest.config.ts | 2 +- .../services/sync/syncHostService.test.ts | 21 +++++++- .../components/app/CommandPalette.test.tsx | 28 ++++++++-- .../renderer/components/app/TopBar.test.tsx | 2 +- docs/ARCHITECTURE.md | 4 ++ docs/features/ade-code/README.md | 6 ++- docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 16 +++--- .../onboarding-and-settings/README.md | 2 +- 19 files changed, 181 insertions(+), 69 deletions(-) diff --git a/.claude/commands/automate.md b/.claude/commands/automate.md index f35d329b..57320dd1 100644 --- a/.claude/commands/automate.md +++ b/.claude/commands/automate.md @@ -237,6 +237,7 @@ Step 2: Map changed source to internal docs | apps/desktop/src/shared/ | docs/ARCHITECTURE.md + touching feature's doc | | apps/desktop/src/renderer/components// | docs/features// | | apps/desktop/src/renderer/state/ | docs/ARCHITECTURE.md (UI framework) | +| apps/ade-cli/src/tuiClient/ | docs/features/ade-code/README.md + docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) | | apps/ade-cli/ | docs/ARCHITECTURE.md (ADE CLI / Build/Test/Deploy) + docs/features/agents/ | | .github/workflows/ | docs/ARCHITECTURE.md (Build/Test/Deploy) | | apps/ios/ | docs/features/sync-and-multi-device/ios-companion.md | @@ -427,7 +428,7 @@ Step 2: Identify TUI-relevant changes. Treat as candidates: - apps/ade-cli/src/adeRpcServer.ts new actions — should appear in BUILTIN_COMMANDS or via /ade. - New IPC handlers in window.ade.git/.lanes/.app/.prs — TUI may want a slash command + right-pane action wrapper. -Step 3: Map to ade-code surface +Step 3: Map to the TUI surface - Slash commands: apps/ade-cli/src/tuiClient/commands.ts BUILTIN_COMMANDS. - Slash dispatch: apps/ade-cli/src/tuiClient/app.tsx (search by name pattern, e.g. `if (name === "/push")`). - Sidebar rendering: apps/ade-cli/src/tuiClient/components/Drawer.tsx. @@ -466,7 +467,7 @@ Wait for all four parity agents to complete before moving to Verification. ## Verification -After all three passes: +After all seven passes: 1. **Run the affected shards**, not the full suite (`/finalize` runs everything): ```bash diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index d8c05864..53e9f4cd 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -1,6 +1,6 @@ --- name: finalize -description: 'Final gate: simplify code, update docs, and run local CI checks before pushing' +description: 'Final gate: simplify code, validate docs, and run local CI checks before pushing' --- # Finalize Command @@ -9,7 +9,7 @@ This command is the final gate before pushing and opening a PR. It guarantees three outcomes: 1. Code quality cleanup is complete -2. Docs are current +2. Docs changed by `/automate` are still valid 3. Local CI checks pass It does **not** guarantee that remote PR review is complete after a push. GitHub's diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 3f77c827..dd10c6bc 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -73,6 +73,15 @@ describe("ADE CLI", () => { expect(plan).toEqual({ kind: "ade-code", rest: ["--print-state"] }); }); + it("shows socket-aware TUI help for ade code --help", () => { + const plan = buildCliPlan(["code", "--help"]); + expect(plan.kind).toBe("help"); + if (plan.kind !== "help") return; + expect(plan.text).toContain("ade code --socket /tmp/ade.sock"); + expect(plan.text).toContain("ade code --require-socket"); + expect(plan.text).toContain("Command palette"); + }); + it("shows help for bare ade invocations", () => { expect(buildCliPlan([])).toEqual({ kind: "help", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f9056a82..9a8a35ea 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -890,8 +890,18 @@ const HELP_BY_COMMAND: Record = { $ ade code Start the TUI for the current project $ ade code --print-state Smoke-test attach/embed state $ ade code --embedded Force the embedded runtime fallback + $ ade code --require-socket Fail instead of embedding when no socket exists + $ ade code --socket /tmp/ade.sock Attach to a specific runtime socket $ ade --project-root code Launch against a specific ADE project -`, + + Keys: + ctrl-o Open or focus lanes and chats + ctrl-p Open or focus details + shift-tab Cycle pane focus + esc Return or cancel the active pane + ? Help when it is the first prompt character + / Command palette + `, lanes: `${ADE_BANNER} Lanes diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 98abf8f2..8e5d7629 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -755,16 +755,21 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return []; } }; + const commandLedgerScopeKey = (): string => + toOptionalString(args.projectId) ?? args.projectRoot; + const commandLedgerKeyPrefix = (): string => `${commandLedgerScopeKey()}:`; + const commandLedgerLegacyRootPrefix = (): string => `${args.projectRoot}:`; const writePersistedCommandLedger = (): void => { const nowMs = Date.now(); const commands: PersistedMobileCommand[] = []; + const prefix = commandLedgerKeyPrefix(); for (const [key, record] of mobileCommandResultCache) { if (!record.result || record.completedAtMs == null) continue; const persistedResult = persistedMobileCommandResult(record.action, record.result); if (!persistedResult) continue; - if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (!key.startsWith(prefix)) continue; if (nowMs - record.completedAtMs > MOBILE_COMMAND_RESULT_CACHE_TTL_MS) continue; - const deviceId = key.slice(`${args.projectRoot}:`.length).split(":")[0] ?? ""; + const deviceId = key.slice(prefix.length).split(":")[0] ?? ""; commands.push({ key, projectRoot: args.projectRoot, @@ -795,7 +800,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ? mobileCommandArgsFingerprint(legacyArgsKey) : null; if (!argsFingerprint) continue; - mobileCommandResultCache.set(command.key, { + const key = + command.key.startsWith(commandLedgerLegacyRootPrefix()) && + commandLedgerScopeKey() !== args.projectRoot + ? `${commandLedgerKeyPrefix()}${command.key.slice(commandLedgerLegacyRootPrefix().length)}` + : command.key; + mobileCommandResultCache.set(key, { commandId: command.commandId, action: command.action, argsKey: argsFingerprint, @@ -809,10 +819,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }; const commandLedgerSizeForProject = (): number => - [...mobileCommandResultCache.keys()].filter((key) => key.startsWith(`${args.projectRoot}:`)).length; + [...mobileCommandResultCache.keys()].filter((key) => + key.startsWith(commandLedgerKeyPrefix()), + ).length; const dropInFlightCommandRecordsForProject = (): void => { for (const [key, record] of mobileCommandResultCache) { - if (!key.startsWith(`${args.projectRoot}:`)) continue; + if (!key.startsWith(commandLedgerKeyPrefix())) continue; if (record.result == null) mobileCommandResultCache.delete(key); } }; diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 4c2f3557..a02ac34a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -62,14 +62,12 @@ describe("ChatView", () => { ]); const lines = frame.split(/\r?\n/); const bubbleLine = lines.find((line) => line.includes("hello")); - expect(bubbleLine).toBeTruthy(); + expect(bubbleLine, "expected the rendered frame to include the user message").toBeDefined(); // Round border characters wrap the bubble; verify presence so layout stays a bubble. expect(frame).toMatch(/[╭╮╯╰]/); // Bubble is right-aligned: the content sits past the half-width of the frame. - if (bubbleLine) { - const helloIndex = bubbleLine.indexOf("hello"); - expect(helloIndex).toBeGreaterThan(0); - } + const helloIndex = (bubbleLine ?? "").indexOf("hello"); + expect(helloIndex).toBeGreaterThan(0); }); it("renders assistant messages flat without the bubble border", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index adcc1816..b9042196 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -71,18 +71,28 @@ describe("commands", () => { const rows = paletteCommands("/comp", [ { name: "/compact", description: "Free up context by summarizing", source: "sdk" }, ]); - expect(rows.find((row) => row.name === "/compact")).toBeTruthy(); + expect(rows).toContainEqual(expect.objectContaining({ + name: "/compact", + source: "user", + description: "Free up context by summarizing", + })); }); - it("prefers SDK/user entry when same command exists in ADE builtins (dedupe)", () => { - // /clear is in ADE BUILTIN_COMMANDS; the SDK also exposes /clear. + it("keeps ADE-owned inline commands aligned with dispatch when deduping", () => { + // /clear is an ADE terminal control, so the palette must not advertise the SDK command. const rows = paletteCommands("/clear", [ { name: "/clear", description: "Start a new conversation with empty context", source: "sdk" }, ]); const clearRows = rows.filter((row) => row.name === "/clear"); expect(clearRows).toHaveLength(1); - expect(clearRows[0]?.source).toBe("user"); - expect(clearRows[0]?.description).toBe("Start a new conversation with empty context"); + expect(clearRows[0]?.source).toBe("ade"); + expect(clearRows[0]?.description).toBe("Clear the local terminal transcript view"); + + const parsed = parseCommand("/clear", [ + { name: "/clear", description: "Start a new conversation with empty context", source: "sdk" }, + ]); + expect(parsed?.spec?.name).toBe("/clear"); + expect(parsed?.userCommand).toBeNull(); }); it("dedupes slash command case variants and keeps runtime casing", () => { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 0d46a160..dc3d619e 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -48,7 +48,7 @@ import { } from "./adeApi"; import { paletteCommands, parseCommand } from "./commands"; import { connectToAde } from "./connection"; -import { Drawer } from "./components/Drawer"; +import { Drawer, visibleDrawerChatCount, visibleDrawerLaneCount } from "./components/Drawer"; import { ChatView } from "./components/ChatView"; import { Header } from "./components/Header"; import { LANE_DETAIL_ACTIONS, RightPane } from "./components/RightPane"; @@ -689,6 +689,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const attachProbeInFlightRef = useRef(false); const lastChatByLaneRef = useRef>(new Map(Object.entries(loadAdeCodeState().lastChatByLane))); const lastChatByLaneWriteTimerRef = useRef(null); + const pendingNewChatTitleRef = useRef(null); const persistLastChatByLane = useCallback(() => { if (lastChatByLaneWriteTimerRef.current) { @@ -858,11 +859,18 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } [activeSessionId, sessions], ); const latestFailedLineId = useMemo(() => latestExpandableFailureId(events), [events]); - const drawerLaneRows = useMemo(() => lanes.slice(0, 10), [lanes]); + const drawerLaneRows = useMemo( + () => lanes.slice(0, visibleDrawerLaneCount(rows, lanes.length)), + [lanes, rows], + ); const drawerLaneSessions = useMemo( () => sessions.filter((session) => session.laneId === drawerLaneId), [drawerLaneId, sessions], ); + const drawerVisibleLaneSessions = useMemo( + () => drawerLaneSessions.slice(0, visibleDrawerChatCount(drawerLaneSessions.length)), + [drawerLaneSessions], + ); const selectedLaneIndex = useMemo(() => { if (selectedDrawerLaneAction === "new-lane") return drawerLaneRows.length; const targetId = selectedDrawerLaneId ?? drawerLaneId ?? activeLaneId; @@ -870,12 +878,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } return index >= 0 ? index : 0; }, [activeLaneId, drawerLaneId, drawerLaneRows, selectedDrawerLaneAction, selectedDrawerLaneId]); const selectedChatIndex = useMemo(() => { - if (selectedDrawerChatAction === "new-chat") return drawerLaneSessions.length; + if (selectedDrawerChatAction === "new-chat") return drawerVisibleLaneSessions.length; const targetId = selectedDrawerChatId ?? (drawerLaneId === activeLaneId ? activeSessionId : null); - const index = drawerLaneSessions.findIndex((session) => session.sessionId === targetId); + const index = drawerVisibleLaneSessions.findIndex((session) => session.sessionId === targetId); return index >= 0 ? index : 0; - }, [activeLaneId, activeSessionId, drawerLaneId, drawerLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + }, [activeLaneId, activeSessionId, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); const activeMentionRange = useMemo(() => ( activePane === "chat" ? activeMention(prompt) : null ), [activePane, prompt]); @@ -1063,10 +1071,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setSelectedDrawerChatAction("new-chat"); return; } - if (selectedDrawerChatId && drawerLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; - const activeChatInDrawer = drawerLaneSessions.find((session) => session.sessionId === activeSessionId); - setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerLaneSessions[0]?.sessionId ?? null); - }, [activeLaneId, activeSessionId, draftChatActive, drawerLaneId, drawerLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); + if (selectedDrawerChatId && drawerVisibleLaneSessions.some((session) => session.sessionId === selectedDrawerChatId)) return; + const activeChatInDrawer = drawerVisibleLaneSessions.find((session) => session.sessionId === activeSessionId); + setSelectedDrawerChatId(activeChatInDrawer?.sessionId ?? drawerVisibleLaneSessions[0]?.sessionId ?? null); + }, [activeLaneId, activeSessionId, draftChatActive, drawerLaneId, drawerVisibleLaneSessions, selectedDrawerChatAction, selectedDrawerChatId]); useEffect(() => { setSlashIndex(0); @@ -1140,12 +1148,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } }); }, [openForm]); - const openNewChatSetup = useCallback(() => { + const openNewChatSetup = useCallback((title?: string | null) => { if (!activeLaneIdRef.current) { setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); focusDetails(); return; } + const trimmedTitle = title?.trim() || null; + pendingNewChatTitleRef.current = trimmedTitle; draftSeededFromHistoryRef.current = true; const previousPane = activePaneRef.current; stashActiveInput(); @@ -1334,7 +1344,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const nextProvider = configSession?.provider ?? modelState.provider ?? "codex"; const commandSessionId = nextSessionId ?? configSession?.sessionId ?? null; const remoteCommands = commandSessionId ? await getSlashCommands(conn, commandSessionId).catch(() => []) : []; - const projectCommands = discoverProjectSlashCommands(project.workspaceRoot); + const projectCommands = discoverProjectSlashCommands(nextLane?.worktreePath || project.workspaceRoot); const nextCommands = remoteCommands.length ? remoteCommands : projectCommands; const nextModels = await getAvailableModels(conn, nextProvider).catch(() => []); const activeModel = nextModels.find((model) => model.modelId === configSession?.modelId || model.id === configSession?.modelId) @@ -1512,6 +1522,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } const created = await createChatSession({ connection: conn, laneId, + title: pendingNewChatTitleRef.current, provider: normalized.provider, modelId: normalized.modelId, reasoningEffort: normalized.reasoningEffort, @@ -1527,6 +1538,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } cursorModeId: normalized.cursorModeId, cursorConfigValues: normalized.cursorConfigValues, }); + pendingNewChatTitleRef.current = null; setDraftChatMode(false); selectActiveSessionId(created.id); await refreshState(); @@ -1610,10 +1622,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setRightPane({ kind: "details", title: "New chat", body: "No active lane is available." }); return; } - if (args) { - chatDraftRef.current = args; - } - openNewChatSetup(); + openNewChatSetup(args); return; } if (name === "/new lane") { @@ -2855,7 +2864,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setSelectedDrawerLaneId(lastLane?.id ?? null); } else { const nextIndex = Math.max(0, selectedChatIndex - 1); - const session = drawerLaneSessions[nextIndex] ?? null; + const session = drawerVisibleLaneSessions[nextIndex] ?? null; setSelectedDrawerChatAction(session ? null : "new-chat"); setSelectedDrawerChatId(session?.sessionId ?? null); } @@ -2865,7 +2874,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } if (drawerSection === "lanes") { if (selectedLaneIndex >= drawerLaneRows.length) { setDrawerSection("chats"); - const firstSession = drawerLaneSessions[0] ?? null; + const firstSession = drawerVisibleLaneSessions[0] ?? null; setSelectedDrawerChatAction(firstSession ? null : "new-chat"); setSelectedDrawerChatId(firstSession?.sessionId ?? null); } else { @@ -2875,8 +2884,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } setSelectedDrawerLaneId(lane?.id ?? null); } } else { - const nextIndex = Math.min(drawerLaneSessions.length, selectedChatIndex + 1); - const session = drawerLaneSessions[nextIndex] ?? null; + const nextIndex = Math.min(drawerVisibleLaneSessions.length, selectedChatIndex + 1); + const session = drawerVisibleLaneSessions[nextIndex] ?? null; setSelectedDrawerChatAction(session ? null : "new-chat"); setSelectedDrawerChatId(session?.sessionId ?? null); } @@ -2907,12 +2916,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } addNotice(`Switched to lane ${lane.name}.`, "success"); } } else { - if (selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerLaneSessions.length) { + if (selectedDrawerChatAction === "new-chat" || selectedChatIndex >= drawerVisibleLaneSessions.length) { openNewChatSetup(); setRightOpen(true); return; } - const session = drawerLaneSessions[selectedChatIndex]; + const session = drawerVisibleLaneSessions[selectedChatIndex]; if (session) { selectActiveLaneId(session.laneId); setDrawerLaneId(session.laneId); @@ -3004,6 +3013,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath } browsingLaneId={drawerLaneId ?? activeLaneId} selectedLaneIndex={drawerSection === "lanes" ? selectedLaneIndex : -1} selectedChatIndex={drawerSection === "chats" ? selectedChatIndex : -1} + panelHeight={rows} focused={activePane === "drawer"} /> ) : null} diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 3146a80c..9b720c8e 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -56,7 +56,7 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ const ADE_OWNED_SINGLE_WORD_COMMANDS = new Set( BUILTIN_COMMANDS .filter((command) => command.placement === "inline" && !command.name.includes(" ")) - .map((command) => command.name), + .map((command) => command.name.toLowerCase()), ); export type ParsedCommand = { @@ -98,7 +98,7 @@ export function parseCommand(input: string, userCommands: AgentChatSlashCommand[ const exactUserCommand = userCommands.find((command) => slashCommandKey(command.name) === firstKey) ?? null; const adeOwnedSingleWordCommand = candidates.find((command) => - slashCommandKey(command.name) === firstKey && ADE_OWNED_SINGLE_WORD_COMMANDS.has(command.name) + slashCommandKey(command.name) === firstKey && ADE_OWNED_SINGLE_WORD_COMMANDS.has(slashCommandKey(command.name)) ); if (adeOwnedSingleWordCommand) { return { @@ -166,11 +166,15 @@ export function paletteCommands( source: "user" as const, argumentHint: command.argumentHint, })); - // Dedupe by name: when both ADE and a runtime/user catalog define the same - // command, prefer the runtime/user entry so SDK-native behavior wins. + // Dedupe by name. Most runtime/user commands win over ADE built-ins, but + // ADE-owned inline terminal controls must match parseCommand dispatch. const byName = new Map(); for (const command of builtins) byName.set(slashCommandKey(command.name), command); - for (const command of users) byName.set(slashCommandKey(command.name), command); + for (const command of users) { + const key = slashCommandKey(command.name); + if (ADE_OWNED_SINGLE_WORD_COMMANDS.has(key)) continue; + byName.set(key, command); + } const merged = [...byName.values()]; const filtered = !queryToken ? merged diff --git a/apps/ade-cli/src/tuiClient/components/Drawer.tsx b/apps/ade-cli/src/tuiClient/components/Drawer.tsx index 80a7bfd0..846f0d6b 100644 --- a/apps/ade-cli/src/tuiClient/components/Drawer.tsx +++ b/apps/ade-cli/src/tuiClient/components/Drawer.tsx @@ -7,6 +7,15 @@ import { formatLaneLabel, formatSessionLabel } from "../format"; const PURPLE = "#A78BFA"; const AMBER = "#F59E0B"; +export function visibleDrawerLaneCount(panelHeight: number, laneCount: number): number { + const lanesMaxRows = Math.max(2, Math.floor(panelHeight / 2) - 3); + return Math.min(laneCount, 10, lanesMaxRows); +} + +export function visibleDrawerChatCount(chatCount: number): number { + return Math.min(chatCount, 12); +} + export function Drawer({ lanes, sessions, @@ -15,6 +24,7 @@ export function Drawer({ browsingLaneId, selectedLaneIndex, selectedChatIndex, + panelHeight, focused = false, }: { lanes: LaneSummary[]; @@ -24,14 +34,15 @@ export function Drawer({ browsingLaneId: string | null; selectedLaneIndex: number; selectedChatIndex: number; + panelHeight?: number; focused?: boolean; }) { const { stdout } = useStdout(); - const panelHeight = stdout?.rows ?? 40; - const laneSessions = sessions.filter((session) => session.laneId === browsingLaneId).slice(0, 12); - // Adaptive 50% cap: LANES section uses up to half the column height (header + rows + "+ new lane"). - const lanesMaxRows = Math.max(2, Math.floor(panelHeight / 2) - 3); - const laneRows = lanes.slice(0, Math.min(10, lanesMaxRows)); + const resolvedPanelHeight = panelHeight ?? stdout?.rows ?? 40; + const laneSessions = sessions + .filter((session) => session.laneId === browsingLaneId) + .slice(0, visibleDrawerChatCount(sessions.length)); + const laneRows = lanes.slice(0, visibleDrawerLaneCount(resolvedPanelHeight, lanes.length)); return ( diff --git a/apps/ade-cli/vitest.config.ts b/apps/ade-cli/vitest.config.ts index 8242fa10..317da94c 100644 --- a/apps/ade-cli/vitest.config.ts +++ b/apps/ade-cli/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["src/**/*.test.{ts,tsx}"], setupFiles: ["src/test/setup.ts"], coverage: { provider: "v8", diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 4332ae16..2b00660b 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -267,12 +267,16 @@ function createStubChatService() { async function sendCommand(ws: WebSocket, queue: ReturnType, payload: { commandId: string; action: string; + projectId?: string | null; args: Record; }) { ws.send(encodeSyncEnvelope({ type: "command", requestId: payload.commandId, - payload, + payload: { + projectId: "project-1", + ...payload, + }, })); const ack = await queue.next("command_ack"); const result = await queue.next("command_result"); @@ -690,6 +694,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -821,6 +826,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -944,6 +950,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -1147,6 +1154,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -1388,6 +1396,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, pinStore: createStubPinStore(), @@ -1596,6 +1605,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-quick-run", action: "work.runQuickCommand", + projectId: "project-1", args: { laneId: "lane-1", title: "Run tests", @@ -1622,6 +1632,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-quick-run", action: "work.runQuickCommand", + projectId: "project-1", args: { laneId: "lane-1", title: "Run tests", @@ -1641,6 +1652,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-quick-run", action: "work.runQuickCommand", + projectId: "project-1", args: { laneId: "lane-2", title: "Run a different command", @@ -1660,6 +1672,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-start-cli", action: "work.startCliSession", + projectId: "project-1", args: { laneId: "lane-1", provider: "codex", @@ -1696,6 +1709,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-start-cli", action: "work.startCliSession", + projectId: "project-1", args: { laneId: "lane-1", provider: "codex", @@ -1725,6 +1739,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-work-list", action: "work.listSessions", + projectId: "project-1", args: {}, }, })); @@ -1743,6 +1758,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-pr-refresh", action: "prs.refresh", + projectId: "project-1", args: {}, }, })); @@ -1767,6 +1783,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { payload: { commandId: "cmd-unsupported", action: "prs.create", + projectId: "project-1", args: {}, }, })); @@ -1787,6 +1804,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, fileService: createStubFileService(workspaceRoot) as any, @@ -1933,6 +1951,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const host = createSyncHostService({ db: brainDb, logger: createLogger() as any, + projectId: "project-1", projectRoot, port: 0, fileService: createStubFileService(workspaceRoot) as any, diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 78771061..f2f13239 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -346,6 +346,23 @@ describe("CommandPalette", () => { displayName: "ADE", gitOriginUrl: "git@github.com:example/ade.git", dirtyCount: 3, + workSummary: { + rootPath: "/Users/admin/Projects/ADE", + laneCount: 1, + checkedLaneCount: 1, + dirtyLaneCount: 1, + dirtyFileCount: 3, + primaryDirtyCount: 3, + lanes: [ + { + rootPath: "/Users/admin/Projects/ADE", + name: "main", + branchName: "main", + dirtyCount: 3, + isPrimary: true, + }, + ], + }, }, ], })), @@ -378,14 +395,17 @@ describe("CommandPalette", () => { await waitFor(() => expect( - screen.getByRole("dialog", { name: "Open remote tab?" }), + screen.getByRole("dialog", { + name: "You already work on this repo locally", + }), ).toBeTruthy(), ); - expect(screen.getByText("3 changed files")).toBeTruthy(); - expect(screen.getAllByText("/Users/admin/Projects/ADE").length).toBeGreaterThan(0); + expect(screen.getAllByText("Changes").length).toBeGreaterThan(0); + expect(screen.getByTitle(/Primary.*3 files/)).toBeTruthy(); + expect(screen.getAllByTitle("/Users/admin/Projects/ADE").length).toBeGreaterThan(0); expect(switchRemoteProject).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Open remote tab" })); + fireEvent.click(screen.getByRole("button", { name: "Open on Mac Studio" })); await waitFor(() => expect(switchRemoteProject).toHaveBeenCalledWith( "target-1", diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 25621a33..65cc0efd 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -258,7 +258,7 @@ describe("TopBar", () => { expect(await screen.findByTitle("Mac Studio: /srv/ade/remote-app")).toBeTruthy(); expect(screen.getByText("Remote App")).toBeTruthy(); - expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(screen.getByLabelText("Remote: Mac Studio")).toBeTruthy(); expect(globalThis.window.ade.sync.getStatus).not.toHaveBeenCalled(); expect(screen.queryByTitle("Connect a phone to this machine")).toBeNull(); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6f0fda92..883e91c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -285,6 +285,10 @@ Service entry points live under `apps/desktop/src/main/services/ai/`. The subsys - `providerRuntimeHealth.ts` — per-provider health (`ready`, `auth-failed`, `runtime-failed`). - `claudeRuntimeProbe.ts` — lightweight SDK probe on force-refresh to confirm the Claude CLI + ADE CLI path can actually start. - `modelsDevService.ts` — non-blocking 6-hour refresh that enriches pricing and context-window metadata in the registry from `models.dev`. +- **ADE action status surface**: `ai.getStatus`, `ai.listApiKeys`, and + `ai.getOpenCodeRuntimeDiagnostics` expose the same provider readiness, + stored-key, and OpenCode runtime health data to renderer settings and + `ade code` model setup through the shared ADE action registry. - **Fallback**: if no usable provider is present, ADE runs in **guest mode** — deterministic features (packs, diffs, conflicts) continue; AI surfaces are disabled with explanatory UI. ### 4.2 Permission modes (provider-native + ADE) diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index e618d472..caa14d7a 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -13,9 +13,11 @@ It is a client. The runtime, lanes, chats, transcripts, PRs, processes, and proo | `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. | | `apps/ade-cli/src/tuiClient/connection.ts` | Resolves attached vs embedded mode, runs the `ade/initialize` handshake, registers the project with `projects.add`, wraps subsequent requests with `projectId`. | | `apps/ade-cli/src/tuiClient/jsonRpcClient.ts` | Socket client: connect, request/response, `chat/event` notifications. | -| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation. | +| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over `AdeCodeConnection.action` / `actionList` for lanes, chat, models, navigation, provider readiness, API-key status, OpenCode diagnostics, and project slash-command discovery. | | `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | +| `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state such as the last selected chat per lane under the project `.ade/cache` layout. | +| `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens used by the header, model setup pane, transcript, and controls. | | `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, navigation DTOs aligned with `apps/desktop/src/shared/types`. | | `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer`, `ChatView`, `Header`, `RightPane`, `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`. | | `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input). Imported per-module so ade-cli typecheck stays scoped. | @@ -73,7 +75,7 @@ Heartbeats are kept alive with `startTuiHeartbeat` so the runtime knows the chat ## Slash commands -`commands.ts` exports the built-in slash command catalog. `placement` decides whether the command runs inline in the chat or opens the right pane. Server-provided `AgentChatSlashCommand`s from the active runtime are merged in via `getSlashCommands` (responses with `source: "local"` win over built-ins). +`commands.ts` exports the built-in slash command catalog. `placement` decides whether the command runs inline in the chat or opens the right pane. The TUI also discovers project command files and Codex prompts before a chat exists, then refreshes against server-provided `AgentChatSlashCommand`s from the active runtime via `getSlashCommands`. Provider/runtime commands win over same-named built-ins except for local terminal controls such as `/login`, `/quit`, `/clear`, and `/end`. Inline (acts on chat or shell): diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 27e60a26..336689d0 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -16,7 +16,7 @@ machinery layered on top. | `apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts` | Main-process broker for the in-app web browser. Owns a single `persist:ade-browser` partition, multiple `WebContentsView` tabs (cap 10), bounds + visibility against the renderer-supplied frame, debugger-protocol attachment for inspect-mode hit tests, screenshot capture, and emission of `BuiltInBrowserContextItem`s for selected page elements. Spoofs a desktop Chrome `User-Agent` and the matching `Sec-CH-UA*` client hints on every request through `webRequest.onBeforeSendHeaders` so external sign-in flows (Google, etc.) treat the embedded view as a normal desktop Chrome instead of refusing to load — the previous "open Google sign-in in the system browser" branch was removed because the spoofed UA stops Google from blocking the page in the first place. Window-open requests are forwarded into a fresh tab with `openPanel: true` so the Work sidebar Browser tab pops automatically. Backs the `ade.builtInBrowser.*` IPC surface and is consumed by both `ChatBuiltInBrowserPanel` (sidebar Browser tab) and `openExternal.ts` (links inside the renderer route through the built-in browser when the protocol is `http`/`https`/`about:blank`). | | `apps/desktop/src/shared/types/builtInBrowser.ts` | Cross-process types for the built-in browser: `BuiltInBrowserStatus`, `BuiltInBrowserTab`, `BuiltInBrowserContextItem` (`kind: "built_in_browser_element" | "built_in_browser_capture"`), `BuiltInBrowserSelectResult`, `BuiltInBrowserScreenshot`, `BuiltInBrowserOpenPanelArgs`, and the `BuiltInBrowserEventPayload` union (`status`, `open-request`, `selection`, `selection-cleared`, `error`). Navigate / create-tab / switch-tab args carry an optional `openPanel: boolean` so callers can ask for the Work sidebar Browser tab to flip open atomically with the navigation. | | `apps/desktop/src/main/services/chat/buildClaudeV2Message.ts` | Builds the message payload the Claude Agent SDK V2 session consumes. Handles base64 image content blocks and MIME inference. | -| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers per-project (`.claude/commands/**`) and per-user (`~/.claude/commands/**`) slash commands, including `.md` command files and `.skill` user-invocable skills, parsing YAML frontmatter for description and argument hints. Consumed by `agentChatService` to enrich the `chat.slashCommands` response so the composer's picker lists local Claude commands alongside SDK-provided ones. | +| `apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts` | Discovers project and user Claude slash surfaces by walking ancestor `.claude` roots, reading `.claude/commands/**/*.md`, `~/.claude/commands/**/*.md`, and `.claude/skills/*/SKILL.md` / `~/.claude/skills/*/SKILL.md` entries with command frontmatter. Consumed by `agentChatService` to enrich both the `chat.slashCommands` response and Claude system prompt with local command/skill metadata. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | | `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. | | `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 994cfdb2..f0525b2e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -97,13 +97,15 @@ and a footer that contains the composer. - **File attach picker** opened with the `@` key. Runs a debounced `ade.agentChat.fileSearch` and discards stale results. - **Slash commands.** Local commands (`/clear`, `/login`) are always - available and resolved renderer-side. SDK commands and project-local - Claude commands discovered by `claudeSlashCommandDiscovery` (from - `.claude/commands/**` and `~/.claude/commands/**`, including - `user-invocable: true` skills) merge in through - `ade.agentChat.slashCommands`. Only `/clear` with `source: "local"` is - intercepted client-side — every other command is sent to the agent - verbatim so provider-native commands still flow. The composer also + available and resolved renderer-side. SDK commands and project/user + Claude commands discovered by `claudeSlashCommandDiscovery` merge in + through `ade.agentChat.slashCommands`; discovery walks ancestor + `.claude` roots and reads `.claude/commands`, `~/.claude/commands`, + `.claude/skills/*/SKILL.md`, and `~/.claude/skills/*/SKILL.md` command + metadata so both command files and local skills can appear in the + picker. Only `/clear` with `source: "local"` is intercepted client-side + — every other command is sent to the agent verbatim so provider-native + commands still flow. The composer also decides whether a leading-slash draft is a command or just a sentence via `isProviderSlashCommandInput` (heuristics in `shared/chatSlashCommands.ts`): `"/rebase the lane?"` is treated as diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 1850a7da..5c56b434 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -356,7 +356,7 @@ changing rather than which service backs it: | General | `GeneralSection.tsx` (embeds `AdeCliSection` in compact form) | AI mode, task routing, terminal preferences (font size, line height, scrollback), keybindings link, and the `ade` CLI install / status surface. The CLI card reports whether the bundled `ade-` binary is on `PATH`, the resolved install target, and exposes one-click Install / Repair backed by the platform install-path helper. Receives the legacy `?tab=onboarding`, `?tab=help`, `?tab=tours`, and `?tab=keybindings` deep links via `TAB_ALIASES`. | | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, agent-turn completion sound + volume + quiet-when-focused, chat font size (`chatFontSizePx`), chat transcript density (`chatTranscriptDensity` — `compact` / `comfortable` / `spacious`), chat chrome tint (`chatChromeTint` — `colored` default vs `neutral` for monochrome chrome; the legacy `chatLaneAccentEmphasis` preset slug is still read so older user-pref blobs migrate cleanly), chat shell geometry (`chatShellGeometry` — `soft` / `default` / `sharp` corners), and the user-message minimap toggle (`chatUserMinimapEnabled` — drives the inline `ChatUserMinimap`). Persisted to `localStorage` under `ade.userPreferences.v1`. | | Workspace | `WorkspaceSettingsSection.tsx`, `ProjectSection.tsx` | Project identity, paths, skill files. (`SyncDevicesSection.tsx` — multi-device sync, host transfer, peer status, pairing PIN, Tailscale discovery — is mounted from the top bar's Sync popover, not as a Settings tab.) | -| AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, AI feature flags | +| AI | `AiSettingsSection.tsx`, `AiFeaturesSection.tsx`, `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics, and AI feature flags. The same status surface is exposed through ADE actions for `ade code` model setup. | | Mobile Push | `MobilePushPanel.tsx` | APNs registration, paired-device push tokens, per-category preferences | | Integrations | `IntegrationsSettingsSection.tsx`, `GitHubSection.tsx`, `LinearSection.tsx` | GitHub, Linear, and computer-use backend readiness. The GitHub section reads `status.connected` (the backend's single "GitHub is usable" gate) to decide between CONNECTED / LIMITED ACCESS / NOT CONNECTED, surfaces a dedicated repo-probe error when a fine-grained token authenticates as a user but cannot access the active repo, and the REFRESH button calls `getStatus({ forceRefresh: true })` so users who fix permissions on github.com see the change immediately. See [`pull-requests/README.md`](../pull-requests/README.md#github-connectivity-model) for the full status-shape and `connected` derivation. | | Memory | `MemoryHealthTab.tsx` | Memory health, browser, embedding health | From 4a02922a7336d1d81a33fdc989eb83ed24507c8e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 02:56:17 -0400 Subject: [PATCH 5/7] Fix Claude slash command precedence --- .../chat/claudeSlashCommandDiscovery.test.ts | 64 ++++++++++++++++-- .../chat/claudeSlashCommandDiscovery.ts | 66 +++++++++++-------- 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts index 034f2f66..161e4c5d 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.test.ts @@ -42,7 +42,7 @@ describe("discoverClaudeSlashCommands", () => { ]); }); - it("uses nested project command basenames like Claude Code and keeps scope in the description", () => { + it("uses nested project command paths for unambiguous discovery", () => { const commandsDir = path.join(tmpRoot, ".claude", "commands", "frontend"); fs.mkdirSync(commandsDir, { recursive: true }); fs.writeFileSync(path.join(commandsDir, "test.md"), [ @@ -56,8 +56,8 @@ describe("discoverClaudeSlashCommands", () => { expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { - name: "/test", - description: "frontend: Run frontend tests", + name: "/frontend:test", + description: "Run frontend tests", }, ]); }); @@ -86,8 +86,8 @@ describe("discoverClaudeSlashCommands", () => { expect(discoverClaudeSlashCommands(tmpRoot)).toMatchObject([ { - name: "/visible", - description: "level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9: Visible nested command", + name: "/level-0:level-1:level-2:level-3:level-4:level-5:level-6:level-7:level-8:level-9:visible", + description: "Visible nested command", }, ]); }); @@ -234,6 +234,35 @@ describe("discoverClaudeSlashCommands", () => { }, ]); }); + + it("does not let home commands override project commands when the project is under home", () => { + const projectRoot = path.join(homeRoot, "workspace", "project"); + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "audit.md"), "Personal audit.\n"); + fs.writeFileSync(path.join(projectRoot, ".claude", "commands", "audit.md"), "Project audit.\n"); + + expect(discoverClaudeSlashCommands(projectRoot)).toMatchObject([ + { + name: "/audit", + description: "Project audit.", + }, + ]); + }); + + it("keeps nested commands with the same basename distinct", () => { + const frontendCommands = path.join(tmpRoot, ".claude", "commands", "frontend"); + const backendCommands = path.join(tmpRoot, ".claude", "commands", "backend"); + fs.mkdirSync(frontendCommands, { recursive: true }); + fs.mkdirSync(backendCommands, { recursive: true }); + fs.writeFileSync(path.join(frontendCommands, "button.md"), "Frontend button.\n"); + fs.writeFileSync(path.join(backendCommands, "button.md"), "Backend button.\n"); + + expect(discoverClaudeSlashCommands(tmpRoot)).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/backend:button", description: "Backend button." }), + expect.objectContaining({ name: "/frontend:button", description: "Frontend button." }), + ])); + }); }); describe("resolveClaudeSlashCommandInvocation", () => { @@ -267,6 +296,31 @@ describe("resolveClaudeSlashCommandInvocation", () => { expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/ship now")?.promptText).toBe("Project now"); }); + it("lets project command files under home override same-named personal command files", () => { + const projectRoot = path.join(homeRoot, "workspace", "project"); + fs.mkdirSync(path.join(homeRoot, ".claude", "commands"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, ".claude", "commands"), { recursive: true }); + fs.writeFileSync(path.join(homeRoot, ".claude", "commands", "ship.md"), "Personal $ARGUMENTS\n"); + fs.writeFileSync(path.join(projectRoot, ".claude", "commands", "ship.md"), "Project $ARGUMENTS\n"); + + expect(resolveClaudeSlashCommandInvocation(projectRoot, "/ship now")?.promptText).toBe("Project now"); + }); + + it("requires colon paths when nested command basenames are ambiguous", () => { + const frontendCommands = path.join(tmpRoot, ".claude", "commands", "frontend"); + const backendCommands = path.join(tmpRoot, ".claude", "commands", "backend"); + fs.mkdirSync(frontendCommands, { recursive: true }); + fs.mkdirSync(backendCommands, { recursive: true }); + fs.writeFileSync(path.join(frontendCommands, "button.md"), "Frontend $ARGUMENTS\n"); + fs.writeFileSync(path.join(backendCommands, "button.md"), "Backend $ARGUMENTS\n"); + + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/button primary")).toBeNull(); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/frontend:button primary")?.promptText) + .toBe("Frontend primary"); + expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/backend:button primary")?.promptText) + .toBe("Backend primary"); + }); + it("returns null for built-in commands and unknown command files", () => { expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/help")).toBeNull(); expect(resolveClaudeSlashCommandInvocation(tmpRoot, "/missing")).toBeNull(); diff --git a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts index ef4e3e03..eb7f1cec 100644 --- a/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts +++ b/apps/desktop/src/main/services/chat/claudeSlashCommandDiscovery.ts @@ -102,7 +102,7 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma if (!entry.isFile() || !entry.name.endsWith(".md")) continue; const relative = path.relative(commandsDir, entryPath).replace(/\.md$/i, ""); const parts = relative.split(path.sep).filter(Boolean); - const commandName = parts[parts.length - 1] ?? ""; + const commandName = parts.join(":"); const name = normalizeSlashCommandName(commandName); if (!name) continue; let content = ""; @@ -112,11 +112,10 @@ function discoverLegacyCommands(commandsDir: string): DiscoveredClaudeSlashComma continue; } const frontmatter = readFrontmatter(content) as CommandFrontmatter; - const scope = parts.slice(0, -1).join(":"); const description = maybeString(frontmatter.description) ?? firstMarkdownParagraph(content); commands.push({ name, - description: scope && description ? `${scope}: ${description}` : description, + description, argumentHint: maybeArgumentHint(frontmatter["argument-hint"]) ?? maybeArgumentHint(frontmatter.argumentHint), source: "command", filePath: entryPath, @@ -145,13 +144,13 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str } // Slow path: discovery normalizes filenames (lowercase + slugified), so a // file like `My Command.md` is exposed as `/My-Command` but the literal - // path above won't find it. Nested Claude commands are invoked by basename - // (`commands/frontend/component.md` -> `/component`), but keep the legacy - // colon path accepted for older ADE command references. + // path above won't find it. Unique basename lookup is accepted for older ADE + // command references, but duplicate basenames must use their colon path. const targetName = slashCommandKey(commandName); - let match: string | null = null; + const pathMatches: string[] = []; + const baseMatches: string[] = []; const visit = (dir: string, prefix: string[], depth: number): void => { - if (match || depth > MAX_LEGACY_COMMAND_DEPTH) return; + if (depth > MAX_LEGACY_COMMAND_DEPTH) return; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -159,7 +158,6 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str return; } for (const entry of entries) { - if (match) return; const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { visit(entryPath, [...prefix, entry.name], depth + 1); @@ -169,17 +167,13 @@ function resolveLegacyCommandFile(commandsDir: string, commandName: string): str const commandPath = [...prefix, entry.name].join(":"); const normalizedPath = normalizeSlashCommandName(commandPath); const normalizedBase = normalizeSlashCommandName(entry.name); - if ( - (normalizedPath && slashCommandKey(normalizedPath) === targetName) - || (normalizedBase && slashCommandKey(normalizedBase) === targetName) - ) { - match = entryPath; - return; - } + if (normalizedPath && slashCommandKey(normalizedPath) === targetName) pathMatches.push(entryPath); + if (normalizedBase && slashCommandKey(normalizedBase) === targetName) baseMatches.push(entryPath); } }; visit(commandsDir, [], 0); - return match; + if (pathMatches.length > 0) return pathMatches[0] ?? null; + return baseMatches.length === 1 ? baseMatches[0] ?? null : null; } function discoverSkills(skillsDir: string): DiscoveredClaudeSlashCommand[] { @@ -222,7 +216,7 @@ function discoverSkills(skillsDir: string): DiscoveredClaudeSlashCommand[] { function ancestorClaudeRoots(cwd: string): string[] { const roots: string[] = []; const seen = new Set(); - const home = os.homedir(); + const home = path.resolve(os.homedir()); let current = path.resolve(cwd); let depth = 0; while (depth < 25) { @@ -240,11 +234,25 @@ function ancestorClaudeRoots(cwd: string): string[] { return roots; } +function claudeRootsByPrecedence(cwd: string): string[] { + const roots: string[] = []; + const seen = new Set(); + const home = path.resolve(os.homedir()); + const addRoot = (root: string): void => { + if (seen.has(root)) return; + seen.add(root); + roots.push(root); + }; + + for (const root of ancestorClaudeRoots(cwd)) { + addRoot(root); + } + addRoot(path.join(home, ".claude")); + return roots; +} + export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashCommand[] { - const roots = [ - path.join(os.homedir(), ".claude"), - ...ancestorClaudeRoots(cwd), - ]; + const roots = claudeRootsByPrecedence(cwd); const byName = new Map(); for (const root of roots) { @@ -253,7 +261,8 @@ export function discoverClaudeSlashCommands(cwd: string): DiscoveredClaudeSlashC ...discoverSkills(path.join(root, "skills")), ]; for (const command of discovered) { - byName.set(slashCommandKey(command.name), command); + const key = slashCommandKey(command.name); + if (!byName.has(key)) byName.set(key, command); } } @@ -308,19 +317,18 @@ export function resolveClaudeSlashCommandInvocation( const name = match[1]; if (!name) return null; const argumentsText = match[2]?.trim() ?? ""; - const roots = [ - path.join(os.homedir(), ".claude"), - ...ancestorClaudeRoots(cwd), - ]; + const roots = claudeRootsByPrecedence(cwd); // Prefer command files; fall back to user-invocable skills (SKILL.md). let resolvedFile: string | null = null; for (const root of roots) { - resolvedFile = resolveLegacyCommandFile(path.join(root, "commands"), name) ?? resolvedFile; + resolvedFile = resolveLegacyCommandFile(path.join(root, "commands"), name); + if (resolvedFile) break; } if (!resolvedFile) { for (const root of roots) { - resolvedFile = resolveSkillFile(path.join(root, "skills"), name) ?? resolvedFile; + resolvedFile = resolveSkillFile(path.join(root, "skills"), name); + if (resolvedFile) break; } } if (!resolvedFile) return null; From 315a24bf961485025f25dbaec46492a0d5f8821c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 03:05:43 -0400 Subject: [PATCH 6/7] Normalize ade code help whitespace --- apps/ade-cli/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 9a8a35ea..23175a2e 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -901,7 +901,7 @@ const HELP_BY_COMMAND: Record = { esc Return or cancel the active pane ? Help when it is the first prompt character / Command palette - `, + `, lanes: `${ADE_BANNER} Lanes From f9d780bf7eaa1d8ad52750d9d1e97f1dbdf01d97 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 11 May 2026 03:16:00 -0400 Subject: [PATCH 7/7] Harden TUI slash command login filter --- .../src/tuiClient/__tests__/adeApi.test.ts | 21 +++++++++++++++++++ apps/ade-cli/src/tuiClient/adeApi.ts | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 5c1821ed..7d191c4c 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -124,6 +124,27 @@ describe("discoverProjectSlashCommands", () => { }), ])); }); + + it("hides login commands regardless of project command filename casing", () => { + const projectRoot = makeTmpRoot("ade-code-login-command-"); + const commandsDir = path.join(projectRoot, ".claude", "commands"); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, "Login.md"), [ + "---", + "description: Case variant login", + "---", + "", + "Login.", + "", + ].join("\n")); + fs.writeFileSync(path.join(commandsDir, "ship.md"), "Ship.\n"); + + const commands = discoverProjectSlashCommands(projectRoot); + expect(commands.some((command) => command.name.toLowerCase() === "/login")).toBe(false); + expect(commands).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: "/ship" }), + ])); + }); }); describe("createChatSession", () => { diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 5cfeee39..36d20dd1 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -69,8 +69,8 @@ function slashCommandKey(value: string): string { export function discoverProjectSlashCommands(workspaceRoot: string): AgentChatSlashCommand[] { const byName = new Map(); const add = (command: { name: string; description: string; argumentHint?: string }) => { - if (command.name === "/login") return; const key = slashCommandKey(command.name); + if (key === "/login") return; if (byName.has(key)) return; byName.set(key, { name: command.name,