From 5d0562ffa992558db550f2e55d01ca7926bfab81 Mon Sep 17 00:00:00 2001 From: serafin-garcia Date: Wed, 1 Jul 2026 00:15:34 -0700 Subject: [PATCH] feat(open-knowledge): first-class terminal chat controls + default-CLI detection (#2325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(open-knowledge): terminal-behaviors SPEC (audited, decisions locked) * [US-001] Reorder TERMINAL_CLI_IDS and add promptless CLI launch build path Reorder TERMINAL_CLI_IDS to claude,codex,opencode,cursor so the visible launch-row order matches the default-CLI auto-pick priority (one constant drives both). Consumer audit confirmed no site depends on the old positional order (display map, VISIBLE_CLIS, and parseStickyCliId find-by-match are all order-independent; no index access). Widen buildCliLaunchArgString's prompt param to string|null|undefined and add a promptless branch: an absent or empty prompt emits a bare (dropping the positional and any prompt-carrying flag such as OpenCode's --prompt), keeping only Claude's opted-in MCP pre-approval. This is the New chat launch shape. The non-empty prompted path stays byte-identical. * [US-002] Add resolveDefaultCli — shared default-CLI resolution New pure function packages/app/src/lib/default-cli-resolver.ts that both the New chat launch and the Ask X bubble default use, so every entry point agrees on the same CLI. Resolution order: 1. sticky parses to a CLI (parseStickyCliId) and that CLI is installed -> it; 2. else the first installed CLI by TERMINAL_CLI_IDS priority; 3. else claude (surfaces the existing Get Claude install nudge on PATH miss). Priority order is TERMINAL_CLI_IDS itself, so auto-pick order stays in lockstep with the visible launch-row order. An app-target sticky (a HandoffTarget id, not a terminal-cli sentinel) parses to null and falls through — New chat only ever launches a CLI. The installed map is Partial so a not-yet-resolved probe degrades to the claude fallback. Covered by 10 unit tests. * [US-003] Add batched CLI-install probe + renderer bridge exposure New desktop probe answering which of the four CLIs are on PATH, so the New chat default-CLI auto-pick can pick the first installed one. claude-readiness.ts: pure resolveCliInstalledMap(deps) -> Record (installed iff the login-shell command -v probe exits 0). Each entry routes through the existing resolveCliOnPath, so a flaky or rejected probe degrades that one entry to not-installed without crashing the batch; unknown collapses to false since defaulting must resolve to a concrete CLI. Covered by 3 unit tests over the exit-code to installed-map parsing. Renderer exposure co-located with cliPreflight: new cliInstalledMap() bridge method added to all three OkDesktopBridge copies (canonical bridge-contract plus the core and app mirrors, kept in lockstep for the m1-smoke drift catcher), the ok:terminal:cli-installed-map IPC channel, the preload invoke wiring, and a main handler backed by a 60s promise-cache so repeated New chat clicks share one probe batch instead of re-spawning four login shells each time. The stress fixture bridge fake gains the method to satisfy the contract. * [US-004] Wire EditorPane for New chat: terminalAvailable, promptless launch, installed-map Derive a single terminalAvailable = desktopBridge != null && desktopBridge.terminal != null and gate both the terminal host mount and the header terminal buttons on it, so a session-only bridge (no terminal surface) never renders a control that would throw. Widen TerminalLaunchIntent.prompt to string | null — null is a New chat launch. It flows through TerminalSessionsHost, TerminalGate, and TerminalPanel, which already passes it to the now-null-safe buildCliLaunchArgString. Fetch the installed-CLI map once on mount (capability-guarded like the existing getDockState restore, so a partial or older bridge skips the probe rather than throwing a synchronous not-a-function the .catch cannot intercept). launchNewChat resolves the CLI via resolveDefaultCli(sticky, installed) unless one is named, reveals the dock, and threads a promptless one-shot intent through the same nonce-dedup + hide-clear path as Open in terminal. Thread terminalAvailable / terminalVisible / onToggleTerminal / onNewChat to EditorHeader and the installed-CLI map to TerminalSessionsHost (the header buttons render in US-005, the tab-strip New chat in US-006). The EditorPane dom-test stub now models cliInstalledMap. * [US-005] Add New chat + show/hide terminal buttons to the editor header When the terminal is available (desktop bridge + terminal surface), the header now renders, after the presence badges and before the vertical separator, a New chat button and a show/hide-terminal toggle. Both are ghost icon buttons matching the existing Search control: an accessible aria-label, an aria-hidden icon, a tooltip, and the electron no-drag opt-out. New chat (MessageSquarePlus) launches the resolved default CLI promptless via the onNewChat handler. The toggle (Terminal) flips the dock visibility; its aria-label and tooltip name the action they perform, flipping between Show terminal and Hide terminal with dock state (the WAI-ARIA changing-label toggle pattern — no aria-pressed, which would double-announce state alongside a Show/Hide label). All copy routes through Lingui. Neither button renders on web or a bridge without a terminal surface. Covered by four DOM tests: button order, label flip, handler dispatch, and absence when the terminal is unavailable. * [US-006] Split the tab-strip + into New chat and New terminal tab The tab strip's single + button becomes two controls. New chat (keeps the plus icon) hugs the last tab and launches the default CLI promptless; a new New terminal tab button (SquareTerminal) sits in the trailing control group and opens a bare shell, the previous + behavior. Layout: TabsList drops flex-1 (keeps min-w-0 + overflow-x-auto so tabs still scroll internally) so New chat can sit immediately right of the last tab, outside the scroll container's fade mask. A flex-1 spacer then pushes the trailing group (New terminal tab, dock-toggle, collapse) to the far right. TerminalSessionsHost resolves the strip New chat's CLI from the sticky pick plus the installed-CLI map at click time (resolveDefaultCli) and opens a promptless session; New terminal tab keeps openSession(null). The launch nonce comes from a local monotonic ref — it is required by the intent type but only load-bearing for the EditorPane prop-channel dedup, so a direct open just needs a distinct value. Updates TerminalTabStrip's tests for the renamed/added controls and fixes the TerminalDock multi-session tests, which created bare-shell sessions through the old + and now go through New terminal tab. * [US-007] Default the Ask X bubble to the first-installed CLI on desktop On desktop, when the user has made no pick and has no sticky preference, the Ask X bubble now leads with the first-installed CLI (resolveDefaultCli; Claude if none) instead of the first installed app target — matching what New chat would launch. The composer fetches its own installed-CLI map from the terminal bridge (capability-guarded, main-cached) to drive this. The default is derived, never saved, so a freshly-installed CLI can take over mid-session. An explicit CLI pick still launches that CLI regardless of install state (the existing no-probe contract for the picker), a sticky app-target pick is still honored, and the web host keeps the app-target default. Two DOM tests cover the desktop no-pick default: Codex when Claude is absent, and the Claude CLI fallback when nothing is installed. * [US-008] Lead the Open with AI dropdowns with the Terminal section Flip the two sections in all five Open with AI surfaces so Terminal renders before Desktop, leading with the in-app terminal as the first-class path: OpenInAgentMenu, OpenInAgentContextSubmenu, OpenInAgentEmptySpaceSubmenu, AgentSplitButton, and the empty-state CreatePromptComposer. Each swap moves the Terminal block above the Desktop block and relocates the inter-section separator to sit after Terminal (gated on the Terminal section being present) instead of before it. Option lists, labels, test ids, and accessible names are otherwise unchanged; the Command Palette (no Terminal section) is untouched. Adds Terminal-precedes-Desktop DOM-order assertions to the four existing section grouping tests and a new order assertion on the split-button picker in BottomComposer's tests. No new user-facing strings. * [US-009] Regenerate Lingui catalogs for the new terminal strings Runs bun run i18n to extract and compile the en + pseudo catalogs for the strings added by the terminal-behaviors work: New chat, Show terminal, Hide terminal, and New terminal tab. The obsolete New terminal tab-strip tooltip (renamed to New chat / New terminal tab) is dropped by the extractor. Also strips spec-marker citations from a handful of test comments (that linkage belongs in the commit, not the source). * feat(open-knowledge): consolidate header chat control to Open/Close toggle + collapse doc-panel on chat open - Replace the two header buttons (New chat + show/hide terminal) with one stateful toggle: Open chat -> Close chat, reflecting dock visibility. - Opening the chat (header toggle, file-tree right-click, bottom composer) collapses the right doc-panel so the chat gets the full column; deferred a frame so it lands after the terminal column mounts, and it never touches the panel on close. Panel stays user-reopenable. - Report session presence from the host so the toggle bootstraps a default-CLI chat only when none exists. * docs(open-knowledge): record D9/D10 header-chat amendments (supersede D3/D5) * fix(open-knowledge): review fixes — honor sticky CLI pre-probe, transient/dock-guarded doc-panel collapse - resolveDefaultCli: honor a sticky CLI unless KNOWN-absent (false), so a New chat click during the cold-start probe window keeps the remembered pick instead of dropping to claude — matching the Ask-X bubble. Priority auto-pick now requires a positive install (true). - Doc-panel collapse on chat open: stop writing the persistent 'collapsed' pin (it left the panel collapsed across docs + restarts after one chat/ask-AI); now transient. Guard on right-dock (a bottom-docked chat has no right column to clear). Cancel the deferred rAF on cleanup/re-request. * chore(open-knowledge): changeset for terminal chat controls * fix(open-knowledge): warn (not error) for recoverable CLI-install probe failure The cliInstalledMap probe failure degrades to 'none installed' → claude default, so it's recoverable. Log at console.warn with the [terminal] namespace to match the terminal surface's probe-failure convention (TerminalPanel, ClaudeReadinessBanner) rather than error severity + an [ask-ai] namespace that breaks [terminal] triage. * refactor(open-knowledge): extract shared useInstalledClis hook; tidy bridge JSDoc Review feedback: the cliInstalledMap probe effect (state + capability guard + cancel-flag cleanup + error log) was duplicated byte-for-byte in EditorPane and BottomComposer, both feeding the same resolveDefaultCli. Extract a shared useInstalledClis() hook (matching the useGitSyncStatus bridge-hook precedent) so both are a single call and a third consumer reuses it. Strip the lone JSDoc on cliInstalledMap in the core desktop-bridge mirror to match its bare siblings. * test(open-knowledge): pin useInstalledClis contract; evict CLI-map cache on probe rejection Review follow-ups: - Add use-installed-clis.dom.test.tsx covering the hook's three behaviors: the capability guard (web-host / partial-bridge → empty map, no throw), the resolved-map success path, and the .catch degradation (rejected probe → empty map + warn). - resolveTerminalCliInstalledMap now evicts the TTL cache on a rejected probe (defensive — the batch never rejects today) so a transient failure becomes an immediate retry rather than a 60s-cached rejection. --------- GitOrigin-RevId: c4b03c63913e4ec4c42dc36d3dd2d2a01c23ac99 --- .changeset/terminal-chat-controls.md | 7 ++ .../components/BottomComposer.dom.test.tsx | 44 +++++++ .../app/src/components/BottomComposer.tsx | 8 +- packages/app/src/components/EditorArea.tsx | 16 +++ .../src/components/EditorHeader.dom.test.tsx | 41 ++++++- packages/app/src/components/EditorHeader.tsx | 44 ++++++- .../src/components/EditorPane.dom.test.tsx | 6 + packages/app/src/components/EditorPane.tsx | 36 +++++- .../src/components/TerminalDock.dom.test.tsx | 46 +++---- .../src/components/TerminalSessionsHost.tsx | 29 ++++- .../components/TerminalTabStrip.dom.test.tsx | 62 ++++++++-- .../app/src/components/TerminalTabStrip.tsx | 41 ++++++- .../src/components/doc-panel-events.test.ts | 15 +++ .../app/src/components/doc-panel-events.ts | 22 ++++ .../CreatePromptComposer.dom.test.tsx | 4 + .../empty-state/CreatePromptComposer.tsx | 95 +++++++------- .../components/handoff/AgentSplitButton.tsx | 116 +++++++++--------- .../OpenInAgentContextSubmenu.dom.test.tsx | 4 + .../handoff/OpenInAgentContextSubmenu.tsx | 94 +++++++------- .../OpenInAgentEmptySpaceSubmenu.dom.test.tsx | 4 + .../handoff/OpenInAgentEmptySpaceSubmenu.tsx | 76 ++++++------ .../handoff/OpenInAgentMenu.dom.test.tsx | 5 + .../components/handoff/OpenInAgentMenu.tsx | 42 +++---- .../src/hooks/use-installed-clis.dom.test.tsx | 69 +++++++++++ packages/app/src/hooks/use-installed-clis.ts | 23 ++++ .../app/src/lib/default-cli-resolver.test.ts | 79 ++++++++++++ packages/app/src/lib/default-cli-resolver.ts | 11 ++ packages/app/src/lib/desktop-bridge-types.ts | 1 + packages/app/src/locales/en/messages.json | 5 +- packages/app/src/locales/en/messages.po | 16 ++- packages/app/src/locales/pseudo/messages.json | 5 +- packages/app/src/locales/pseudo/messages.po | 14 ++- .../tests/stress/fixtures/handoff-mocks.ts | 6 + packages/core/src/desktop-bridge.ts | 1 + .../core/src/handoff/terminal-launch.test.ts | 35 ++++++ packages/core/src/handoff/terminal-launch.ts | 7 +- packages/desktop/src/main/claude-readiness.ts | 19 +++ packages/desktop/src/main/index.ts | 24 ++++ packages/desktop/src/preload/index.ts | 1 + .../desktop/src/shared/bridge-contract.ts | 1 + packages/desktop/src/shared/ipc-channels.ts | 4 + .../tests/main/claude-readiness.test.ts | 31 +++++ 42 files changed, 938 insertions(+), 271 deletions(-) create mode 100644 .changeset/terminal-chat-controls.md create mode 100644 packages/app/src/hooks/use-installed-clis.dom.test.tsx create mode 100644 packages/app/src/hooks/use-installed-clis.ts create mode 100644 packages/app/src/lib/default-cli-resolver.test.ts create mode 100644 packages/app/src/lib/default-cli-resolver.ts 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()); + }); +});