diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 020906ae..e618c726 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -2569,6 +2569,31 @@ Homepage: https://github.com/vimeo/player.js Copyright (c) 2016 [Vimeo](https://vimeo.com) +### `@xterm/addon-fit@0.11.0` +Homepage: https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit + +Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +### `@xterm/addon-unicode11@0.9.0` +Homepage: https://github.com/xtermjs/xterm.js/tree/master/addons/addon-unicode11 + +Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +### `@xterm/addon-web-links@0.12.0` +Homepage: https://github.com/xtermjs/xterm.js/tree/master/addons/addon-web-links + +Copyright (c) 2017, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +### `@xterm/addon-webgl@0.19.0` +Homepage: https://github.com/xtermjs/xterm.js/tree/master/addons/addon-webgl + +Copyright (c) 2018, The xterm.js authors (https://github.com/xtermjs/xterm.js) + +### `@xterm/xterm@6.0.0` +Homepage: https://github.com/xtermjs/xterm.js + +Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js) Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com) Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/) + ### `accepts@2.0.0` Homepage: https://github.com/jshttp/accepts @@ -4390,6 +4415,13 @@ Homepage: https://github.com/prebuild/node-gyp-build Copyright (c) 2017 Mathias Buus +### `node-pty@1.1.0` +Homepage: https://github.com/microsoft/node-pty + +Copyright (c) 2012-2015, Christopher Jeffrey (https://github.com/chjj/) +Copyright (c) 2016, Daniel Imms (http://www.growingwiththeweb.com) +Copyright (c) 2018 - present Microsoft Corporation + ### `node-releases@2.0.37` Homepage: https://github.com/chicoxyzzy/node-releases diff --git a/bun.lock b/bun.lock index d506e9be..6a69517e 100644 --- a/bun.lock +++ b/bun.lock @@ -107,6 +107,11 @@ "@tiptap/y-tiptap": "^3.0.3", "@u-wave/react-vimeo": "^0.9.12", "@uiw/codemirror-theme-basic": "^4.25.9", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -316,6 +321,7 @@ "@inkeep/open-knowledge-server": "workspace:*", "@napi-rs/keyring": "^1.3.0", "electron-updater": "6.8.4", + "node-pty": "1.1.0", "pino": "^10.3.1", "semver": "^7.7.4", "yaml": "^2.8.3", @@ -1809,6 +1815,16 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="], + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/addon-webgl": ["@xterm/addon-webgl@0.19.0", "", {}, "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@zag-js/anatomy": ["@zag-js/anatomy@1.41.1", "", {}, "sha512-wBQVpl8TC9O5AjeJrnmNdJWEUYorTi7iklOcySeXIeaz6D7Y0YY0YbEOSFNsRTpn/NQHwkPejf3i5qkKavNHXw=="], "@zag-js/collection": ["@zag-js/collection@1.41.1", "", { "dependencies": { "@zag-js/utils": "1.41.1" } }, "sha512-6Kun1lmkp3k+JHkcwCscrKNmPLAZNIeswpGvbbd3T5Qj7WX7b5A2Z926ZHUMicrXQinAtT90B9zrTurDdJZ4EQ=="], @@ -3227,6 +3243,8 @@ "node-liblzma": ["node-liblzma@2.2.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "bin": { "nxz": "lib/cli/nxz.js" } }, "sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g=="], + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="], diff --git a/knip.config.ts b/knip.config.ts index e9378d5c..ff401a92 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -115,12 +115,14 @@ export default { 'src/main/index.ts', 'src/preload/index.ts', 'src/utility/server-entry.ts', + 'src/utility/pty-host.ts', 'src/**/*.test.ts', 'electron.vite.config.ts', 'scripts/*.mjs', 'tests/**/*.test.ts', 'tests/**/*.test.mjs', ], + ignoreUnresolved: [/utility\/pty-host\.js$/], project: 'src/**', }, }, diff --git a/packages/app/package.json b/packages/app/package.json index 0ab45586..6b42cf1a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -43,14 +43,14 @@ { "name": "main app bundle (gzipped)", "path": "dist/assets/index-*.js", - "limit": "407 kB", + "limit": "415 kB", "gzip": true, "running": false }, { "name": "all JS chunks combined (gzipped)", "path": "dist/assets/*.js", - "limit": "2.85 MB", + "limit": "3 MB", "gzip": true, "running": false }, @@ -115,6 +115,11 @@ "@tiptap/y-tiptap": "^3.0.3", "@u-wave/react-vimeo": "^0.9.12", "@uiw/codemirror-theme-basic": "^4.25.9", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode11": "0.9.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", + "@xterm/xterm": "6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/packages/app/src/App.dom.test.tsx b/packages/app/src/App.dom.test.tsx index b2840794..0c68ae9f 100644 --- a/packages/app/src/App.dom.test.tsx +++ b/packages/app/src/App.dom.test.tsx @@ -91,6 +91,10 @@ mock.module('@/lib/config-provider', () => ({ ), })); +mock.module('@/lib/config-context', () => ({ + useConfigContext: () => ({ merged: null }), +})); + mock.module('@/lib/api-config', () => ({ fetchApiConfig: (...args: Parameters) => fetchApiConfigMock(...args), })); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index eb373bc9..55d219dd 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -6,6 +6,12 @@ import { CreateProjectMenuTrigger } from '@/components/CreateProjectMenuTrigger' import { EditorPane } from '@/components/EditorPane'; import { FileSidebar } from '@/components/FileSidebar'; import { defaultInitialDir } from '@/components/file-tree-utils'; +import { + type TerminalLaunchContextValue, + TerminalLaunchProvider, +} from '@/components/handoff/TerminalLaunchContext'; +import { requestTerminalLaunch } from '@/components/handoff/terminal-launch-events'; +import { selectScopedPrompt } from '@/components/handoff/useHandoffDispatch'; import { InstallInClaudeDesktopDialog } from '@/components/InstallInClaudeDesktopDialog'; import { McpConsentDialog } from '@/components/McpConsentDialog'; import { isNewItemShortcut, NewItemDialog } from '@/components/NewItemDialog'; @@ -24,6 +30,7 @@ import { useDocumentTransition, } from '@/editor/DocumentContext'; import { fetchApiConfig } from '@/lib/api-config'; +import { useConfigContext } from '@/lib/config-context'; import { ConfigProvider } from '@/lib/config-provider'; import { assetPathFromHash, docNameFromHash, isContentRootHash } from '@/lib/doc-hash'; import { mark, ProfilerBoundary } from '@/lib/perf'; @@ -316,6 +323,16 @@ function AppBody() { const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null; const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); const singleFile = useSingleFileMode(); + const { merged } = useConfigContext(); + const autoOpen = merged?.appearance?.preview?.autoOpen ?? true; + + const terminalLaunch: TerminalLaunchContextValue | null = desktopBridge + ? { + launchInTerminal: (input) => { + requestTerminalLaunch(selectScopedPrompt(input, 'claude-code', autoOpen)); + }, + } + : null; return ( <> @@ -363,14 +380,20 @@ function AppBody() { className="pointer-events-none fixed inset-x-0 top-0 z-50 h-2 [-webkit-app-region:drag]" /> )} - - {/* No-project single-file mode drops the file sidebar (file tree + - project switcher); the editor inset takes the full width. */} - {!singleFile && setCommandPaletteOpen(true)} />} - - setCommandPaletteOpen(true)} /> - - + {/* The "Open in terminal" entry point spans both the FileSidebar + menus and the EditorHeader/EditorPane, which are siblings here — + so the provider wraps both. Its value is desktop-gated; the docked + terminal that consumes the launch lives in EditorPane. */} + + + {/* No-project single-file mode drops the file sidebar (file tree + + project switcher); the editor inset takes the full width. */} + {!singleFile && setCommandPaletteOpen(true)} />} + + setCommandPaletteOpen(true)} /> + + + ); diff --git a/packages/app/src/components/ClaudeReadinessBanner.dom.test.tsx b/packages/app/src/components/ClaudeReadinessBanner.dom.test.tsx new file mode 100644 index 00000000..daa5874b --- /dev/null +++ b/packages/app/src/components/ClaudeReadinessBanner.dom.test.tsx @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ClaudeReadiness, OkDesktopBridge } from '@/lib/desktop-bridge-types'; + +const toastErrors: string[] = []; +mock.module('sonner', () => ({ + toast: { error: (message: string) => toastErrors.push(message) }, +})); + +const { ClaudeReadinessBanner } = await import('./ClaudeReadinessBanner'); + +function makeBridge(rewireResult: ClaudeReadiness = { claude: 'present', mcp: 'wired' }) { + const openExternal = mock(async (_url: string) => {}); + const rewireClaudeMcp = mock(async () => rewireResult); + const bridge = { + shell: { openExternal }, + terminal: { rewireClaudeMcp }, + } as unknown as OkDesktopBridge; + return { bridge, openExternal, rewireClaudeMcp }; +} + +beforeEach(() => { + toastErrors.length = 0; +}); +afterEach(() => cleanup()); + +describe('ClaudeReadinessBanner', () => { + test('not-found: offers a help affordance that opens the Claude Code docs', () => { + const { bridge, openExternal, rewireClaudeMcp } = makeBridge(); + render( + {}} + />, + ); + + expect(screen.getByText(/isn't installed or on your PATH/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Get Claude Code' })); + expect(openExternal).toHaveBeenCalledTimes(1); + expect(openExternal.mock.calls[0]?.[0]).toContain('claude-code'); + expect(rewireClaudeMcp).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Connect tools' })).toBeNull(); + }); + + test('present + needs-rewire: offers a re-wire affordance and dismisses on success', async () => { + const onDismiss = mock(() => {}); + const { bridge, rewireClaudeMcp, openExternal } = makeBridge(); + render( + , + ); + + expect(screen.getByText(/aren't connected to it yet/)).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Connect tools' })); + expect(rewireClaudeMcp).toHaveBeenCalledTimes(1); + await waitFor(() => expect(onDismiss).toHaveBeenCalledTimes(1)); + expect(openExternal).not.toHaveBeenCalled(); + }); + + test('present + needs-rewire: a rewire that reports an error surfaces a toast and keeps the banner', async () => { + const onDismiss = mock(() => {}); + const { bridge } = makeBridge({ + claude: 'present', + mcp: 'needs-rewire', + rewireError: 'consent dialog failed to arm', + }); + render( + , + ); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Connect tools' })); + await Promise.resolve(); + }); + await waitFor(() => expect(toastErrors.length).toBe(1)); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + test('present + wired: renders nothing', () => { + const { bridge } = makeBridge(); + const { container } = render( + {}} + />, + ); + expect(container.firstChild).toBeNull(); + expect(screen.queryByRole('status')).toBeNull(); + }); + + test('unknown probe verdict renders nothing (no false "not installed")', () => { + const { bridge } = makeBridge(); + const { container } = render( + {}} + />, + ); + expect(container.firstChild).toBeNull(); + }); + + test('exposes a status live region and an accessible dismiss control', () => { + const onDismiss = mock(() => {}); + const { bridge } = makeBridge(); + render( + , + ); + + expect(screen.getByRole('status')).toBeTruthy(); + const dismiss = screen.getByRole('button', { name: 'Dismiss' }); + fireEvent.click(dismiss); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/components/ClaudeReadinessBanner.tsx b/packages/app/src/components/ClaudeReadinessBanner.tsx new file mode 100644 index 00000000..a81b4a21 --- /dev/null +++ b/packages/app/src/components/ClaudeReadinessBanner.tsx @@ -0,0 +1,80 @@ +import { useLingui } from '@lingui/react/macro'; +import { X } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import type { ClaudeReadiness, OkDesktopBridge } from '@/lib/desktop-bridge-types'; + +const CLAUDE_CODE_DOCS_URL = 'https://docs.claude.com/en/docs/claude-code'; + +interface ClaudeReadinessBannerProps { + readonly readiness: ClaudeReadiness; + readonly bridge: OkDesktopBridge; + readonly onDismiss: () => void; +} + +type BannerKind = 'claude-missing' | 'mcp-needs-rewire'; + +function bannerKind(readiness: ClaudeReadiness): BannerKind | null { + if (readiness.claude === 'not-found') return 'claude-missing'; + if (readiness.claude === 'present' && readiness.mcp === 'needs-rewire') { + return 'mcp-needs-rewire'; + } + return null; +} + +export function ClaudeReadinessBanner({ + readiness, + bridge, + onDismiss, +}: ClaudeReadinessBannerProps) { + const { t } = useLingui(); + const kind = bannerKind(readiness); + if (kind === null) return null; + + const isClaudeMissing = kind === 'claude-missing'; + const message = isClaudeMissing + ? t`Claude Code (claude) isn't installed or on your PATH.` + : t`Claude Code is installed, but Open Knowledge tools aren't connected to it yet.`; + const actionLabel = isClaudeMissing ? t`Get Claude Code` : t`Connect tools`; + + function handleAction() { + if (isClaudeMissing) { + void bridge.shell.openExternal(CLAUDE_CODE_DOCS_URL); + return; + } + bridge.terminal + .rewireClaudeMcp() + .then((result) => { + if (result.rewireError != null) { + toast.error(t`Couldn't connect Open Knowledge tools to Claude Code. Please try again.`); + return; + } + onDismiss(); + }) + .catch((err) => { + console.warn('[terminal] rewireClaudeMcp failed:', err); + toast.error(t`Couldn't connect Open Knowledge tools to Claude Code. Please try again.`); + }); + } + + return ( +
+

{message}

+ + +
+ ); +} diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx index d9bddf51..2c7f57f2 100644 --- a/packages/app/src/components/EditorArea.tsx +++ b/packages/app/src/components/EditorArea.tsx @@ -31,6 +31,7 @@ import { syncPromiseHasResolved } from '@/editor/sync-promise'; import { useDocumentStats } from '@/hooks/use-document-stats'; import { useLifecycleStatus } from '@/hooks/use-lifecycle-status'; import { useSelectionStats } from '@/hooks/use-selection-stats'; +import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { docNameFromHash, hashFromDocName } from '@/lib/doc-hash'; import { getInitialDocPanelWidth, writeDocPanelWidth } from '@/lib/doc-panel-width-store'; import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; @@ -42,9 +43,10 @@ import { cn } from '@/lib/utils'; import { useSyncStatus } from '@/presence/use-sync-status'; import { EditorActivityPool } from './EditorActivityPool'; import { EditorFooter } from './EditorFooter'; -import type { EditorMode } from './EditorPane'; +import type { EditorMode, TerminalLaunchIntent } from './EditorPane'; import { EditorToolbar } from './EditorToolbar'; import { shouldPaintOverlay } from './editor-area-overlay'; +import { TerminalDock } from './TerminalDock'; const LazyActivityModeContent = lazy(async () => { const mod = await import('@/components/ActivityModeContent'); @@ -56,6 +58,12 @@ interface EditorAreaProps { onModeChange: (mode: EditorMode) => void; activeTab: PanelTab; onActiveTabChange: (tab: PanelTab) => void; + terminalBridge?: OkDesktopBridge | null; + terminalVisible?: boolean; + onTerminalVisibleChange?: (visible: boolean) => void; + /** "Open in terminal" launch intent — carried to the terminal session, which + * writes the `claude` launch once per nonce. Null until a UI click. */ + terminalLaunch?: TerminalLaunchIntent | null; } export function EditorArea(props: EditorAreaProps) { @@ -92,6 +100,10 @@ function EditorAreaInner({ onModeChange, activeTab, onActiveTabChange, + terminalBridge, + terminalVisible = false, + onTerminalVisibleChange, + terminalLaunch = null, }: EditorAreaProps) { const { t } = useLingui(); const { @@ -460,6 +472,20 @@ function EditorAreaInner({ ); + const editorColumn = + terminalBridge != null ? ( + {})} + launch={terminalLaunch} + > + {editorContent} + + ) : ( + editorContent + ); + return (
- {editorContent} + {editorColumn} ({ @@ -43,7 +44,25 @@ mock.module('./EditorHeader', () => ({ })); mock.module('./EditorArea', () => ({ - EditorArea: () =>
, + EditorArea: ({ + terminalBridge, + terminalVisible, + }: { + terminalBridge?: unknown; + terminalVisible?: boolean; + }) => ( +
+ {terminalBridge != null ? ( +
+ ) : null} +
+ ), +})); + +const terminalOpenedCalls: true[] = []; +mock.module('@/lib/terminal-telemetry', () => ({ + recordTerminalOpened: () => terminalOpenedCalls.push(true), + recordShellConsentGranted: () => undefined, })); mock.module('./AuthModal', () => ({ @@ -126,3 +145,123 @@ describe('EditorPane auto-sync onboarding gate', () => { expect(screen.getByTestId('auto-sync-onboarding').getAttribute('data-open')).toBe('false'); }); }); + +function makeOkDesktopStub() { + const menuHandlers: Array<(action: string) => void> = []; + const viewMenuPushes: Array<{ terminalVisible?: boolean }> = []; + return { + viewMenuPushes, + dispatchMenuAction(action: string) { + for (const cb of menuHandlers) cb(action); + }, + stub: { + onMenuAction(cb: (action: string) => void) { + menuHandlers.push(cb); + return () => { + const index = menuHandlers.indexOf(cb); + if (index >= 0) menuHandlers.splice(index, 1); + }; + }, + editor: { + notifyViewMenuStateChanged(state: { terminalVisible?: boolean }) { + viewMenuPushes.push(state); + }, + }, + }, + }; +} + +describe('EditorPane terminal dock wiring', () => { + afterEach(() => { + cleanup(); + delete (window as { okDesktop?: unknown }).okDesktop; + terminalOpenedCalls.length = 0; + }); + + test('web host renders the editor chrome without a terminal dock', async () => { + await renderEditorPane(); + + expect(screen.queryByTestId('terminal-dock')).toBeNull(); + expect(screen.getByTestId('editor-header')).toBeTruthy(); + expect(screen.getByTestId('editor-area')).toBeTruthy(); + }); + + test('desktop host renders the editor chrome with the terminal dock under the editor area', async () => { + (window as { okDesktop?: unknown }).okDesktop = makeOkDesktopStub().stub; + await renderEditorPane(); + + expect(screen.getByTestId('editor-header')).toBeTruthy(); + const area = screen.getByTestId('editor-area'); + expect(area.querySelector('[data-testid="terminal-dock"]')).not.toBeNull(); + }); + + test('desktop: toggle-terminal menu action flips dock visibility and pushes the view-menu state', async () => { + const desk = makeOkDesktopStub(); + (window as { okDesktop?: unknown }).okDesktop = desk.stub; + await renderEditorPane(); + + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); + expect(desk.viewMenuPushes.at(-1)).toEqual({ terminalVisible: false }); + + act(() => desk.dispatchMenuAction('toggle-terminal')); + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('true'); + expect(desk.viewMenuPushes.at(-1)).toEqual({ terminalVisible: true }); + + act(() => desk.dispatchMenuAction('toggle-terminal')); + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); + expect(desk.viewMenuPushes.at(-1)).toEqual({ terminalVisible: false }); + }); + + test('desktop: an unrelated menu action does not toggle the terminal', async () => { + const desk = makeOkDesktopStub(); + (window as { okDesktop?: unknown }).okDesktop = desk.stub; + await renderEditorPane(); + + act(() => desk.dispatchMenuAction('toggle-doc-panel')); + expect(screen.getByTestId('terminal-dock').getAttribute('data-visible')).toBe('false'); + }); + + test('desktop: each open records terminal-opened; mount (hidden) and close do not', async () => { + const desk = makeOkDesktopStub(); + (window as { okDesktop?: unknown }).okDesktop = desk.stub; + await renderEditorPane(); + + expect(terminalOpenedCalls).toHaveLength(0); + + act(() => desk.dispatchMenuAction('toggle-terminal')); // hidden → open + expect(terminalOpenedCalls).toHaveLength(1); + + act(() => desk.dispatchMenuAction('toggle-terminal')); // open → hidden (no record) + expect(terminalOpenedCalls).toHaveLength(1); + + act(() => desk.dispatchMenuAction('toggle-terminal')); // hidden → open again + expect(terminalOpenedCalls).toHaveLength(2); + }); + + test('web host: a Cmd/Ctrl+J keydown is intercepted (the toggle handler is wired)', async () => { + await renderEditorPane(); + + const init: KeyboardEventInit = { key: 'j', cancelable: true, bubbles: true }; + if (isMacOS()) init.metaKey = true; + else init.ctrlKey = true; + const event = new KeyboardEvent('keydown', init); + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + test('web host: an unrelated keydown is not intercepted', async () => { + await renderEditorPane(); + + const event = new KeyboardEvent('keydown', { + key: 'g', + metaKey: true, + ctrlKey: true, + cancelable: true, + bubbles: true, + }); + window.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + }); +}); diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx index 49e570b8..03048d5c 100644 --- a/packages/app/src/components/EditorPane.tsx +++ b/packages/app/src/components/EditorPane.tsx @@ -9,6 +9,8 @@ import { type EditorModeValue, useEditorMode } from '@/editor/use-editor-mode'; import { useGitSyncStatus } from '@/hooks/use-git-sync-status'; import { useNoPushPermissionToast } from '@/hooks/use-no-push-permission-toast'; import { useConfigContext } from '@/lib/config-provider'; +import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts'; +import { recordTerminalOpened } from '@/lib/terminal-telemetry'; import { useWorkspace } from '@/lib/use-workspace'; import { AuthModal } from './AuthModal'; import { AutoSyncOnboardingDialog } from './AutoSyncOnboardingDialog'; @@ -17,11 +19,17 @@ import { type PanelTab, TABS } from './DocPanel'; import { EditorArea } from './EditorArea'; import { EditorHeader } from './EditorHeader'; import { OpenInAgentMenuRequestProvider } from './handoff/OpenInAgentMenuRequestContext'; +import { subscribeToTerminalLaunchRequests } from './handoff/terminal-launch-events'; import { buildSelectionOrDocHandoffInput, type HandoffDispatchInput, } from './handoff/useHandoffDispatch'; +export interface TerminalLaunchIntent { + readonly prompt: string; + readonly nonce: number; +} + export type EditorMode = EditorModeValue; interface EditorPaneProps { @@ -40,6 +48,9 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { const [openInAgentMenuInput, setOpenInAgentMenuInput] = useState( null, ); + const desktopBridge = typeof window !== 'undefined' ? (window.okDesktop ?? null) : null; + const [terminalVisible, setTerminalVisible] = useState(false); + const [terminalLaunch, setTerminalLaunch] = useState(null); const syncStatus = useGitSyncStatus(); const { projectLocalConfig, projectLocalSynced } = useConfigContext(); @@ -67,6 +78,45 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { return () => window.removeEventListener(RAW_MDX_NAV_EVENT, onRawMdxNav); }, [activeDocName]); + useEffect(() => { + const bridge = window.okDesktop; + if (bridge == null) return; + return bridge.onMenuAction((action) => { + if (action === 'toggle-terminal') { + setTerminalVisible((visible) => !visible); + } + }); + }, []); + + useEffect(() => { + if (window.okDesktop != null) return; + function handleKeyDown(event: KeyboardEvent) { + if (matchesKeyboardShortcut(event, 'toggle-terminal-panel')) { + event.preventDefault(); + setTerminalVisible((visible) => !visible); + } + } + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }, []); + + useEffect(() => { + return subscribeToTerminalLaunchRequests((prompt) => { + setTerminalVisible(true); + setTerminalLaunch((prev) => ({ prompt, nonce: (prev?.nonce ?? 0) + 1 })); + }); + }, []); + + useEffect(() => { + if (window.okDesktop == null) return; + window.okDesktop.editor.notifyViewMenuStateChanged({ terminalVisible }); + }, [terminalVisible]); + + useEffect(() => { + if (window.okDesktop == null) return; + if (terminalVisible) recordTerminalOpened(); + }, [terminalVisible]); + useNoPushPermissionToast(syncStatus?.pausedReason); function handleModeChange(mode: EditorModeValue) { @@ -114,11 +164,19 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) { openInAgentMenuInput={openInAgentMenuInput} onOpenInAgentMenuOpenChange={handleOpenInAgentMenuOpenChange} /> + {/* The terminal docks under the editor/file column only — EditorArea + nests the vertical split inside its horizontal editor↔doc-panel + split so the doc panel stays full-height beside the terminal. The + ⌘J/menu/telemetry state stays owned here and is threaded down. */} {}), }; const projectLocalPatch = mock((_patch: unknown) => projectPatchResult); -const dispatchOpenInTerminalMock = mock((_bridge: unknown, _path: string) => Promise.resolve()); const showItemInFolderMock = mock((_path: string) => Promise.resolve()); const notifyViewMenuStateChangedMock = mock((_snapshot: unknown) => {}); const onOpenSearch = mock(() => {}); @@ -276,6 +275,7 @@ mock.module('@/components/ui/dropdown-menu', () => ({
{children}
), DropdownMenuItem: Button, + DropdownMenuSeparator: () => null, DropdownMenuTrigger: PassThrough, })); @@ -363,10 +363,6 @@ mock.module('@/lib/config-provider', () => ({ }), })); -mock.module('@/lib/dispatch-open-in-terminal', () => ({ - dispatchOpenInTerminal: dispatchOpenInTerminalMock, -})); - mock.module('@/lib/use-workspace', () => ({ useWorkspace: () => workspace, })); @@ -408,7 +404,6 @@ describe('FileSidebar runtime behavior', () => { treeCalls.startCreatingFromTemplate, treeCalls.uploadFiles, projectLocalPatch, - dispatchOpenInTerminalMock, showItemInFolderMock, notifyViewMenuStateChangedMock, onOpenSearch, @@ -528,7 +523,6 @@ describe('FileSidebar runtime behavior', () => { 'empty-space-menu-upload-file', 'empty-space-menu-reveal-in-finder', 'open-in-agent-empty-space-submenu', - 'empty-space-menu-open-in-terminal', 'empty-space-menu-copy-full-path', 'empty-space-menu-show-hidden-files', 'empty-space-menu-show-all-files', @@ -554,12 +548,6 @@ describe('FileSidebar runtime behavior', () => { fireEvent.click(screen.getByTestId('empty-space-menu-reveal-in-finder')); expect(showItemInFolderMock).toHaveBeenCalledWith('/tmp/open-knowledge'); - fireEvent.click(screen.getByTestId('empty-space-menu-open-in-terminal')); - expect(dispatchOpenInTerminalMock).toHaveBeenCalledWith( - window.okDesktop, - '/tmp/open-knowledge', - ); - fireEvent.click(screen.getByTestId('empty-space-menu-copy-full-path')); await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('/tmp/open-knowledge'), diff --git a/packages/app/src/components/FileSidebar.menu-action.dom.test.tsx b/packages/app/src/components/FileSidebar.menu-action.dom.test.tsx index b19ea026..d81b1b4e 100644 --- a/packages/app/src/components/FileSidebar.menu-action.dom.test.tsx +++ b/packages/app/src/components/FileSidebar.menu-action.dom.test.tsx @@ -57,7 +57,6 @@ const ACTIVE_TARGET = { const notifyViewMenuStateChangedMock = mock(() => {}); const toggleSidebarMock = mock(() => {}); const showItemInFolderMock = mock((_path: string) => Promise.resolve()); -const dispatchOpenInTerminalMock = mock((_bridge: unknown, _path: string) => Promise.resolve()); const handoffDispatchMock = mock((_target: string, _input: unknown) => Promise.resolve({ ok: true }), ); @@ -130,6 +129,7 @@ mock.module('@/components/ui/dropdown-menu', () => ({ DropdownMenu: PassThrough, DropdownMenuContent: ElementPassThrough, DropdownMenuItem: Button, + DropdownMenuSeparator: () => null, DropdownMenuTrigger: PassThrough, })); @@ -198,10 +198,6 @@ mock.module('@/lib/config-provider', () => ({ }), })); -mock.module('@/lib/dispatch-open-in-terminal', () => ({ - dispatchOpenInTerminal: dispatchOpenInTerminalMock, -})); - mock.module('@/lib/use-workspace', () => ({ useWorkspace: () => ({ contentDir: '/tmp/open-knowledge', @@ -230,7 +226,6 @@ describe('FileSidebar menu-action runtime routing', () => { notifyViewMenuStateChangedMock, toggleSidebarMock, showItemInFolderMock, - dispatchOpenInTerminalMock, handoffDispatchMock, projectLocalPatch, treeCalls.collapseAll, @@ -347,12 +342,6 @@ describe('FileSidebar menu-action runtime routing', () => { menuActionCallback?.('reveal-in-finder' as MenuAction); expect(showItemInFolderMock).toHaveBeenCalledWith('/tmp/open-knowledge/notes/source.md'); - menuActionCallback?.('open-in-terminal' as MenuAction); - expect(dispatchOpenInTerminalMock).toHaveBeenCalledWith( - window.okDesktop, - '/tmp/open-knowledge/notes', - ); - menuActionCallback?.('send-to-ai' as MenuAction); expect(handoffDispatchMock).toHaveBeenCalledWith( 'codex', diff --git a/packages/app/src/components/FileSidebar.tsx b/packages/app/src/components/FileSidebar.tsx index 7c821dde..777cfd66 100644 --- a/packages/app/src/components/FileSidebar.tsx +++ b/packages/app/src/components/FileSidebar.tsx @@ -8,7 +8,6 @@ import { FoldVertical, ListCollapse, SquarePen, - Terminal, UnfoldVertical, Upload, } from 'lucide-react'; @@ -62,11 +61,9 @@ import { useFolderConfig } from '@/hooks/use-folder-config'; import { useIsEmbedded } from '@/hooks/use-is-embedded'; import { useConfigContext } from '@/lib/config-provider'; import { subscribeToCreateTopLevelFile } from '@/lib/create-file-events'; -import { dispatchOpenInTerminal } from '@/lib/dispatch-open-in-terminal'; import { buildSendToAiInputForActiveTarget, resolveActiveTargetAbsPath, - resolveActiveTargetParentDirAbsPath, resolveActiveTargetRelativePath, } from '@/lib/file-menu-target-resolvers'; import { @@ -219,10 +216,6 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { if (!workspace || !bridge) return; void bridge.shell.showItemInFolder(workspace.contentDir); }; - const handleEmptySpaceOpenInTerminal = () => { - if (!workspace || !bridge) return; - void dispatchOpenInTerminal(bridge, workspace.contentDir); - }; const handleEmptySpaceCopyFullPath = async () => { if (!workspace) return; try { @@ -322,16 +315,6 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { void bridge.shell.showItemInFolder(absPath); return; } - case 'open-in-terminal': { - if (!bridge || !workspace) return; - const dirAbsPath = resolveActiveTargetParentDirAbsPath( - activeTarget, - activeDocName, - workspace, - ); - void dispatchOpenInTerminal(bridge, dirAbsPath); - return; - } case 'send-to-ai': { const installedTargets = VISIBLE_TARGETS.filter( (target) => handoffInstallStates[target.id]?.installed === true, @@ -686,8 +669,8 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { * (parentDir = '' → contentDir). Upload opens the project-root file * picker. Disabled when workspace hasn't resolved. * - * Section 2: Act-on-project. Reveal in Finder + Open in Terminal - * are Electron-only (`if (!bridge) return null`); Open with AI submenu + * Section 2: Act-on-project. Reveal in Finder + * is Electron-only (`if (!bridge) return null`); Open with AI submenu * is cross-host (filtered via useInstalledAgents); Copy full path * is cross-host. * @@ -769,24 +752,6 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { dispatch={dispatchHandoff} webFallbackVisible={false} /> - {bridge ? ( - - - ) : null} void; -}) { - const { t } = useLingui(); - const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined; - if (!bridge) return null; - const hint = dirAbsPath === null ? t`No workspace` : null; - return ( - { - if (dirAbsPath === null) return; - onClose(); - void dispatchOpenInTerminal(bridge, dirAbsPath); - }} - aria-label={hint ? t`Open in Terminal, ${hint}` : t`Open in Terminal`} - > - - ); -} - interface FileTreeMenuProps { item: ContextMenuItem; context: ContextMenuOpenContext; @@ -722,21 +686,6 @@ function FileTreeMenu({ const deleteTargets = selectedDeleteTargets.length > 1 ? selectedDeleteTargets : [target]; const deleteCount = deleteTargets.length; const deleteLabel = plural(deleteCount, { one: 'Delete', other: 'Delete # items' }); - const folderAbsPath = - isFolder && workspace - ? joinWorkspacePath( - workspace.contentDir, - relativePathForTreeItem(item), - workspace.pathSeparator, - ) - : null; - const parentDirAbsPath: string | null = (() => { - if (!workspace || isFolder) return null; - const rel = relativePathForTreeItem(item); - const lastSep = rel.lastIndexOf('/'); - if (lastSep === -1) return workspace.contentDir; - return joinWorkspacePath(workspace.contentDir, rel.slice(0, lastSep), workspace.pathSeparator); - })(); const handoffInput: HandoffDispatchInput | null = isAsset ? null : isFolder @@ -873,7 +822,6 @@ function FileTreeMenu({ dispatch={handoff.dispatch} webFallbackVisible={false} /> -