Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/terminal-chat-controls.md
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions packages/app/src/components/BottomComposer.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ async function renderComposerWithThrowingTerminal(docName = 'notes') {
);
}

async function renderComposerWithInstalledClis(installed: Record<string, boolean>) {
(window as { okDesktop?: unknown }).okDesktop = {
terminal: { cliInstalledMap: async () => installed },
};
return renderComposerWithTerminal();
}

async function renderFolderComposer(folderPath = 'specs/foo') {
const { BottomComposer } = await import('./BottomComposer');
return render(<BottomComposer folderPath={folderPath} />);
Expand Down Expand Up @@ -291,6 +298,7 @@ beforeEach(() => {
afterEach(() => {
cleanup();
consoleErrorSpy.mockRestore();
delete (window as { okDesktop?: unknown }).okDesktop;
});

describe('BottomComposer (shell behavior)', () => {
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 7 additions & 1 deletion packages/app/src/components/BottomComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -137,6 +139,7 @@ export function BottomComposer({
const terminalLaunch = useTerminalLaunch();
const [stickyId] = useState(() => loadStickyAgent());
const [selectedId, setSelectedId] = useState<string | null>(null);
const installedClis = useInstalledClis();
const [isEmpty, setIsEmpty] = useState(true);
const [pending, setPending] = useState(false);
const inputRef = useRef<ComposerMentionInputHandle>(null);
Expand Down Expand Up @@ -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);

Expand Down
16 changes: 16 additions & 0 deletions packages/app/src/components/EditorArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
41 changes: 38 additions & 3 deletions packages/app/src/components/EditorHeader.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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(
<TooltipProvider>
<EditorHeader />
<EditorHeader {...props} />
</TooltipProvider>,
);
return document.querySelector('header') as HTMLElement;
Expand Down Expand Up @@ -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();
});
});
44 changes: 42 additions & 2 deletions packages/app/src/components/EditorHeader.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -149,6 +166,29 @@ export function EditorHeader({ onSignIn, onSetIdentity, onOpenSearch }: EditorHe
)}
<SyncStatusBadge onSignIn={onSignIn} onSetIdentity={onSetIdentity} />
<PresenceBar />
{/* 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 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onToggleChat?.()}
aria-label={chatToggleLabel}
data-telemetry-event="ok.editor_header.toggle_chat.click"
className={cn(
'shrink-0 text-muted-foreground',
isElectronHost && '[-webkit-app-region:no-drag]',
)}
>
<MessageSquare aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent>{chatToggleLabel}</TooltipContent>
</Tooltip>
)}
<Separator orientation="vertical" className="h-4 shrink-0 data-vertical:self-center" />
<BetaBadge />
{/* Settings is unavailable in single-file mode (config editing is inert). */}
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/components/EditorPane.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ function makeOkDesktopStub(
},
terminal: {
getDockState,
cliInstalledMap: async () => ({
claude: true,
codex: false,
opencode: false,
cursor: false,
}),
},
},
};
Expand Down
36 changes: 34 additions & 2 deletions packages/app/src/components/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,30 @@ 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,
type TerminalDockPosition,
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;
}
Expand All @@ -44,7 +48,10 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) {
const [activeTab, setActiveTab] = useState<PanelTab>(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<TerminalLaunchIntent | null>(null);
Expand All @@ -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();
Expand Down Expand Up @@ -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 });
});
Expand Down Expand Up @@ -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
Expand All @@ -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 ? (
<TerminalSessionsHost
bridge={desktopBridge}
visible={terminalVisible}
onVisibleChange={setTerminalVisible}
launch={terminalLaunch}
installedClis={installedClis}
container={terminalPlacement.container}
isShowing={terminalPlacement.isShowing}
onRequestEditorFocus={() => terminalPlacement.editorRegion?.focus()}
dockPosition={terminalDock}
onToggleDock={toggleTerminalDock}
onHasSessionsChange={setHasSessions}
/>
) : null}
<AuthModal
Expand Down
Loading