diff --git a/.changeset/terminal-chat-controls.md b/.changeset/terminal-chat-controls.md new file mode 100644 index 000000000..c8ba20b95 --- /dev/null +++ b/.changeset/terminal-chat-controls.md @@ -0,0 +1,7 @@ +--- +"@inkeep/open-knowledge": patch +--- + +Starting an AI chat in the docked terminal is now a first-class action. A single **Open chat / Close chat** button in the editor header reveals the terminal (collapsing the right document panel so the chat gets the full column) and starts your default CLI when no session is open, or hides it when it's showing. The terminal tab strip's **+** is now **New chat** (launches your default CLI, next to the last tab) alongside a separate **New terminal tab** button for a plain shell. + +Your **default CLI** is detected from what's installed — priority Claude → Codex → OpenCode → Cursor — and the one you pick anywhere sticks for next time; if none are installed it falls back to Claude with the existing install prompt. The "Ask AI" bubble and the file-tree / composer "Open in terminal" actions use the same default and also clear the document panel for the chat. Across the app, the **Terminal** option now leads the "Open with AI" menus ahead of Desktop. diff --git a/packages/app/src/components/BottomComposer.dom.test.tsx b/packages/app/src/components/BottomComposer.dom.test.tsx index 4cbed51a3..b7caeb184 100644 --- a/packages/app/src/components/BottomComposer.dom.test.tsx +++ b/packages/app/src/components/BottomComposer.dom.test.tsx @@ -223,6 +223,13 @@ async function renderComposerWithThrowingTerminal(docName = 'notes') { ); } +async function renderComposerWithInstalledClis(installed: Record) { + (window as { okDesktop?: unknown }).okDesktop = { + terminal: { cliInstalledMap: async () => installed }, + }; + return renderComposerWithTerminal(); +} + async function renderFolderComposer(folderPath = 'specs/foo') { const { BottomComposer } = await import('./BottomComposer'); return render(); @@ -291,6 +298,7 @@ beforeEach(() => { afterEach(() => { cleanup(); consoleErrorSpy.mockRestore(); + delete (window as { okDesktop?: unknown }).okDesktop; }); describe('BottomComposer (shell behavior)', () => { @@ -562,6 +570,42 @@ describe('BottomComposer (dispatch + picker + sticky default)', () => { expect(terminalLaunchCalls).toHaveLength(0); }); + test('desktop with no sticky pick leads with the first-installed CLI (Codex when Claude is absent)', async () => { + await renderComposerWithInstalledClis({ + claude: false, + codex: true, + opencode: false, + cursor: true, + }); + await waitFor(() => + expect(screen.getByTestId('ask-ai-send').textContent).toContain('Codex CLI'), + ); + }); + + test('desktop with no sticky pick and no CLI installed defaults to the Claude CLI', async () => { + await renderComposerWithInstalledClis({ + claude: false, + codex: false, + opencode: false, + cursor: false, + }); + await waitFor(() => + expect(screen.getByTestId('ask-ai-send').textContent).toContain('Claude CLI'), + ); + }); + + test('the Ask X picker lists the Terminal section before the Desktop section (Terminal-first)', async () => { + const user = userEvent.setup(); + await renderComposerWithTerminal(); + + await user.click(screen.getByTestId('ask-ai-agent-trigger')); + const terminalLabel = await screen.findByText('Terminal'); + const desktopLabel = screen.getByText('Desktop'); + expect( + terminalLabel.compareDocumentPosition(desktopLabel) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + test('without a docked terminal (web host) all Terminal CLI options are absent', async () => { const user = userEvent.setup(); await renderComposer(); diff --git a/packages/app/src/components/BottomComposer.tsx b/packages/app/src/components/BottomComposer.tsx index d703fa9d2..c0e82e7f1 100644 --- a/packages/app/src/components/BottomComposer.tsx +++ b/packages/app/src/components/BottomComposer.tsx @@ -31,7 +31,9 @@ import { selectionSnapshotToCompose, } from '@/editor/selection-context'; import type { EditorSurface } from '@/editor/selection-stats'; +import { useInstalledClis } from '@/hooks/use-installed-clis'; import { useSelectionContext } from '@/hooks/use-selection-context'; +import { resolveDefaultCli } from '@/lib/default-cli-resolver'; import { VISIBLE_TARGETS } from '@/lib/handoff/targets'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; import { recordOnboardingAskedAi } from '@/lib/onboarding-signals'; @@ -137,6 +139,7 @@ export function BottomComposer({ const terminalLaunch = useTerminalLaunch(); const [stickyId] = useState(() => loadStickyAgent()); const [selectedId, setSelectedId] = useState(null); + const installedClis = useInstalledClis(); const [isEmpty, setIsEmpty] = useState(true); const [pending, setPending] = useState(false); const inputRef = useRef(null); @@ -276,8 +279,11 @@ export function BottomComposer({ }, [liveFrontmatterSelection]); const effectiveId = selectedId ?? stickyId; - const selectedCli: TerminalCli | null = + const explicitCli: TerminalCli | null = terminalLaunch !== null ? parseStickyCliId(effectiveId) : null; + const defaultCli: TerminalCli | null = + terminalLaunch !== null && effectiveId === null ? resolveDefaultCli(null, installedClis) : null; + const selectedCli: TerminalCli | null = explicitCli ?? defaultCli; const isTerminalSelected = selectedCli !== null; const resolvedTarget = isTerminalSelected ? null : resolveStickyAgent(states, effectiveId); diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index 4ba6a4fb5..ec0087c4b 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -16,6 +16,7 @@ import { AssetPreview } from '@/components/AssetPreview'; import { DocPanel, type PanelTab } from '@/components/DocPanel'; import { consumePendingDocPanelTabRequest, + subscribeToDocPanelCollapseRequests, subscribeToDocPanelTabRequests, } from '@/components/doc-panel-events'; import { EditorSkeleton } from '@/components/EditorSkeleton'; @@ -321,6 +322,21 @@ function EditorAreaInner({ panelRef.current?.expand(); }, [docPanelExpandSignal, panelRef]); + useEffect(() => { + let rafId = 0; + const unsubscribe = subscribeToDocPanelCollapseRequests(() => { + if (terminalDock !== 'right') return; + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + panelRef.current?.collapse(); + }); + }); + return () => { + cancelAnimationFrame(rafId); + unsubscribe(); + }; + }, [panelRef, terminalDock]); + useLayoutEffect(() => { if (!isCollapsed) return; const panelEl = document.getElementById('doc-panel'); diff --git a/packages/app/src/components/EditorHeader.dom.test.tsx b/packages/app/src/components/EditorHeader.dom.test.tsx index f8670e519..2cbd497d5 100644 --- a/packages/app/src/components/EditorHeader.dom.test.tsx +++ b/packages/app/src/components/EditorHeader.dom.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, mock, test } from 'bun:test'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; import { TooltipProvider } from '@/components/ui/tooltip'; import { @@ -82,11 +82,17 @@ function setElectronHost(enabled: boolean) { }); } -async function renderHeader() { +interface TerminalHeaderProps { + terminalAvailable?: boolean; + terminalVisible?: boolean; + onToggleChat?: () => void; +} + +async function renderHeader(props: TerminalHeaderProps = {}) { const { EditorHeader } = await import('./EditorHeader'); render( - + , ); return document.querySelector('header') as HTMLElement; @@ -215,4 +221,33 @@ describe('EditorHeader runtime behavior', () => { expect(lastShareInput).toBeNull(); }); + + test('terminalAvailable renders a single Open chat button when the dock is hidden', async () => { + await renderHeader({ terminalAvailable: true, terminalVisible: false }); + + expect(screen.getByRole('button', { name: 'Open chat' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Close chat' })).toBeNull(); + }); + + test('the button label flips to Close chat when the dock is visible', async () => { + await renderHeader({ terminalAvailable: true, terminalVisible: true }); + + expect(screen.getByRole('button', { name: 'Close chat' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Open chat' })).toBeNull(); + }); + + test('clicking the chat toggle fires its handler', async () => { + const onToggleChat = mock(() => {}); + await renderHeader({ terminalAvailable: true, terminalVisible: false, onToggleChat }); + + fireEvent.click(screen.getByRole('button', { name: 'Open chat' })); + expect(onToggleChat).toHaveBeenCalledTimes(1); + }); + + test('the chat toggle does not render when terminalAvailable is false (web / no terminal surface)', async () => { + await renderHeader({ terminalAvailable: false }); + + expect(screen.queryByRole('button', { name: 'Open chat' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Close chat' })).toBeNull(); + }); }); diff --git a/packages/app/src/components/EditorHeader.tsx b/packages/app/src/components/EditorHeader.tsx index d15937c35..476dc78fd 100644 --- a/packages/app/src/components/EditorHeader.tsx +++ b/packages/app/src/components/EditorHeader.tsx @@ -1,6 +1,6 @@ import { parseManagedArtifactName } from '@inkeep/open-knowledge-core'; import { Trans, useLingui } from '@lingui/react/macro'; -import { Search } from 'lucide-react'; +import { MessageSquare, Search } from 'lucide-react'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; @@ -28,10 +28,27 @@ interface EditorHeaderProps { onSignIn?: () => void; onSetIdentity?: () => void; onOpenSearch?: () => void; + /** Desktop terminal is available (bridge + `terminal` surface). Gates the + * chat toggle header button; absent/false on web. */ + terminalAvailable?: boolean; + /** Current dock visibility — flips the button between "Open chat" and "Close + * chat" so the label always names the action it performs. */ + terminalVisible?: boolean; + /** Toggle the docked chat: when hidden, reveal it (collapsing the doc-panel) + * and spawn a default-CLI chat if none exists; when shown, hide it. */ + onToggleChat?: () => void; } -export function EditorHeader({ onSignIn, onSetIdentity, onOpenSearch }: EditorHeaderProps) { +export function EditorHeader({ + onSignIn, + onSetIdentity, + onOpenSearch, + terminalAvailable, + terminalVisible, + onToggleChat, +}: EditorHeaderProps) { const { t } = useLingui(); + const chatToggleLabel = terminalVisible ? t`Close chat` : t`Open chat`; const { activeDocName, activeTarget } = useDocumentContext(); const managedArtifact = activeDocName ? parseManagedArtifactName(activeDocName) : null; const { state: sidebarState, isDraggingRail } = useSidebar(); @@ -149,6 +166,29 @@ export function EditorHeader({ onSignIn, onSetIdentity, onOpenSearch }: EditorHe )} + {/* Desktop-only chat toggle (gated on the derived terminalAvailable), + placed after the badges, before the vertical separator. One control: + Open chat (reveal-and-start) ⇄ Close chat (hide). */} + {terminalAvailable && ( + + + + + {chatToggleLabel} + + )} {/* Settings is unavailable in single-file mode (config editing is inert). */} diff --git a/packages/app/src/components/EditorPane.dom.test.tsx b/packages/app/src/components/EditorPane.dom.test.tsx index f6062ddb3..63636410c 100644 --- a/packages/app/src/components/EditorPane.dom.test.tsx +++ b/packages/app/src/components/EditorPane.dom.test.tsx @@ -249,6 +249,12 @@ function makeOkDesktopStub( }, terminal: { getDockState, + cliInstalledMap: async () => ({ + claude: true, + codex: false, + opencode: false, + cursor: false, + }), }, }, }; diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx index c4ab62483..aa356c1ad 100644 --- a/packages/app/src/components/EditorPane.tsx +++ b/packages/app/src/components/EditorPane.tsx @@ -6,8 +6,10 @@ import { RAW_MDX_NAV_EVENT, type RawMdxNavDetail } from '@/editor/extensions/raw import { rememberPendingSourceNavigation } from '@/editor/source-editor-navigation'; import { type EditorModeValue, useEditorMode } from '@/editor/use-editor-mode'; import { useGitSyncStatus } from '@/hooks/use-git-sync-status'; +import { useInstalledClis } from '@/hooks/use-installed-clis'; import { useNoPushPermissionToast } from '@/hooks/use-no-push-permission-toast'; import { useConfigContext } from '@/lib/config-provider'; +import { resolveDefaultCli } from '@/lib/default-cli-resolver'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; import { getInitialTerminalDock, @@ -15,17 +17,19 @@ import { writeTerminalDock, } from '@/lib/terminal-dock-store'; import { recordTerminalOpened } from '@/lib/terminal-telemetry'; +import { loadStickyAgent } from '@/lib/unified-agent-store'; import { AuthModal } from './AuthModal'; import { AutoSyncOnboardingDialog } from './AutoSyncOnboardingDialog'; import { shouldShowAutoSyncOnboarding } from './auto-sync-onboarding-gate'; import { type PanelTab, TABS } from './DocPanel'; +import { requestDocPanelCollapse } from './doc-panel-events'; import { EditorArea, type TerminalPlacement } from './EditorArea'; import { EditorHeader } from './EditorHeader'; import { subscribeToTerminalLaunchRequests } from './handoff/terminal-launch-events'; import { TerminalSessionsHost } from './TerminalSessionsHost'; export interface TerminalLaunchIntent { - readonly prompt: string; + readonly prompt: string | null; readonly cli: TerminalCli; readonly nonce: number; } @@ -44,7 +48,10 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { const [activeTab, setActiveTab] = useState(TABS[0].id); const [autoSyncOnboardingDismissed, setAutoSyncOnboardingDismissed] = useState(false); const desktopBridge = typeof window !== 'undefined' ? (window.okDesktop ?? null) : null; + const terminalAvailable = desktopBridge != null && desktopBridge.terminal != null; const [terminalVisible, setTerminalVisible] = useState(false); + const installedClis = useInstalledClis(); + const [hasSessions, setHasSessions] = useState(false); const [dockRestoreSettled, setDockRestoreSettled] = useState(false); const restoreRevealRef = useRef(false); const [terminalLaunch, setTerminalLaunch] = useState(null); @@ -66,6 +73,23 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { }); const launchNonceRef = useRef(0); + function launchNewChat(cli?: TerminalCli) { + const resolvedCli = cli ?? resolveDefaultCli(loadStickyAgent(), installedClis); + setTerminalVisible(true); + launchNonceRef.current += 1; + setTerminalLaunch({ prompt: null, cli: resolvedCli, nonce: launchNonceRef.current }); + } + + function handleToggleChat() { + if (terminalVisible) { + setTerminalVisible(false); + return; + } + setTerminalVisible(true); + requestDocPanelCollapse(); + if (!hasSessions) launchNewChat(); + } + const syncStatus = useGitSyncStatus(); const { projectConfig, projectLocalConfig, projectLocalSynced, projectSynced } = useConfigContext(); @@ -121,6 +145,7 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { useEffect(() => { return subscribeToTerminalLaunchRequests((prompt, cli) => { setTerminalVisible(true); + requestDocPanelCollapse(); launchNonceRef.current += 1; setTerminalLaunch({ prompt, cli, nonce: launchNonceRef.current }); }); @@ -190,6 +215,9 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { setAuthModalOpen(true); }} onOpenSearch={onOpenSearch} + terminalAvailable={terminalAvailable} + terminalVisible={terminalVisible} + onToggleChat={handleToggleChat} /> {/* The terminal docks to the right of the doc panel (its own column) or under the editor/file column. EditorArea owns the layout; the dock @@ -207,17 +235,21 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { terminalDock={terminalDock} onTerminalPlacement={setTerminalPlacement} /> - {desktopBridge != null ? ( + {/* The `desktopBridge != null` clause re-narrows the bridge to non-null for + the prop — the derived `terminalAvailable` boolean can't narrow it. */} + {terminalAvailable && desktopBridge != null ? ( terminalPlacement.editorRegion?.focus()} dockPosition={terminalDock} onToggleDock={toggleTerminalDock} + onHasSessionsChange={setHasSessions} /> ) : null} { expect(screen.getAllByTestId('terminal-session')).toHaveLength(1); const firstActive = activePanelId(); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); expect(screen.getAllByTestId('terminal-session')).toHaveLength(2); expect(activePanelId()).not.toBe(firstActive); @@ -284,8 +284,8 @@ describe('TerminalDock multi-session', () => { test('all sessions stay mounted with exactly one active', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); expect(screen.getAllByTestId('terminal-session')).toHaveLength(3); const tabpanels = screen.getAllByRole('tabpanel', { hidden: true }); @@ -298,7 +298,7 @@ describe('TerminalDock multi-session', () => { test('switching tabs changes the active session without unmounting the others', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const secondActive = activePanelId(); await user.click(screen.getByRole('tab', { name: 'Terminal 1' })); @@ -310,7 +310,7 @@ describe('TerminalDock multi-session', () => { test('typing target stays scoped: the active panel is the only one shown', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); expect(sessionPanels()).toHaveLength(2); const activeCount = document.querySelectorAll( @@ -322,7 +322,7 @@ describe('TerminalDock multi-session', () => { test('selecting a tab moves focus to that session', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const second = activePanelId(); await user.click(screen.getByRole('tab', { name: 'Terminal 1' })); @@ -338,7 +338,7 @@ describe('TerminalDock multi-session', () => { test('closing a non-active tab removes only it and leaves the active one running', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const activeBefore = activePanelId(); await user.click(screen.getByRole('button', { name: 'Close Terminal 1' })); @@ -350,7 +350,7 @@ describe('TerminalDock multi-session', () => { test("a session's OSC title becomes its tab label; siblings keep the default", async () => { const user = userEvent.setup(); const view = renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await waitFor(() => expect(view.create).toHaveBeenCalledTimes(2)); act(() => emitTitle('pty-1', 'claude — repo')); @@ -396,7 +396,7 @@ describe('TerminalDock multi-session', () => { test("closing a tab reaps only that session's PTY and leaves the others alive", async () => { const user = userEvent.setup(); const view = renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await waitFor(() => expect(view.create).toHaveBeenCalledTimes(2)); expect(view.kill).not.toHaveBeenCalled(); @@ -410,8 +410,8 @@ describe('TerminalDock multi-session', () => { test('closing the active tab activates its left neighbor', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await user.click(screen.getByRole('tab', { name: 'Terminal 2' })); const middle = activePanelId(); @@ -428,7 +428,7 @@ describe('TerminalDock multi-session', () => { test('closing the active leftmost tab activates its right neighbor', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await user.click(screen.getByRole('tab', { name: 'Terminal 1' })); const closedId = activePanelId(); const rightNeighborId = @@ -445,8 +445,8 @@ describe('TerminalDock multi-session', () => { test('closing the active tab moves focus into the surviving neighbor', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await user.click(screen.getByRole('tab', { name: 'Terminal 2' })); await user.click(screen.getByRole('button', { name: 'Close Terminal 2' })); @@ -473,8 +473,8 @@ describe('TerminalDock multi-session', () => { test('hiding the dock preserves every session and keeps the last-active tab on reopen', async () => { const user = userEvent.setup(); const view = renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await user.click(screen.getByRole('tab', { name: 'Terminal 2' })); const activeBeforeHide = activePanelId(); expect(screen.getAllByTestId('terminal-session')).toHaveLength(3); @@ -590,7 +590,7 @@ describe('TerminalDock multi-session', () => { test('the Terminal menu "Kill Terminal" action closes the active tab', async () => { const user = userEvent.setup(); const view = renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); expect(screen.getAllByTestId('terminal-session')).toHaveLength(2); act(() => view.dispatchMenuAction('kill-terminal')); @@ -601,8 +601,8 @@ describe('TerminalDock multi-session', () => { test('Cmd+number jumps to the matching tab while the terminal is focused', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const panels = sessionPanels(); const thirdSink = panels[2]?.querySelector('.xterm-helper-textarea'); act(() => thirdSink?.focus()); @@ -628,7 +628,7 @@ describe('TerminalDock multi-session', () => { test('Cmd+number for a tab that does not exist is left for the shell', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const panels = sessionPanels(); act(() => panels[1]?.querySelector('.xterm-helper-textarea')?.focus()); const before = activePanelId(); @@ -650,7 +650,7 @@ describe('TerminalDock multi-session', () => { test('Cmd+number is ignored when focus is outside the terminal dock', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); await user.click(screen.getByRole('tab', { name: 'Terminal 1' })); const before = activePanelId(); @@ -672,7 +672,7 @@ describe('TerminalDock multi-session', () => { test('a non-chord keystroke is not intercepted so it reaches the active shell', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const before = activePanelId(); act(() => sessionPanels()[0]?.querySelector('.xterm-helper-textarea')?.focus()); @@ -711,7 +711,7 @@ describe('TerminalDock multi-session', () => { test('wires each tab to its panel via accessible tablist/tabpanel relationships', async () => { const user = userEvent.setup(); renderDock(true); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); const tablist = screen.getByRole('tablist', { name: 'Terminal sessions' }); const tabs = within(tablist).getAllByRole('tab'); diff --git a/packages/app/src/components/TerminalSessionsHost.tsx b/packages/app/src/components/TerminalSessionsHost.tsx index e023251c5..1f374c31e 100644 --- a/packages/app/src/components/TerminalSessionsHost.tsx +++ b/packages/app/src/components/TerminalSessionsHost.tsx @@ -1,9 +1,12 @@ +import type { TerminalCli } from '@inkeep/open-knowledge-core'; import { useLingui } from '@lingui/react/macro'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { TabsContent } from '@/components/ui/tabs'; +import { resolveDefaultCli } from '@/lib/default-cli-resolver'; import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import type { TerminalDockPosition } from '@/lib/terminal-dock-store'; +import { loadStickyAgent } from '@/lib/unified-agent-store'; import { emitOpenAskAiComposer } from './ask-ai-composer-events'; import type { TerminalLaunchIntent } from './EditorPane'; import { subscribeToActiveTerminalInput } from './handoff/terminal-input-events'; @@ -45,6 +48,9 @@ interface TerminalSessionsHostProps { readonly visible: boolean; readonly onVisibleChange: (visible: boolean) => void; readonly launch?: TerminalLaunchIntent | null; + /** Which CLIs are on PATH (desktop probe). The tab strip's "New chat" resolves + * its default CLI from this + the sticky pick. */ + readonly installedClis?: Partial>; readonly container: HTMLElement | null; readonly isShowing: boolean; readonly onRequestEditorFocus: () => void; @@ -52,6 +58,10 @@ interface TerminalSessionsHostProps { * controls so their icons/labels reflect where the terminal lives. */ readonly dockPosition: TerminalDockPosition; readonly onToggleDock: () => void; + /** Reports whether any PTY session is currently open. The header's New chat + * button reads this to decide between spawning a first chat and merely + * revealing the existing dock. */ + readonly onHasSessionsChange?: (hasSessions: boolean) => void; } export function TerminalSessionsHost({ @@ -59,11 +69,13 @@ export function TerminalSessionsHost({ visible, onVisibleChange, launch = null, + installedClis, container, isShowing, onRequestEditorFocus, dockPosition, onToggleDock, + onHasSessionsChange, }: TerminalSessionsHostProps) { const { t } = useLingui(); @@ -101,6 +113,7 @@ export function TerminalSessionsHost({ if (ptyId === null) ptyIdBySessionRef.current.delete(id); else ptyIdBySessionRef.current.set(id, ptyId); } + const stripLaunchNonceRef = useRef(0); function openSession(launchForSession: TerminalLaunchIntent | null) { sessionCounterRef.current += 1; @@ -112,6 +125,15 @@ export function TerminalSessionsHost({ setActiveSessionId(id); } + function openNewChatSession() { + stripLaunchNonceRef.current += 1; + openSession({ + prompt: null, + cli: resolveDefaultCli(loadStickyAgent(), installedClis ?? {}), + nonce: stripLaunchNonceRef.current, + }); + } + function setSessionTitle(id: string, title: string) { const next = title.trim() === '' ? null : title.trim(); setSessions((prev) => { @@ -238,6 +260,10 @@ export function TerminalSessionsHost({ bridge.editor.notifyViewMenuStateChanged({ terminalLive: sessions.length > 0 }); }, [bridge, sessions.length]); + useEffect(() => { + onHasSessionsChange?.(sessions.length > 0); + }, [onHasSessionsChange, sessions.length]); + useLayoutEffect(() => { if (isShowing || visible) return; if (hostEl == null || !hostEl.contains(document.activeElement)) return; @@ -261,7 +287,8 @@ export function TerminalSessionsHost({ activeSessionId={activeSessionId} onSelect={setActiveSessionId} onTabActivate={(id) => queueMicrotask(() => focusTerminalSession(id))} - onNew={() => openSession(null)} + onNewChat={openNewChatSession} + onNewTerminalTab={() => openSession(null)} onClose={closeSession} dockPosition={dockPosition} onToggleDock={onToggleDock} diff --git a/packages/app/src/components/TerminalTabStrip.dom.test.tsx b/packages/app/src/components/TerminalTabStrip.dom.test.tsx index 5d27c08d2..d0f0bece2 100644 --- a/packages/app/src/components/TerminalTabStrip.dom.test.tsx +++ b/packages/app/src/components/TerminalTabStrip.dom.test.tsx @@ -17,7 +17,8 @@ function renderStrip(props?: { }) { const onSelect = mock((_id: string) => {}); const onTabActivate = mock((_id: string) => {}); - const onNew = mock(() => {}); + const onNewChat = mock(() => {}); + const onNewTerminalTab = mock(() => {}); const onClose = mock((_id: string) => {}); const onToggleDock = mock(() => {}); const onCollapse = mock(() => {}); @@ -28,7 +29,8 @@ function renderStrip(props?: { activeSessionId={props?.activeSessionId ?? 's1'} onSelect={onSelect} onTabActivate={onTabActivate} - onNew={onNew} + onNewChat={onNewChat} + onNewTerminalTab={onNewTerminalTab} onClose={onClose} dockPosition={props?.dockPosition ?? 'bottom'} onToggleDock={onToggleDock} @@ -36,7 +38,15 @@ function renderStrip(props?: { /> , ); - return { onSelect, onTabActivate, onNew, onClose, onToggleDock, onCollapse }; + return { + onSelect, + onTabActivate, + onNewChat, + onNewTerminalTab, + onClose, + onToggleDock, + onCollapse, + }; } describe('TerminalTabStrip', () => { @@ -104,26 +114,50 @@ describe('TerminalTabStrip', () => { expect(onSelect).toHaveBeenCalledWith('s2'); }); - test('the new-terminal control reports onNew and never onSelect', async () => { + test('the New chat control reports onNewChat and never onSelect / onNewTerminalTab', async () => { const user = userEvent.setup(); - const { onNew, onSelect } = renderStrip(); + const { onNewChat, onNewTerminalTab, onSelect } = renderStrip(); - await user.click(screen.getByRole('button', { name: 'New terminal' })); + await user.click(screen.getByRole('button', { name: 'New chat' })); - expect(onNew).toHaveBeenCalledTimes(1); + expect(onNewChat).toHaveBeenCalledTimes(1); + expect(onNewTerminalTab).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled(); }); + test('the New terminal tab control reports onNewTerminalTab (bare shell) and never onNewChat', async () => { + const user = userEvent.setup(); + const { onNewChat, onNewTerminalTab } = renderStrip(); + + await user.click(screen.getByRole('button', { name: 'New terminal tab' })); + + expect(onNewTerminalTab).toHaveBeenCalledTimes(1); + expect(onNewChat).not.toHaveBeenCalled(); + }); + + test('New chat hugs the last tab, preceding the trailing New terminal tab / collapse controls', () => { + renderStrip(); + const newChat = screen.getByRole('button', { name: 'New chat' }); + const newTerminalTab = screen.getByRole('button', { name: 'New terminal tab' }); + const collapse = screen.getByRole('button', { name: 'Collapse terminal' }); + expect( + newChat.compareDocumentPosition(newTerminalTab) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + newTerminalTab.compareDocumentPosition(collapse) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + test('a tab close control reports onClose with that session id only', async () => { const user = userEvent.setup(); - const { onClose, onSelect, onNew } = renderStrip({ activeSessionId: 's1' }); + const { onClose, onSelect, onNewChat } = renderStrip({ activeSessionId: 's1' }); await user.click(screen.getByRole('button', { name: 'Close Terminal 2' })); expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledWith('s2'); expect(onSelect).not.toHaveBeenCalled(); - expect(onNew).not.toHaveBeenCalled(); + expect(onNewChat).not.toHaveBeenCalled(); }); test('the dock-toggle reports onToggleDock and labels the resulting position', async () => { @@ -142,15 +176,16 @@ describe('TerminalTabStrip', () => { expect(screen.queryByRole('button', { name: 'Dock terminal to the right' })).toBeNull(); }); - test('the collapse control reports onCollapse and never onClose/onNew', async () => { + test('the collapse control reports onCollapse and never onClose / onNewChat / onNewTerminalTab', async () => { const user = userEvent.setup(); - const { onCollapse, onClose, onNew } = renderStrip(); + const { onCollapse, onClose, onNewChat, onNewTerminalTab } = renderStrip(); await user.click(screen.getByRole('button', { name: 'Collapse terminal' })); expect(onCollapse).toHaveBeenCalledTimes(1); expect(onClose).not.toHaveBeenCalled(); - expect(onNew).not.toHaveBeenCalled(); + expect(onNewChat).not.toHaveBeenCalled(); + expect(onNewTerminalTab).not.toHaveBeenCalled(); }); test('no drag-to-dock grip is rendered (dragging was removed)', () => { @@ -160,7 +195,8 @@ describe('TerminalTabStrip', () => { test('every icon-only control exposes an accessible name', () => { renderStrip(); - expect(screen.getByRole('button', { name: 'New terminal' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'New chat' })).toBeDefined(); + expect(screen.getByRole('button', { name: 'New terminal tab' })).toBeDefined(); expect(screen.getByRole('button', { name: 'Dock terminal to the right' })).toBeDefined(); expect(screen.getByRole('button', { name: 'Collapse terminal' })).toBeDefined(); for (const label of ['Terminal 1', 'Terminal 2', 'Terminal 3']) { diff --git a/packages/app/src/components/TerminalTabStrip.tsx b/packages/app/src/components/TerminalTabStrip.tsx index 843369902..997224bc9 100644 --- a/packages/app/src/components/TerminalTabStrip.tsx +++ b/packages/app/src/components/TerminalTabStrip.tsx @@ -5,6 +5,7 @@ import { PanelBottomIcon, PanelRightIcon, PlusIcon, + SquareTerminalIcon, XIcon, } from 'lucide-react'; import type { ReactNode } from 'react'; @@ -24,7 +25,12 @@ interface TerminalTabStripProps { readonly activeSessionId: string; readonly onSelect: (id: string) => void; readonly onTabActivate?: (id: string) => void; - readonly onNew: () => void; + /** Fires when the user clicks "New chat" (hugs the last tab) — launch the + * default CLI promptless in a fresh session. */ + readonly onNewChat: () => void; + /** Fires when the user clicks "New terminal tab" (trailing group) — open a + * bare shell (the previous `+` behavior). */ + readonly onNewTerminalTab: () => void; readonly onClose: (id: string) => void; /** Where the terminal is currently docked — drives the dock-toggle + collapse * button icons/labels. */ @@ -40,7 +46,8 @@ export function TerminalTabStrip({ activeSessionId, onSelect, onTabActivate, - onNew, + onNewChat, + onNewTerminalTab, onClose, dockPosition, onToggleDock, @@ -60,7 +67,7 @@ export function TerminalTabStrip({ {sessions.map((session) => { const isActive = session.id === activeSessionId; @@ -100,21 +107,43 @@ export function TerminalTabStrip({ ); })} + {/* New chat hugs the last tab (outside the tablist's scroll+fade so it is + never clipped): launch the default CLI promptless. */} - New terminal + New chat + + + {/* Spacer pushes the trailing controls to the far right. */} +
+ {/* New terminal tab opens a bare shell (the previous `+` behavior). */} + + + + + + New terminal tab diff --git a/packages/app/src/components/doc-panel-events.test.ts b/packages/app/src/components/doc-panel-events.test.ts index c104c9858..eca6022a3 100644 --- a/packages/app/src/components/doc-panel-events.test.ts +++ b/packages/app/src/components/doc-panel-events.test.ts @@ -1,7 +1,9 @@ import { describe, expect, mock, test } from 'bun:test'; import { consumePendingDocPanelTabRequest, + requestDocPanelCollapse, requestDocPanelTab, + subscribeToDocPanelCollapseRequests, subscribeToDocPanelTabRequests, } from './doc-panel-events'; @@ -23,4 +25,17 @@ describe('doc-panel-events', () => { expect(onRequest).toHaveBeenCalledTimes(1); expect(consumePendingDocPanelTabRequest()).toBe('outline'); }); + + test('dispatches and subscribes collapse requests, and unsubscribes cleanly', () => { + const target = new EventTarget(); + const onCollapse = mock(() => {}); + + const unsubscribe = subscribeToDocPanelCollapseRequests(onCollapse, target); + requestDocPanelCollapse(target); + expect(onCollapse).toHaveBeenCalledTimes(1); + + unsubscribe(); + requestDocPanelCollapse(target); + expect(onCollapse).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/app/src/components/doc-panel-events.ts b/packages/app/src/components/doc-panel-events.ts index aada2470d..056204f17 100644 --- a/packages/app/src/components/doc-panel-events.ts +++ b/packages/app/src/components/doc-panel-events.ts @@ -46,3 +46,25 @@ export function subscribeToDocPanelTabRequests( target.addEventListener(DOC_PANEL_TAB_EVENT, listener as EventListener); return () => target.removeEventListener(DOC_PANEL_TAB_EVENT, listener as EventListener); } + +const DOC_PANEL_COLLAPSE_EVENT = 'open-knowledge:doc-panel-collapse'; + +export function requestDocPanelCollapse( + target: Pick | EventTarget = typeof window === 'undefined' + ? new EventTarget() + : window, +): void { + target.dispatchEvent(new CustomEvent(DOC_PANEL_COLLAPSE_EVENT)); +} + +export function subscribeToDocPanelCollapseRequests( + onRequest: () => void, + target: Pick | EventTarget = typeof window === + 'undefined' + ? new EventTarget() + : window, +): () => void { + const listener = () => onRequest(); + target.addEventListener(DOC_PANEL_COLLAPSE_EVENT, listener as EventListener); + return () => target.removeEventListener(DOC_PANEL_COLLAPSE_EVENT, listener as EventListener); +} diff --git a/packages/app/src/components/empty-state/CreatePromptComposer.dom.test.tsx b/packages/app/src/components/empty-state/CreatePromptComposer.dom.test.tsx index 57e40b0a9..79bf5c2ba 100644 --- a/packages/app/src/components/empty-state/CreatePromptComposer.dom.test.tsx +++ b/packages/app/src/components/empty-state/CreatePromptComposer.dom.test.tsx @@ -156,6 +156,10 @@ describe('CreatePromptComposer Desktop / Terminal sections', () => { expect(screen.getByText('Desktop')).toBeTruthy(); expect(screen.getByText('Terminal')).toBeTruthy(); + expect( + screen.getByText('Terminal').compareDocumentPosition(screen.getByText('Desktop')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect(screen.getByTestId('create-with-cli-claude')).toBeTruthy(); expect(screen.queryByTestId('menu-separator')).not.toBeNull(); }); diff --git a/packages/app/src/components/empty-state/CreatePromptComposer.tsx b/packages/app/src/components/empty-state/CreatePromptComposer.tsx index bb84707e1..63d476442 100644 --- a/packages/app/src/components/empty-state/CreatePromptComposer.tsx +++ b/packages/app/src/components/empty-state/CreatePromptComposer.tsx @@ -286,62 +286,59 @@ export function CreatePromptComposer({ scenario, className }: CreatePromptCompos - {showDesktopSection ? ( - + {showTerminalSection ? ( + - Desktop + Terminal - {selectableTargets.map((target) => ( - chooseAgent(target.id)} - data-testid={`create-agent-option-${target.id}`} - > - - ))} + {/* Selects a docked-terminal CLI as the create target (the + Create button performs the launch). Visible text is the + brand name while the accessible name is " CLI" so AT + users can tell it apart from the matching Desktop row (WCAG + 2.5.3 — the name contains the visible label). */} + {VISIBLE_CLIS.map((cli) => { + const { displayName } = TERMINAL_CLIS[cli]; + return ( + chooseCli(cli)} + data-testid={`create-with-cli-${cli}`} + aria-label={t`${displayName} CLI`} + > + + ); + })} ) : null} - {showTerminalSection ? ( + {showDesktopSection ? ( <> - {showDesktopSection ? : null} - + {showTerminalSection ? : null} + - Terminal + Desktop - {/* Selects a docked-terminal CLI as the create target (the - Create button performs the launch). Visible text is the - brand name while the accessible name is " CLI" so AT - users can tell it apart from the matching Desktop row (WCAG - 2.5.3 — the name contains the visible label). */} - {VISIBLE_CLIS.map((cli) => { - const { displayName } = TERMINAL_CLIS[cli]; - return ( - chooseCli(cli)} - data-testid={`create-with-cli-${cli}`} - aria-label={t`${displayName} CLI`} - > - - ); - })} + {selectableTargets.map((target) => ( + chooseAgent(target.id)} + data-testid={`create-agent-option-${target.id}`} + > + + ))} ) : null} diff --git a/packages/app/src/components/handoff/AgentSplitButton.tsx b/packages/app/src/components/handoff/AgentSplitButton.tsx index 5ede8e020..6255309ae 100644 --- a/packages/app/src/components/handoff/AgentSplitButton.tsx +++ b/packages/app/src/components/handoff/AgentSplitButton.tsx @@ -96,81 +96,81 @@ export function AgentSplitButton({ {hasOptions ? ( <> - {showDesktop ? ( - + {showTerminal ? ( + - Desktop + Terminal - {installedTargets.map((target) => ( + {/* The visible text is the bare CLI name while the accessible + name carries " CLI" so AT users can tell it apart + from a same-named Desktop row (WCAG 2.5.3 — the accessible + name contains the visible label). */} + {cliRows ? ( + cliRows.map((row) => ( + + {/* Per-CLI brand icon (same source of truth the "Open + with AI" surfaces use via `cliIconTargetId`), so each + row is identifiable at a glance — OpenCode is + terminal-only and would otherwise show no brand mark. + The "Terminal" section header + the "(CLI)" label + already convey that these launch a terminal. */} + + )) + ) : terminal ? ( onSelectTarget(target)} - data-testid={testIds.option(target.id)} + onSelect={terminal.onSelect} + data-testid={terminalTestId('claude')} + aria-label={t`Claude CLI`} > - - ))} + ) : null} ) : null} - {showTerminal ? ( + {showDesktop ? ( <> - {showDesktop ? : null} - + {showTerminal ? : null} + - Terminal + Desktop - {/* The visible text is the bare CLI name while the accessible - name carries " CLI" so AT users can tell it apart - from a same-named Desktop row (WCAG 2.5.3 — the accessible - name contains the visible label). */} - {cliRows ? ( - cliRows.map((row) => ( - - {/* Per-CLI brand icon (same source of truth the "Open - with AI" surfaces use via `cliIconTargetId`), so each - row is identifiable at a glance — OpenCode is - terminal-only and would otherwise show no brand mark. - The "Terminal" section header + the "(CLI)" label - already convey that these launch a terminal. */} - - )) - ) : terminal ? ( + {installedTargets.map((target) => ( onSelectTarget(target)} + data-testid={testIds.option(target.id)} > - - ) : null} + ))} ) : null} diff --git a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx index 2aee8bd3b..9e213ab9c 100644 --- a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx +++ b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.dom.test.tsx @@ -184,6 +184,10 @@ describe('OpenInAgentContextSubmenu runtime behavior', () => { expect(screen.getByText('Desktop')).toBeTruthy(); expect(screen.getByText('Terminal')).toBeTruthy(); + expect( + screen.getByText('Terminal').compareDocumentPosition(screen.getByText('Desktop')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect(document.querySelector('[data-slot="dropdown-menu-separator"]')).toBeTruthy(); const terminalRow = screen.getByTestId('file-tree-open-in-terminal-claude'); diff --git a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx index a1edc7203..a7ac78a87 100644 --- a/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx +++ b/packages/app/src/components/handoff/OpenInAgentContextSubmenu.tsx @@ -75,30 +75,32 @@ export function OpenInAgentContextSubmenu(props: OpenInAgentContextSubmenuProps) Open with AI - {showDesktopSection ? ( - + {showTerminalSection ? ( + - Desktop + Terminal - {installedTargets.map((target) => { - const enabled = !inputMissing; - const { displayName } = target; - const accessibleLabel = hint - ? t`Open with AI ${displayName}, ${hint}` - : t`Open with AI ${displayName}`; + {/* Launches `claude` / `codex` / `cursor-agent` in the docked + terminal with the right-clicked node's scope prompt. Visible + text is the brand name; the accessible name is " CLI" + (plus the "No workspace" hint when input is missing), so it + contains the visible label and AT users can tell it apart from a + Desktop row (WCAG 2.5.3 — name contains visible label). */} + {VISIBLE_CLIS.map((cli) => { + const { displayName } = TERMINAL_CLIS[cli]; return ( { - if (!input) return; - void dispatch(target.id, input); + if (input === null) return; + terminalLaunch.launchInTerminal(input, cli); }} - data-testid={`file-tree-open-in-${target.id}`} - aria-label={accessibleLabel} + disabled={inputMissing} + data-testid={`file-tree-open-in-terminal-${cli}`} + aria-label={hint ? t`${displayName} CLI, ${hint}` : t`${displayName} CLI`} > - ) : null} - {showEmptyHint ? ( - - {probePending ? ( - Checking for installed agents - ) : ( - No installed agents found - )} - - ) : null} - {showTerminalSection ? ( + {showDesktopSection ? ( <> - {/* Separator only when a Desktop section sits above this one. */} - {showDesktopSection ? : null} - + {/* Separator only when a Terminal section sits above this one. */} + {showTerminalSection ? : null} + - Terminal + Desktop - {/* Launches `claude` / `codex` / `cursor-agent` in the docked - terminal with the right-clicked node's scope prompt. Visible - text is the brand name; the accessible name is " CLI" - (plus the "No workspace" hint when input is missing), so it - contains the visible label and AT users can tell it apart from a - Desktop row (WCAG 2.5.3 — name contains visible label). */} - {VISIBLE_CLIS.map((cli) => { - const { displayName } = TERMINAL_CLIS[cli]; + {installedTargets.map((target) => { + const enabled = !inputMissing; + const { displayName } = target; + const accessibleLabel = hint + ? t`Open with AI ${displayName}, ${hint}` + : t`Open with AI ${displayName}`; return ( { - if (input === null) return; - terminalLaunch.launchInTerminal(input, cli); + if (!input) return; + void dispatch(target.id, input); }} - disabled={inputMissing} - data-testid={`file-tree-open-in-terminal-${cli}`} - aria-label={hint ? t`${displayName} CLI, ${hint}` : t`${displayName} CLI`} + data-testid={`file-tree-open-in-${target.id}`} + aria-label={accessibleLabel} > - ) : null} + {showEmptyHint ? ( + + {probePending ? ( + Checking for installed agents + ) : ( + No installed agents found + )} + + ) : null} ); diff --git a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx index 9a295ecbf..09008c3f6 100644 --- a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx +++ b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.dom.test.tsx @@ -168,6 +168,10 @@ describe('OpenInAgentEmptySpaceSubmenu runtime behavior', () => { expect(screen.getByText('Desktop')).toBeTruthy(); expect(screen.getByText('Terminal')).toBeTruthy(); + expect( + screen.getByText('Terminal').compareDocumentPosition(screen.getByText('Desktop')) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect(document.querySelector('[data-slot="context-menu-separator"]')).toBeTruthy(); const terminalRow = screen.getByTestId('empty-space-open-in-terminal-claude'); diff --git a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx index 2fbb5c7b6..95a0c0e10 100644 --- a/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx +++ b/packages/app/src/components/handoff/OpenInAgentEmptySpaceSubmenu.tsx @@ -74,30 +74,32 @@ export function OpenInAgentEmptySpaceSubmenu(props: OpenInAgentEmptySpaceSubmenu Open with AI - {showDesktopSection ? ( - + {showTerminalSection ? ( + - Desktop + Terminal - {installedTargets.map((target) => { - const enabled = !inputMissing; - const { displayName } = target; - const accessibleLabel = hint - ? t`Open with AI ${displayName}, ${hint}` - : t`Open with AI ${displayName}`; + {/* Launches `claude` / `codex` / `cursor-agent` in the docked + terminal with the project-scope prompt. Visible text is the + brand name; the accessible name is " CLI" (plus the "No + workspace" hint when input is missing), so it contains the + visible label and AT users can tell it apart from a Desktop row + (WCAG 2.5.3 — name contains visible label). */} + {VISIBLE_CLIS.map((cli) => { + const { displayName } = TERMINAL_CLIS[cli]; return ( { - if (!input) return; - void dispatch(target.id, input); + if (input === null) return; + terminalLaunch.launchInTerminal(input, cli); }} - data-testid={`empty-space-open-in-${target.id}`} - aria-label={accessibleLabel} + disabled={inputMissing} + data-testid={`empty-space-open-in-terminal-${cli}`} + aria-label={hint ? t`${displayName} CLI, ${hint}` : t`${displayName} CLI`} > - ) : null} - {showTerminalSection ? ( + {showDesktopSection ? ( <> - {/* Separator only when a Desktop section sits above this one. */} - {showDesktopSection ? : null} - + {/* Separator only when a Terminal section sits above this one. */} + {showTerminalSection ? : null} + - Terminal + Desktop - {/* Launches `claude` / `codex` / `cursor-agent` in the docked - terminal with the project-scope prompt. Visible text is the - brand name; the accessible name is " CLI" (plus the "No - workspace" hint when input is missing), so it contains the - visible label and AT users can tell it apart from a Desktop row - (WCAG 2.5.3 — name contains visible label). */} - {VISIBLE_CLIS.map((cli) => { - const { displayName } = TERMINAL_CLIS[cli]; + {installedTargets.map((target) => { + const enabled = !inputMissing; + const { displayName } = target; + const accessibleLabel = hint + ? t`Open with AI ${displayName}, ${hint}` + : t`Open with AI ${displayName}`; return ( { - if (input === null) return; - terminalLaunch.launchInTerminal(input, cli); + if (!input) return; + void dispatch(target.id, input); }} - disabled={inputMissing} - data-testid={`empty-space-open-in-terminal-${cli}`} - aria-label={hint ? t`${displayName} CLI, ${hint}` : t`${displayName} CLI`} + data-testid={`empty-space-open-in-${target.id}`} + aria-label={accessibleLabel} > -
{hasRows ? (
- {showDesktopSection ? ( + {showTerminalSection ? (
- Desktop + Terminal - {installedTargets.map((target) => { - const { displayName } = target; + {VISIBLE_CLIS.map((cli) => { + const { displayName } = TERMINAL_CLIS[cli]; return ( ); })}
) : null} - {showTerminalSection ? ( + {showDesktopSection ? ( <> - {showDesktopSection ? : null} + {showTerminalSection ? : null}
- Terminal + Desktop - {VISIBLE_CLIS.map((cli) => { - const { displayName } = TERMINAL_CLIS[cli]; + {installedTargets.map((target) => { + const { displayName } = target; return ( ); diff --git a/packages/app/src/hooks/use-installed-clis.dom.test.tsx b/packages/app/src/hooks/use-installed-clis.dom.test.tsx new file mode 100644 index 000000000..97518a475 --- /dev/null +++ b/packages/app/src/hooks/use-installed-clis.dom.test.tsx @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { useInstalledClis } from './use-installed-clis'; + +function Probe() { + const map = useInstalledClis(); + return
; +} + +function readMap(): Record { + return JSON.parse(screen.getByTestId('map').getAttribute('data-map') ?? '{}'); +} + +function setBridge(bridge: unknown) { + (window as { okDesktop?: unknown }).okDesktop = bridge; +} + +describe('useInstalledClis', () => { + afterEach(() => { + cleanup(); + delete (window as { okDesktop?: unknown }).okDesktop; + }); + + test('capability guard: no terminal surface → empty map, never throws', async () => { + setBridge({}); // desktop bridge present but no `terminal` (session-only host) + render(); + await act(async () => {}); + expect(readMap()).toEqual({}); + }); + + test('capability guard: no desktop bridge at all (web host) → empty map', async () => { + delete (window as { okDesktop?: unknown }).okDesktop; + render(); + await act(async () => {}); + expect(readMap()).toEqual({}); + }); + + test('success: the resolved installed map surfaces', async () => { + setBridge({ + terminal: { + cliInstalledMap: async () => ({ + claude: true, + codex: false, + opencode: false, + cursor: true, + }), + }, + }); + render(); + await act(async () => {}); + expect(readMap()).toEqual({ claude: true, codex: false, opencode: false, cursor: true }); + }); + + test('degradation: a rejected probe leaves the map empty and warns', async () => { + const warnSpy = spyOn(console, 'warn').mockImplementation(() => {}); + setBridge({ + terminal: { + cliInstalledMap: async () => { + throw new Error('probe failed'); + }, + }, + }); + render(); + await act(async () => {}); + expect(readMap()).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/app/src/hooks/use-installed-clis.ts b/packages/app/src/hooks/use-installed-clis.ts new file mode 100644 index 000000000..dfe4b4fff --- /dev/null +++ b/packages/app/src/hooks/use-installed-clis.ts @@ -0,0 +1,23 @@ +import type { TerminalCli } from '@inkeep/open-knowledge-core'; +import { useEffect, useState } from 'react'; + +export function useInstalledClis(): Partial> { + const [installedClis, setInstalledClis] = useState>>({}); + useEffect(() => { + const terminal = window.okDesktop?.terminal; + if (typeof terminal?.cliInstalledMap !== 'function') return; + let cancelled = false; + void terminal + .cliInstalledMap() + .then((map) => { + if (!cancelled) setInstalledClis(map); + }) + .catch((err) => { + console.warn('[terminal] cliInstalledMap probe failed; defaulting to none installed:', err); + }); + return () => { + cancelled = true; + }; + }, []); + return installedClis; +} diff --git a/packages/app/src/lib/default-cli-resolver.test.ts b/packages/app/src/lib/default-cli-resolver.test.ts new file mode 100644 index 000000000..eee924521 --- /dev/null +++ b/packages/app/src/lib/default-cli-resolver.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test'; +import { TERMINAL_CLI_IDS, type TerminalCli } from '@inkeep/open-knowledge-core'; +import { resolveDefaultCli } from './default-cli-resolver'; +import { TERMINAL_CLI_ID, terminalCliId } from './unified-agent-store'; + +function installedMap(clis: readonly TerminalCli[]): Record { + return Object.fromEntries(TERMINAL_CLI_IDS.map((cli) => [cli, clis.includes(cli)])) as Record< + TerminalCli, + boolean + >; +} + +describe('resolveDefaultCli', () => { + describe('sticky pick', () => { + test('a sticky CLI that is installed wins, even over a higher-priority installed CLI', () => { + expect(resolveDefaultCli(terminalCliId('codex'), installedMap(['claude', 'codex']))).toBe( + 'codex', + ); + }); + + test('a sticky CLI that is NOT installed falls through to first-installed by priority', () => { + expect(resolveDefaultCli(terminalCliId('cursor'), installedMap(['opencode', 'codex']))).toBe( + 'codex', + ); + }); + + test('the legacy bare `terminal-cli` sentinel resolves to claude when installed', () => { + expect(resolveDefaultCli(TERMINAL_CLI_ID, installedMap(['claude', 'codex']))).toBe('claude'); + }); + + test('the legacy bare sentinel falls through when claude is not installed', () => { + expect(resolveDefaultCli(TERMINAL_CLI_ID, installedMap(['opencode']))).toBe('opencode'); + }); + + test('an app-target sticky (not a CLI sentinel) is ignored — New chat only launches CLIs', () => { + expect(resolveDefaultCli('claude-code', installedMap(['codex']))).toBe('codex'); + }); + }); + + describe('priority auto-pick (no usable sticky)', () => { + test('null sticky picks the highest-priority installed CLI', () => { + expect(resolveDefaultCli(null, installedMap(['codex', 'opencode', 'cursor']))).toBe('codex'); + }); + + test('respects the full priority order claude > codex > opencode > cursor', () => { + expect(resolveDefaultCli(null, installedMap(['opencode', 'cursor']))).toBe('opencode'); + expect(resolveDefaultCli(null, installedMap(['cursor']))).toBe('cursor'); + expect(resolveDefaultCli(null, installedMap(['claude', 'codex', 'opencode', 'cursor']))).toBe( + 'claude', + ); + }); + }); + + describe('nothing installed', () => { + test('empty install map + no sticky → claude (the install-nudge default)', () => { + expect(resolveDefaultCli(null, {})).toBe('claude'); + }); + + test('all-false install map → claude', () => { + expect( + resolveDefaultCli(null, { claude: false, codex: false, opencode: false, cursor: false }), + ).toBe('claude'); + }); + + test('a KNOWN-absent sticky CLI with nothing installed → claude', () => { + expect(resolveDefaultCli(terminalCliId('codex'), installedMap([]))).toBe('claude'); + }); + }); + + describe('cold start (probe not yet resolved → unknown, not known-absent)', () => { + test('a sticky CLI is honored against an empty/unknown map (not dropped to claude)', () => { + expect(resolveDefaultCli(terminalCliId('codex'), {})).toBe('codex'); + }); + + test('no sticky + unknown map → claude (priority auto-pick needs a positive install)', () => { + expect(resolveDefaultCli(null, {})).toBe('claude'); + }); + }); +}); diff --git a/packages/app/src/lib/default-cli-resolver.ts b/packages/app/src/lib/default-cli-resolver.ts new file mode 100644 index 000000000..9bda48d2a --- /dev/null +++ b/packages/app/src/lib/default-cli-resolver.ts @@ -0,0 +1,11 @@ +import { TERMINAL_CLI_IDS, type TerminalCli } from '@inkeep/open-knowledge-core'; +import { parseStickyCliId } from './unified-agent-store'; + +export function resolveDefaultCli( + sticky: string | null, + installed: Partial>, +): TerminalCli { + const stickyCli = parseStickyCliId(sticky); + if (stickyCli && installed[stickyCli] !== false) return stickyCli; + return TERMINAL_CLI_IDS.find((cli) => installed[cli] === true) ?? 'claude'; +} diff --git a/packages/app/src/lib/desktop-bridge-types.ts b/packages/app/src/lib/desktop-bridge-types.ts index 4a0a5dd1c..5a161d2a6 100644 --- a/packages/app/src/lib/desktop-bridge-types.ts +++ b/packages/app/src/lib/desktop-bridge-types.ts @@ -750,6 +750,7 @@ export interface OkDesktopBridge { onExit(cb: (msg: OkPtyExit) => void): OkUnsubscribe; claudePreflight(): Promise; cliPreflight(cli: TerminalCli): Promise; + cliInstalledMap(): Promise>; rewireClaudeMcp(): Promise; }; diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index ea818f9fc..911dde425 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -263,6 +263,7 @@ "67f-3g": ["Duplicate succeeded but the sidebar may be out of date — refresh to resync"], "6Aih4U": ["Offline"], "6EKG_c": ["What agents match on to decide when to use the skill."], + "6I7cJR": ["Open chat"], "6LbUG7": ["The repository is private and your GitHub account doesn't have access to it."], "6MnZEC": ["Terminal failed to load"], "6O626z": ["No headings match \"", ["trimmedAnchorQuery"], "\""], @@ -407,7 +408,6 @@ "AAlr5Y": [ "No collab server for this worktree — run <0>ok start here, or reopen the project." ], - "AB4qw9": ["New terminal"], "AE7BY3": ["The terminal session ended."], "AIn2Yv": [["displayName"], " CLI, ", ["hint"]], "ALhMYW": ["Terminal"], @@ -1524,6 +1524,7 @@ "hDoLrm": ["Remove ", ["projectName"], " from recent projects"], "hGmuTc": ["Docs tagged #", ["tagDocsName"]], "hHMv6l": ["Delete block"], + "hIQkLb": ["New chat"], "hSBIG-": ["A title, description, tags — anything that describes this folder."], "hSySVc": ["Install handoff failed: ", ["msg"]], "hXUWyd": ["Value is required"], @@ -1610,6 +1611,7 @@ ["trimmedSlug"], " already exists here. Saving creates a local copy that overrides it for this folder." ], + "k0vc0N": ["New terminal tab"], "kCNU4q": [ "Indexing your pages — ", ["semanticIndexedCount"], @@ -1817,6 +1819,7 @@ "pmKdif": ["Failed: ", ["loadError"]], "pmUArF": ["Workspace"], "ppDgFi": ["Restart with this app's version"], + "pqZJT2": ["Close chat"], "pzutoc": ["Italic"], "q2kXMG": ["Inline Math"], "q4YJl_": ["The terminal session ended (exit code ", ["0"], ")."], diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index b16d428aa..a8797874e 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -1172,6 +1172,10 @@ msgstr "Close all" msgid "Close all unpinned" msgstr "Close all unpinned" +#: src/components/EditorHeader.tsx +msgid "Close chat" +msgstr "Close chat" + #: src/editor/find-replace/FindReplaceBar.tsx msgid "Close find" msgstr "Close find" @@ -3878,6 +3882,10 @@ msgstr "never" msgid "New" msgstr "New" +#: src/components/TerminalTabStrip.tsx +msgid "New chat" +msgstr "New chat" + #: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx @@ -3937,8 +3945,8 @@ msgid "New template" msgstr "New template" #: src/components/TerminalTabStrip.tsx -msgid "New terminal" -msgstr "New terminal" +msgid "New terminal tab" +msgstr "New terminal tab" #: src/editor/find-replace/FindReplaceBar.tsx msgid "Next match" @@ -4327,6 +4335,10 @@ msgstr "Open and focus the Ask AI composer for the current editor selection." msgid "Open and focus the bottom Ask AI prompt composer." msgstr "Open and focus the bottom Ask AI prompt composer." +#: src/components/EditorHeader.tsx +msgid "Open chat" +msgstr "Open chat" + #: src/lib/keyboard-shortcuts.ts msgid "Open CodeMirror search in source editor mode." msgstr "Open CodeMirror search in source editor mode." diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index cd1ce2931..21936fc62 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -263,6 +263,7 @@ "67f-3g": ["Ďũƥĺĩćàţē śũććēēďēď ƀũţ ţĥē śĩďēƀàŕ ḿàŷ ƀē ōũţ ōƒ ďàţē — ŕēƒŕēśĥ ţō ŕēśŷńć"], "6Aih4U": ["Ōƒƒĺĩńē"], "6EKG_c": ["Ŵĥàţ àĝēńţś ḿàţćĥ ōń ţō ďēćĩďē ŵĥēń ţō ũśē ţĥē śķĩĺĺ."], + "6I7cJR": ["Ōƥēń ćĥàţ"], "6LbUG7": ["Ţĥē ŕēƥōśĩţōŕŷ ĩś ƥŕĩvàţē àńď ŷōũŕ ĜĩţĤũƀ àććōũńţ ďōēśń'ţ ĥàvē àććēśś ţō ĩţ."], "6MnZEC": ["Ţēŕḿĩńàĺ ƒàĩĺēď ţō ĺōàď"], "6O626z": ["Ńō ĥēàďĩńĝś ḿàţćĥ \"", ["trimmedAnchorQuery"], "\""], @@ -407,7 +408,6 @@ "AAlr5Y": [ "Ńō ćōĺĺàƀ śēŕvēŕ ƒōŕ ţĥĩś ŵōŕķţŕēē — ŕũń <0>ōķ śţàŕţ ĥēŕē, ōŕ ŕēōƥēń ţĥē ƥŕōĴēćţ." ], - "AB4qw9": ["Ńēŵ ţēŕḿĩńàĺ"], "AE7BY3": ["Ţĥē ţēŕḿĩńàĺ śēśśĩōń ēńďēď."], "AIn2Yv": [["displayName"], " ĆĹĨ, ", ["hint"]], "ALhMYW": ["Ţēŕḿĩńàĺ"], @@ -1524,6 +1524,7 @@ "hDoLrm": ["Ŕēḿōvē ", ["projectName"], " ƒŕōḿ ŕēćēńţ ƥŕōĴēćţś"], "hGmuTc": ["Ďōćś ţàĝĝēď #", ["tagDocsName"]], "hHMv6l": ["Ďēĺēţē ƀĺōćķ"], + "hIQkLb": ["Ńēŵ ćĥàţ"], "hSBIG-": ["À ţĩţĺē, ďēśćŕĩƥţĩōń, ţàĝś — àńŷţĥĩńĝ ţĥàţ ďēśćŕĩƀēś ţĥĩś ƒōĺďēŕ."], "hSySVc": ["Ĩńśţàĺĺ ĥàńďōƒƒ ƒàĩĺēď: ", ["msg"]], "hXUWyd": ["Vàĺũē ĩś ŕēǫũĩŕēď"], @@ -1610,6 +1611,7 @@ ["trimmedSlug"], " àĺŕēàďŷ ēxĩśţś ĥēŕē. Śàvĩńĝ ćŕēàţēś à ĺōćàĺ ćōƥŷ ţĥàţ ōvēŕŕĩďēś ĩţ ƒōŕ ţĥĩś ƒōĺďēŕ." ], + "k0vc0N": ["Ńēŵ ţēŕḿĩńàĺ ţàƀ"], "kCNU4q": [ "Ĩńďēxĩńĝ ŷōũŕ ƥàĝēś — ", ["semanticIndexedCount"], @@ -1817,6 +1819,7 @@ "pmKdif": ["Ƒàĩĺēď: ", ["loadError"]], "pmUArF": ["Ŵōŕķśƥàćē"], "ppDgFi": ["Ŕēśţàŕţ ŵĩţĥ ţĥĩś àƥƥ'ś vēŕśĩōń"], + "pqZJT2": ["Ćĺōśē ćĥàţ"], "pzutoc": ["Ĩţàĺĩć"], "q2kXMG": ["Ĩńĺĩńē Ḿàţĥ"], "q4YJl_": ["Ţĥē ţēŕḿĩńàĺ śēśśĩōń ēńďēď (ēxĩţ ćōďē ", ["0"], ")."], diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index b45765b0c..a75ba0523 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -1167,6 +1167,10 @@ msgstr "" msgid "Close all unpinned" msgstr "" +#: src/components/EditorHeader.tsx +msgid "Close chat" +msgstr "" + #: src/editor/find-replace/FindReplaceBar.tsx msgid "Close find" msgstr "" @@ -3873,6 +3877,10 @@ msgstr "" msgid "New" msgstr "" +#: src/components/TerminalTabStrip.tsx +msgid "New chat" +msgstr "" + #: src/components/CommandPalette.tsx #: src/components/FileSidebar.tsx #: src/components/FileTree.tsx @@ -3932,7 +3940,7 @@ msgid "New template" msgstr "" #: src/components/TerminalTabStrip.tsx -msgid "New terminal" +msgid "New terminal tab" msgstr "" #: src/editor/find-replace/FindReplaceBar.tsx @@ -4322,6 +4330,10 @@ msgstr "" msgid "Open and focus the bottom Ask AI prompt composer." msgstr "" +#: src/components/EditorHeader.tsx +msgid "Open chat" +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Open CodeMirror search in source editor mode." msgstr "" diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts index cc011f288..6855caa6e 100644 --- a/packages/app/tests/stress/fixtures/handoff-mocks.ts +++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts @@ -365,6 +365,12 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P onExit: () => () => {}, claudePreflight: async () => ({ claude: 'present' as const, mcp: 'wired' as const }), cliPreflight: async () => ({ onPath: 'present' as const }), + cliInstalledMap: async () => ({ + claude: true, + codex: true, + opencode: true, + cursor: true, + }), rewireClaudeMcp: async () => ({ claude: 'present' as const, mcp: 'wired' as const }), }, platform: 'darwin' as const, diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts index f4651bcfb..a430d1dc3 100644 --- a/packages/core/src/desktop-bridge.ts +++ b/packages/core/src/desktop-bridge.ts @@ -741,6 +741,7 @@ export interface OkDesktopBridge { onExit(cb: (msg: OkPtyExit) => void): OkUnsubscribe; claudePreflight(): Promise; cliPreflight(cli: TerminalCli): Promise; + cliInstalledMap(): Promise>; rewireClaudeMcp(): Promise; }; diff --git a/packages/core/src/handoff/terminal-launch.test.ts b/packages/core/src/handoff/terminal-launch.test.ts index 4863f323c..03b9a6911 100644 --- a/packages/core/src/handoff/terminal-launch.test.ts +++ b/packages/core/src/handoff/terminal-launch.test.ts @@ -11,6 +11,12 @@ import { const CLAUDE_PREAPPROVE = `--settings '{"enabledMcpjsonServers":["${MCP_SERVER_NAME}"]}'`; +describe('TERMINAL_CLI_IDS', () => { + it('lists the CLIs in auto-pick priority order (claude > codex > opencode > cursor)', () => { + expect([...TERMINAL_CLI_IDS]).toEqual(['claude', 'codex', 'opencode', 'cursor']); + }); +}); + describe('shellSingleQuote', () => { it('wraps a plain string in single quotes', () => { expect(shellSingleQuote('hello world')).toBe("'hello world'"); @@ -121,6 +127,35 @@ describe('buildCliLaunchArgString', () => { }); }); +describe('buildCliLaunchArgString promptless (New chat)', () => { + it('emits a bare `` for a null/undefined/empty prompt — no positional, no prompt flag', () => { + for (const emptyPrompt of [null, undefined, ''] as const) { + expect(buildCliLaunchArgString('claude', emptyPrompt)).toBe('claude'); + expect(buildCliLaunchArgString('codex', emptyPrompt)).toBe('codex'); + expect(buildCliLaunchArgString('cursor', emptyPrompt)).toBe('cursor-agent'); + expect(buildCliLaunchArgString('opencode', emptyPrompt)).toBe('opencode'); + } + }); + + it('still applies Claude MCP pre-approval on a promptless launch when opted in', () => { + const arg = buildCliLaunchArgString('claude', null, { mcpPreApprove: true }); + expect(arg).toBe(`claude ${CLAUDE_PREAPPROVE}`); + expect(arg.endsWith(' ')).toBe(false); + }); + + it('never adds --prompt or a positional to a promptless opencode launch, even opted in', () => { + expect(buildCliLaunchArgString('opencode', '', { mcpPreApprove: true })).toBe('opencode'); + }); + + it('leaves the non-empty prompted shape byte-identical (promptless branch must not perturb it)', () => { + expect(buildCliLaunchArgString('claude', 'hi')).toBe("claude 'hi'"); + expect(buildCliLaunchArgString('claude', 'hi', { mcpPreApprove: true })).toBe( + `claude ${CLAUDE_PREAPPROVE} 'hi'`, + ); + expect(buildCliLaunchArgString('opencode', 'hi')).toBe("opencode --prompt 'hi'"); + }); +}); + describe('claude MCP pre-approval', () => { it('is OFF by default and only added for claude when opted in', () => { expect(buildCliLaunchCommand('claude', 'hi')).not.toContain('--settings'); diff --git a/packages/core/src/handoff/terminal-launch.ts b/packages/core/src/handoff/terminal-launch.ts index d2b873273..344fa6711 100644 --- a/packages/core/src/handoff/terminal-launch.ts +++ b/packages/core/src/handoff/terminal-launch.ts @@ -69,8 +69,8 @@ export const TERMINAL_CLIS = { export const TERMINAL_CLI_IDS = [ 'claude', 'codex', - 'cursor', 'opencode', + 'cursor', ] as const satisfies readonly TerminalCli[]; export interface BuildCliLaunchOptions { @@ -79,12 +79,15 @@ export interface BuildCliLaunchOptions { export function buildCliLaunchArgString( cli: TerminalCli, - prompt: string, + prompt: string | null | undefined, opts: BuildCliLaunchOptions = {}, ): string { const info: TerminalCliInfo = TERMINAL_CLIS[cli]; const preApprove = opts.mcpPreApprove === true && info.mcpPreApproveArg ? `${info.mcpPreApproveArg} ` : ''; + if (prompt == null || prompt.length === 0) { + return `${info.bin} ${preApprove}`.trimEnd(); + } const promptFlag = info.promptFlag ? `${info.promptFlag} ` : ''; return `${info.bin} ${preApprove}${promptFlag}${shellSingleQuote(prompt)}`; } diff --git a/packages/desktop/src/main/claude-readiness.ts b/packages/desktop/src/main/claude-readiness.ts index d3d714e9e..602b6d74e 100644 --- a/packages/desktop/src/main/claude-readiness.ts +++ b/packages/desktop/src/main/claude-readiness.ts @@ -1,4 +1,5 @@ import type { McpEntryClassification } from '@inkeep/open-knowledge'; +import { TERMINAL_CLI_IDS, type TerminalCli } from '@inkeep/open-knowledge-core'; import type { ClaudeReadiness, CliReadiness } from '../shared/bridge-contract.ts'; import { getLogger } from './desktop-logger.ts'; @@ -139,3 +140,21 @@ export async function resolveCliOnPath(deps: ResolveCliOnPathDeps): Promise; +} + +export async function resolveCliInstalledMap( + deps: ResolveCliInstalledMapDeps, +): Promise> { + const entries = await Promise.all( + TERMINAL_CLI_IDS.map(async (cli) => { + const { onPath } = await resolveCliOnPath({ probe: () => deps.probe(cli) }); + return [cli, onPath === 'present'] as const; + }), + ); + return Object.fromEntries(entries) as Record; +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index a2d6a45b5..af360159b 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -119,6 +119,7 @@ import { checkTargetExists as checkTargetExistsImpl } from './check-target-exist import { cliProbeArgs, resolveClaudeReadiness, + resolveCliInstalledMap, resolveCliOnPath, runLoginShellProbe, } from './claude-readiness.ts'; @@ -1443,6 +1444,25 @@ function resolveTerminalCliOnPath(cli: TerminalCli): Promise { }); } +const CLI_INSTALLED_MAP_TTL_MS = 60_000; +let cliInstalledMapCache: { at: number; value: Promise> } | null = + null; + +function resolveTerminalCliInstalledMap(): Promise> { + const now = Date.now(); + if (cliInstalledMapCache && now - cliInstalledMapCache.at < CLI_INSTALLED_MAP_TTL_MS) { + return cliInstalledMapCache.value; + } + const value = resolveCliInstalledMap({ + probe: (cli) => probeLoginShellOnPath(cliProbeArgs(TERMINAL_CLIS[cli].bin)), + }).catch((err) => { + cliInstalledMapCache = null; + throw err; + }); + cliInstalledMapCache = { at: now, value }; + return value; +} + function pickLoadedRendererForMcpDialog(): McpWiringDispatchTarget | undefined { const isUsable = (win: BrowserWindow): boolean => !win.isDestroyed() && !win.webContents.isLoading(); @@ -1667,6 +1687,10 @@ function registerIpcHandlers() { return resolveTerminalCliOnPath(req.cli); }); + handle('ok:terminal:cli-installed-map', async (): Promise> => { + return resolveTerminalCliInstalledMap(); + }); + handle('ok:terminal:dock-state', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); return { visible: win ? (dockVisibleForWindow.get(win.id) ?? false) : false }; diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 21ab2d418..894211e91 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -526,6 +526,7 @@ const bridge: OkDesktopBridge = { }, claudePreflight: () => invoke('ok:terminal:claude-assist', { action: 'preflight' }), cliPreflight: (cli) => invoke('ok:terminal:cli-preflight', { cli }), + cliInstalledMap: () => invoke('ok:terminal:cli-installed-map'), rewireClaudeMcp: () => invoke('ok:terminal:claude-assist', { action: 'rewire' }), }, diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts index 86d0a1aa0..e7c4a3f72 100644 --- a/packages/desktop/src/shared/bridge-contract.ts +++ b/packages/desktop/src/shared/bridge-contract.ts @@ -746,6 +746,7 @@ export interface OkDesktopBridge { onExit(cb: (msg: OkPtyExit) => void): OkUnsubscribe; claudePreflight(): Promise; cliPreflight(cli: TerminalCli): Promise; + cliInstalledMap(): Promise>; rewireClaudeMcp(): Promise; }; diff --git a/packages/desktop/src/shared/ipc-channels.ts b/packages/desktop/src/shared/ipc-channels.ts index 8502c0371..e8d1b7f53 100644 --- a/packages/desktop/src/shared/ipc-channels.ts +++ b/packages/desktop/src/shared/ipc-channels.ts @@ -511,6 +511,10 @@ export interface RequestChannels { args: [req: { cli: TerminalCli }]; result: CliReadiness; }; + 'ok:terminal:cli-installed-map': { + args: []; + result: Record; + }; 'ok:terminal:dock-state': { args: []; result: { visible: boolean }; diff --git a/packages/desktop/tests/main/claude-readiness.test.ts b/packages/desktop/tests/main/claude-readiness.test.ts index 58077fb05..0347fc474 100644 --- a/packages/desktop/tests/main/claude-readiness.test.ts +++ b/packages/desktop/tests/main/claude-readiness.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { TERMINAL_CLI_IDS, type TerminalCli } from '@inkeep/open-knowledge-core'; import { CLAUDE_PROBE_ARGS, cliProbeArgs, @@ -7,6 +8,7 @@ import { type ProbeChild, type ProbeTimers, resolveClaudeReadiness, + resolveCliInstalledMap, resolveCliOnPath, runLoginShellProbe, } from '../../src/main/claude-readiness.ts'; @@ -272,3 +274,32 @@ describe('resolveCliOnPath', () => { }); }); }); + +describe('resolveCliInstalledMap', () => { + test('maps each CLI probe exit code to installed=true iff the probe exited 0', async () => { + const codes: Record = { + claude: 0, + codex: 127, + opencode: null, + cursor: 0, + }; + expect(await resolveCliInstalledMap({ probe: (cli) => Promise.resolve(codes[cli]) })).toEqual({ + claude: true, + codex: false, + opencode: false, + cursor: true, + }); + }); + + test('a rejected probe for one CLI degrades that entry to not-installed, never crashes', async () => { + const map = await resolveCliInstalledMap({ + probe: (cli) => (cli === 'codex' ? Promise.reject(new Error('boom')) : Promise.resolve(0)), + }); + expect(map).toEqual({ claude: true, codex: false, opencode: true, cursor: true }); + }); + + test('returns exactly one entry per CLI in TERMINAL_CLI_IDS', async () => { + const map = await resolveCliInstalledMap({ probe: () => Promise.resolve(127) }); + expect(Object.keys(map).sort()).toEqual([...TERMINAL_CLI_IDS].sort()); + }); +});