diff --git a/.changeset/command-palette-menu-parity.md b/.changeset/command-palette-menu-parity.md
new file mode 100644
index 00000000..9188d778
--- /dev/null
+++ b/.changeset/command-palette-menu-parity.md
@@ -0,0 +1,9 @@
+---
+"@inkeep/open-knowledge": minor
+---
+
+The Cmd+K command palette now reaches the actions that used to live only in the native menu bar. You can search for and run Check for updates, New from template, New worktree, Switch worktree, Rename, Duplicate, Move to Trash, Reveal in Finder, Copy full path, Copy relative path, Close tab, the View toggles (sidebar, document panel, terminal, hidden files, .ok folders, only markdown files, skills section), Expand all, Collapse all, New Terminal, Kill Terminal, Set up OpenKnowledge integrations, Check spelling while typing, and OpenKnowledge on GitHub, all from Cmd+K.
+
+Toggle commands show their current state (for example the sidebar row reads "Hide sidebar" when the sidebar is open), and the commands that do not need the desktop shell now work in the web app too, not just Desktop. These rows appear once you start typing, so the palette's empty state stays lean.
+
+On macOS, "Check for updates" no longer appears twice. It shows once, in the App menu, matching where Settings lives.
diff --git a/packages/app/package.json b/packages/app/package.json
index 1a11c668..493e4b28 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -26,7 +26,7 @@
"measure:fuzz": "bash scripts/measure-fuzz.sh",
"measure:stress": "bash scripts/measure-stress.sh",
"perf:compare": "bash scripts/perf-compare.sh",
- "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts",
+ "test:e2e": "playwright test tests/stress/ux-interactions.e2e.ts tests/stress/file-tree-create.e2e.ts tests/stress/file-tree-drag-to-root.e2e.ts tests/stress/file-tree-deselect-to-root.e2e.ts tests/stress/file-tree-compact-folders.e2e.ts tests/stress/create-then-rename-editable.e2e.ts tests/stress/find-replace.e2e.ts tests/stress/editor-tabs.e2e.ts tests/stress/crdt-stress.e2e.ts tests/stress/slash-command.e2e.ts tests/stress/paste-fidelity.e2e.ts tests/stress/fr-7a-disconnect-source-mode.e2e.ts tests/stress/docs-open.e2e.ts tests/stress/frozen-table-headers.e2e.ts tests/stress/asset-embed.e2e.ts tests/stress/asset-embed-advanced.e2e.ts tests/stress/asset-click-dispatch.e2e.ts tests/stress/handoff.e2e.ts tests/stress/multi-agent-presence.e2e.ts tests/stress/editor-mode-persistence.e2e.ts tests/stress/sidebar-search-pill.e2e.ts tests/stress/command-palette-semantic.e2e.ts tests/stress/agent-activity-panel.e2e.ts tests/stress/drop-pipeline-auto-open.e2e.ts tests/stress/command-palette-flicker.e2e.ts tests/stress/cm6-list-hanging-indent.e2e.ts tests/stress/ng7-rapid-nav-coherence.e2e.ts tests/stress/reveal-on-activate.e2e.ts tests/stress/selection-indicator.e2e.ts tests/stress/new-file-cross-doc-bleed.e2e.ts tests/stress/editor-mode-flip-cross-doc-bleed.e2e.ts tests/stress/editor-area-viewport-resize.e2e.ts tests/stress/qa-sidebar-responsive.e2e.ts tests/stress/prd-6955-reassertion-repro.e2e.ts tests/stress/prd-6955-reassertion-wedge.e2e.ts tests/stress/prd-6914-repro.e2e.ts tests/stress/showall-lazy-tree.e2e.ts tests/stress/tabs-component-strip.e2e.ts tests/stress/source-find-scroll.e2e.ts tests/stress/harness-app-warmth.e2e.ts tests/stress/qa-canary-authoring-both-modes.e2e.ts tests/stress/qa-canary-live-typing.e2e.ts tests/stress/keystroke-cadence-danger-space.e2e.ts tests/stress/jsx-unregistered-backspace-delete.e2e.ts tests/stress/jsx-unregistered-ime-concurrent.e2e.ts tests/stress/jsx-backspace-delete.e2e.ts tests/stress/link-authoring-bytes.e2e.ts tests/stress/link-authoring-apex.e2e.ts tests/stress/command-palette-parity.e2e.ts",
"test:e2e:install-browsers": "playwright install chromium webkit firefox",
"test:visual": "playwright test --config playwright.visual.config.ts",
"test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots",
@@ -45,7 +45,7 @@
{
"name": "main app bundle (gzipped)",
"path": "dist/assets/index-*.js",
- "limit": "476 kB",
+ "limit": "480 kB",
"gzip": true,
"running": false
},
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index e17e7bd2..a6d221e6 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -505,7 +505,7 @@ function AppBody() {
{desktopBridge ? : null}
{/* Help → Report a Bug… opens ReportBugDialog here — same
desktop-only App-root trigger pattern as CreateProjectMenuTrigger. */}
- {desktopBridge ? : null}
+ {desktopBridge ? : null}
{/* First-launch consent dialog — host-agnostic. Self-gates on
the shared `mcpConsentStore` snapshot; renders nothing until
main fires `ok:mcp-wiring:show`. Mounted identically in
diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx
index f109b7af..fb3d27ff 100644
--- a/packages/app/src/components/CommandPalette.dom.test.tsx
+++ b/packages/app/src/components/CommandPalette.dom.test.tsx
@@ -1,6 +1,15 @@
-import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
+import {
+ PALETTE_COMMAND_IDS,
+ PRE_EXISTING_PALETTE_IDS,
+} from '@/lib/command-menu-parity.test-helper';
+import {
+ __resetLocalMenuActionBusForTests,
+ subscribeLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
+import { __resetViewMenuStateForTests, setViewMenuState } from '@/lib/view-menu-state-store';
import { renderLinguiTemplate } from '@/test-utils/lingui-mock';
type CommandDialogProps = {
@@ -23,7 +32,12 @@ type CommandItemProps = {
};
let activeDocName: string | null = 'docs/active';
-let activeTarget: { kind: 'doc'; docName: string } | null = { kind: 'doc', docName: 'docs/active' };
+// Loosely typed so parity tests can set folder / asset / missing targets to
+// exercise the contextual-command projection.
+let activeTarget: { kind: string; [key: string]: unknown } | null = {
+ kind: 'doc',
+ docName: 'docs/active',
+};
let requestDocPanelTabCalls: string[] = [];
let seedDialogProps: Array<{ open: boolean }> = [];
let newItemDialogProps: Array<{ open: boolean; kind: string; initialDir: string }> = [];
@@ -241,6 +255,19 @@ function createBridge() {
}),
),
},
+ // Surfaces the backfilled bridge-invoke commands reach.
+ update: {
+ checkNow: mock(() => Promise.resolve()),
+ },
+ mcpWiring: {
+ reconfigure: mock(() => Promise.resolve(true)),
+ },
+ spellcheck: {
+ toggle: mock(() => Promise.resolve(true)),
+ },
+ shell: {
+ openExternal: mock(() => Promise.resolve()),
+ },
};
}
@@ -818,3 +845,290 @@ describe('NavigationItem path subtitle', () => {
expect(folderRow.querySelector('[data-testid="file-entry-extension-badge"]')).toBeNull();
});
});
+
+// Ratchet C + acceptance criteria for the menu-parity backfill. Honest limit: cmdk and
+// the hooks are mocked, so these assert the registry-driven branch emits the row
+// and wires the dispatch under its declared enabling context — not cmdk's own
+// filtering (covered by the palette DOM tests above + a Playwright smoke).
+describe('Cmd+K menu-parity backfill', () => {
+ let busActions: string[];
+ let unsubscribeBus: (() => void) | null = null;
+
+ beforeEach(() => {
+ cleanup();
+ __resetLocalMenuActionBusForTests();
+ __resetViewMenuStateForTests();
+ activeDocName = 'docs/active';
+ activeTarget = { kind: 'doc', docName: 'docs/active' };
+ pageListLoading = false;
+ window.location.hash = '';
+ globalThis.fetch = mock(() =>
+ Promise.resolve(new Response(JSON.stringify({ results: [] }), { status: 200 })),
+ ) as never;
+ // Panels visible + a live terminal so the state-aware View toggles render
+ // their "Hide …" variant and Kill Terminal is enabled.
+ setViewMenuState({
+ sidebarVisible: true,
+ docPanelVisible: true,
+ terminalVisible: true,
+ terminalLive: true,
+ });
+ busActions = [];
+ unsubscribeBus = subscribeLocalMenuAction((action) => busActions.push(action));
+ });
+
+ afterEach(() => {
+ unsubscribeBus?.();
+ unsubscribeBus = null;
+ __resetLocalMenuActionBusForTests();
+ __resetViewMenuStateForTests();
+ });
+
+ // Each id-backed backfill row renders under a matching query and emits its
+ // OkMenuAction id on the bus when selected.
+ const ID_BACKED: Array<{ testid: string; query: string; id: string }> = [
+ {
+ testid: 'command-palette-new-from-template',
+ query: 'new from template',
+ id: 'new-from-template',
+ },
+ { testid: 'command-palette-toggle-sidebar', query: 'sidebar', id: 'toggle-sidebar' },
+ { testid: 'command-palette-toggle-doc-panel', query: 'document panel', id: 'toggle-doc-panel' },
+ { testid: 'command-palette-toggle-terminal', query: 'hide terminal', id: 'toggle-terminal' },
+ {
+ testid: 'command-palette-toggle-show-hidden-files',
+ query: 'hidden files',
+ id: 'toggle-show-hidden-files',
+ },
+ {
+ testid: 'command-palette-toggle-show-ok-folders',
+ query: 'ok folders',
+ id: 'toggle-show-ok-folders',
+ },
+ {
+ testid: 'command-palette-toggle-show-only-markdown-files',
+ query: 'only markdown',
+ id: 'toggle-show-only-markdown-files',
+ },
+ {
+ testid: 'command-palette-toggle-show-skills-section',
+ query: 'skills section',
+ id: 'toggle-show-skills-section',
+ },
+ { testid: 'command-palette-expand-all-tree', query: 'expand all', id: 'expand-all-tree' },
+ { testid: 'command-palette-collapse-all-tree', query: 'collapse all', id: 'collapse-all-tree' },
+ { testid: 'command-palette-new-terminal', query: 'new terminal', id: 'new-terminal' },
+ { testid: 'command-palette-kill-terminal', query: 'kill terminal', id: 'kill-terminal' },
+ { testid: 'command-palette-new-worktree', query: 'new worktree', id: 'new-worktree' },
+ { testid: 'command-palette-switch-worktree', query: 'switch worktree', id: 'switch-worktree' },
+ { testid: 'command-palette-close-tab', query: 'close tab', id: 'close-active-tab-or-window' },
+ { testid: 'command-palette-rename', query: 'rename', id: 'rename' },
+ { testid: 'command-palette-duplicate', query: 'duplicate', id: 'duplicate' },
+ { testid: 'command-palette-move-to-trash', query: 'move to trash', id: 'move-to-trash' },
+ {
+ testid: 'command-palette-reveal-in-finder',
+ query: 'reveal in finder',
+ id: 'reveal-in-finder',
+ },
+ { testid: 'command-palette-copy-full-path', query: 'copy full path', id: 'copy-full-path' },
+ {
+ testid: 'command-palette-copy-relative-path',
+ query: 'copy relative path',
+ id: 'copy-relative-path',
+ },
+ ];
+
+ for (const { testid, query, id } of ID_BACKED) {
+ test(`AC1: "${id}" renders on query and emits on the bus`, async () => {
+ await renderPalette({ bridge: createBridge() });
+ await setQuery(query);
+ const row = screen.getByTestId(testid);
+ expect(row).not.toBeNull();
+ fireEvent.click(row);
+ expect(busActions).toContain(id);
+ });
+ }
+
+ // Ratchet C completeness: every id classified as a palette command (shared with
+ // the id-classification ratchet via command-menu-parity.test-helper) must be
+ // covered by a rendered id-backed row above OR a pre-existing palette surface.
+ // A future id classified into PALETTE_COMMAND_IDS with no rendered row — which
+ // satisfies only Ratchets A/B — turns this red, closing the "classified but
+ // unreachable" gap that the id ratchets alone cannot see.
+ test('Ratchet C: every classified palette-command id renders a row or is a pre-existing surface', () => {
+ const rendered = new Set(ID_BACKED.map((row) => row.id));
+ const covered = new Set([...rendered, ...PRE_EXISTING_PALETTE_IDS]);
+ const missing = [...PALETTE_COMMAND_IDS].filter((id) => !covered.has(id));
+ expect(missing).toEqual([]);
+ // No ID_BACKED row tests an id that isn't classified (stale row test).
+ const staleRows = [...rendered].filter((id) => !PALETTE_COMMAND_IDS.has(id));
+ expect(staleRows).toEqual([]);
+ // The pre-existing escape hatch stays honest: every entry is still classified.
+ const stalePreExisting = [...PRE_EXISTING_PALETTE_IDS].filter(
+ (id) => !PALETTE_COMMAND_IDS.has(id),
+ );
+ expect(stalePreExisting).toEqual([]);
+ });
+
+ test('AC1: check-for-updates invokes bridge.update.checkNow', async () => {
+ const { bridge } = await renderPalette({ bridge: createBridge() });
+ await setQuery('check for updates');
+ fireEvent.click(screen.getByTestId('command-palette-check-for-updates'));
+ await waitFor(() => expect(bridge?.update.checkNow).toHaveBeenCalledTimes(1));
+ });
+
+ test('AC1: set-up-integrations invokes bridge.mcpWiring.reconfigure', async () => {
+ const { bridge } = await renderPalette({ bridge: createBridge() });
+ await setQuery('set up openknowledge integrations');
+ fireEvent.click(screen.getByTestId('command-palette-set-up-integrations'));
+ await waitFor(() => expect(bridge?.mcpWiring.reconfigure).toHaveBeenCalledTimes(1));
+ });
+
+ test('AC1: OpenKnowledge on GitHub opens the repository URL', async () => {
+ const { bridge } = await renderPalette({ bridge: createBridge() });
+ await setQuery('github');
+ fireEvent.click(screen.getByTestId('command-palette-open-github'));
+ expect(bridge?.shell.openExternal).toHaveBeenCalledWith(
+ 'https://github.com/inkeep/open-knowledge',
+ );
+ });
+
+ test('AC1: OpenKnowledge on GitHub falls back to window.open on the web host', async () => {
+ const openSpy = mock(() => null);
+ const originalOpen = window.open;
+ window.open = openSpy as unknown as typeof window.open;
+ try {
+ await renderPalette({ bridge: null });
+ await setQuery('github');
+ fireEvent.click(screen.getByTestId('command-palette-open-github'));
+ expect(openSpy).toHaveBeenCalledWith(
+ 'https://github.com/inkeep/open-knowledge',
+ '_blank',
+ 'noopener,noreferrer',
+ );
+ } finally {
+ window.open = originalOpen;
+ }
+ });
+
+ test('AC3: web host shows host-agnostic toggles but hides desktop-only commands', async () => {
+ await renderPalette({ bridge: null });
+
+ await setQuery('sidebar');
+ expect(screen.queryByTestId('command-palette-toggle-sidebar')).not.toBeNull();
+ await setQuery('expand all');
+ expect(screen.queryByTestId('command-palette-expand-all-tree')).not.toBeNull();
+
+ await setQuery('new terminal');
+ expect(screen.queryByTestId('command-palette-new-terminal')).toBeNull();
+ await setQuery('check for updates');
+ expect(screen.queryByTestId('command-palette-check-for-updates')).toBeNull();
+ await setQuery('rename');
+ expect(screen.queryByTestId('command-palette-rename')).toBeNull();
+ });
+
+ test('AC5: the sidebar toggle label reflects view-menu-state', async () => {
+ setViewMenuState({ sidebarVisible: true });
+ await renderPalette({ bridge: createBridge() });
+ await setQuery('sidebar');
+ expect(screen.getByTestId('command-palette-toggle-sidebar').textContent).toContain(
+ 'Hide sidebar',
+ );
+
+ cleanup();
+ setViewMenuState({ sidebarVisible: false });
+ await renderPalette({ bridge: createBridge() });
+ await setQuery('sidebar');
+ expect(screen.getByTestId('command-palette-toggle-sidebar').textContent).toContain(
+ 'Show sidebar',
+ );
+ });
+
+ test('AC5: doc-panel and terminal toggle labels reflect view-menu-state', async () => {
+ setViewMenuState({ docPanelVisible: true, terminalVisible: true });
+ await renderPalette({ bridge: createBridge() });
+ await setQuery('document panel');
+ expect(screen.getByTestId('command-palette-toggle-doc-panel').textContent).toContain(
+ 'Hide document panel',
+ );
+ await setQuery('hide terminal');
+ expect(screen.getByTestId('command-palette-toggle-terminal').textContent).toContain(
+ 'Hide Terminal',
+ );
+
+ cleanup();
+ setViewMenuState({ docPanelVisible: false, terminalVisible: false });
+ await renderPalette({ bridge: createBridge() });
+ await setQuery('document panel');
+ expect(screen.getByTestId('command-palette-toggle-doc-panel').textContent).toContain(
+ 'Show document panel',
+ );
+ await setQuery('show terminal');
+ expect(screen.getByTestId('command-palette-toggle-terminal').textContent).toContain(
+ 'Show Terminal',
+ );
+ });
+
+ test('AC5: kill-terminal is search-visible only when a terminal is live', async () => {
+ setViewMenuState({ terminalLive: true });
+ await renderPalette({ bridge: createBridge() });
+ await setQuery('kill terminal');
+ expect(screen.queryByTestId('command-palette-kill-terminal')).not.toBeNull();
+
+ cleanup();
+ setViewMenuState({ terminalLive: false });
+ await renderPalette({ bridge: createBridge() });
+ await setQuery('kill terminal');
+ expect(screen.queryByTestId('command-palette-kill-terminal')).toBeNull();
+ });
+
+ test('AC6: the spell-check row toggles via the bridge invoke, not view-menu-state', async () => {
+ const { bridge } = await renderPalette({ bridge: createBridge() });
+ await setQuery('check spelling while typing');
+ fireEvent.click(screen.getByTestId('command-palette-toggle-spell-check'));
+ await waitFor(() => expect(bridge?.spellcheck.toggle).toHaveBeenCalledTimes(1));
+ });
+
+ test('AC7: contextual commands gate on the active editor target', async () => {
+ // Missing target → no contextual rows.
+ await renderPalette({ bridge: createBridge(), docName: null });
+ await setQuery('rename');
+ expect(screen.queryByTestId('command-palette-rename')).toBeNull();
+ await setQuery('duplicate');
+ expect(screen.queryByTestId('command-palette-duplicate')).toBeNull();
+
+ // Doc target → rename + duplicate present.
+ cleanup();
+ await renderPalette({ bridge: createBridge(), docName: 'docs/thing' });
+ await setQuery('rename');
+ expect(screen.queryByTestId('command-palette-rename')).not.toBeNull();
+ await setQuery('duplicate');
+ expect(screen.queryByTestId('command-palette-duplicate')).not.toBeNull();
+
+ // Asset-like target → duplicate hidden, rename still present (target-kind projection).
+ // Clear the query first so re-typing 'duplicate' is a real value change that forces
+ // a re-render after the activeTarget mutation (a same-value setState would bail).
+ activeTarget = { kind: 'asset', assetPath: 'img.png' };
+ await setQuery('');
+ await setQuery('duplicate');
+ expect(screen.queryByTestId('command-palette-duplicate')).toBeNull();
+ await setQuery('rename');
+ expect(screen.queryByTestId('command-palette-rename')).not.toBeNull();
+
+ // Folder target → duplicate re-enabled (the doc-OR-folder availability
+ // clause), so narrowing the gate to doc-only turns this red.
+ activeTarget = { kind: 'folder', folderPath: 'docs/notes' };
+ await setQuery('');
+ await setQuery('duplicate');
+ expect(screen.queryByTestId('command-palette-duplicate')).not.toBeNull();
+ });
+
+ test('AC8: backfilled rows do not render on empty open', async () => {
+ await renderPalette({ bridge: createBridge() });
+ // No query typed — the backfill long-tail is search-only.
+ expect(screen.queryByTestId('command-palette-rename')).toBeNull();
+ expect(screen.queryByTestId('command-palette-toggle-sidebar')).toBeNull();
+ expect(screen.queryByTestId('command-palette-check-for-updates')).toBeNull();
+ expect(screen.queryByTestId('command-palette-new-terminal')).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx
index 708f6be5..89df21a7 100644
--- a/packages/app/src/components/CommandPalette.tsx
+++ b/packages/app/src/components/CommandPalette.tsx
@@ -8,29 +8,48 @@
* bridge is available.
*/
-import { SHOW_INSTALL_SKILL, type WorktreeSelectorEntry } from '@inkeep/open-knowledge-core';
+import {
+ OPEN_KNOWLEDGE_GITHUB_URL,
+ SHOW_INSTALL_SKILL,
+ type WorktreeSelectorEntry,
+} from '@inkeep/open-knowledge-core';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import {
+ Blocks,
Bug,
+ Check,
+ Copy,
Download,
+ Eye,
FilePlus2,
FileText,
Folder,
FolderOpen,
FolderPlus,
+ FoldVertical,
GitBranch,
Hash,
LayoutGrid,
Loader2,
Network,
Package,
+ PanelLeft,
+ PanelRight,
+ Pencil,
Plus,
+ RefreshCw,
Settings,
Sparkles,
+ SpellCheck,
+ SquareTerminal,
+ Trash2,
+ UnfoldVertical,
+ X,
} from 'lucide-react';
import {
type Dispatch,
type KeyboardEvent as ReactKeyboardEvent,
+ type ReactNode,
type SetStateAction,
useDeferredValue,
useEffect,
@@ -69,7 +88,9 @@ import {
import { requestDocPanelTab } from '@/components/doc-panel-events';
import { FileEntryIcon } from '@/components/file-entry-icon';
import { defaultInitialDir } from '@/components/file-tree-utils';
+import { GithubIcon } from '@/components/icons/github';
import { NewItemDialog } from '@/components/NewItemDialog';
+import type { ResolvedNavigationTarget } from '@/components/navigation-targets';
import { usePageList } from '@/components/PageListContext';
import { ReportBugDialog } from '@/components/ReportBugDialog';
import { SeedDialog } from '@/components/SeedDialog';
@@ -87,15 +108,17 @@ import type { TagSummaryEntry } from '@/editor/extensions/tag-suggestion';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useSemanticSearchStatus } from '@/hooks/use-semantic-search-status';
import { useWorktrees } from '@/hooks/use-worktrees';
-import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types';
+import type { OkDesktopBridge, OkMenuAction, RecentProjectEntry } from '@/lib/desktop-bridge-types';
import { hashFromDocName } from '@/lib/doc-hash';
import { runWithToast as runWithToastBase } from '@/lib/error-state';
import { VISIBLE_TARGETS } from '@/lib/handoff/targets';
import { formatShortcut, matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts';
+import { emitLocalMenuAction } from '@/lib/local-menu-action-bus';
import { useSingleFileMode } from '@/lib/single-file-mode';
import { SETTINGS_OPEN_HASH } from '@/lib/use-settings-route';
import { useWorkspace } from '@/lib/use-workspace';
import { cn } from '@/lib/utils.ts';
+import { useViewMenuState } from '@/lib/view-menu-state-store';
import { refreshWorktrees } from '@/lib/worktree-store';
import { buildHandoffInput, useHandoffDispatch } from './handoff/useHandoffDispatch';
import { useInstalledAgents } from './handoff/useInstalledAgents';
@@ -129,6 +152,53 @@ interface CommandPaletteProps {
onOpenChange: Dispatch>;
}
+/**
+ * A backfilled Cmd+K command. Rendered search-only so the empty-
+ * open state is unchanged. `run` dispatches through the smallest existing seam —
+ * the local menu-action bus (id-backed), a bridge invoke (main-side), or a
+ * direct renderer call — so no handler is re-implemented here.
+ */
+interface BackfillCommandRow {
+ testid: string;
+ label: string;
+ keywords: string[];
+ icon: ReactNode;
+ group: 'file' | 'view' | 'terminal' | 'app';
+ available: boolean;
+ /** Trailing check indicator for checkbox-style View toggles (state reflection). */
+ checked?: boolean;
+ /** Accelerator glyphs for commands whose native menu item carries one. */
+ shortcut?: string;
+ run: () => void;
+}
+
+/**
+ * Project the palette's 7-kind {@link ResolvedNavigationTarget} onto the 4-kind
+ * gating the native File menu uses for contextual commands: doc-like and folder
+ * allow every contextual command (folder still allows Duplicate); asset-like
+ * (asset / skill-file / large-file) disables Duplicate; a missing / absent
+ * target hides them all.
+ */
+type ContextualTargetKind = 'doc' | 'folder' | 'asset' | 'none';
+function projectContextualTargetKind(
+ target: ResolvedNavigationTarget | null,
+): ContextualTargetKind {
+ if (target === null) return 'none';
+ switch (target.kind) {
+ case 'doc':
+ case 'folder-index':
+ return 'doc';
+ case 'folder':
+ return 'folder';
+ case 'asset':
+ case 'skill-file':
+ case 'large-file':
+ return 'asset';
+ case 'missing':
+ return 'none';
+ }
+}
+
function navigateToDocHash(docName: string): void {
window.location.assign(hashFromDocName(docName));
}
@@ -895,6 +965,286 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
const showTagDocsEmpty =
paletteMode.kind === 'tag-docs' && tagDocsStatus === 'success' && tagDocs.length === 0;
+ // Backfilled Cmd+K commands. Rendered search-only (each row
+ // appears only under a matching query) so the empty-open state is unchanged.
+ // Dispatch via the smallest existing seam — the local menu-action bus
+ // (id-backed), a bridge invoke (main-side), or a direct renderer call — so no
+ // handler is re-implemented here. Host-agnostic rows (view/tree toggles,
+ // new-from-template, GitHub) stay available on the web host; desktop-only rows
+ // gate on `bridge !== null`.
+ const viewMenuState = useViewMenuState();
+ const contextualTargetKind = projectContextualTargetKind(activeTarget);
+ const contextualAvailable = bridge !== null && contextualTargetKind !== 'none';
+ const runMenuAction = (action: OkMenuAction) => {
+ onOpenChange(false);
+ emitLocalMenuAction(action);
+ };
+ const openExternalUrl = (url: string) => {
+ onOpenChange(false);
+ if (bridge) {
+ void bridge.shell.openExternal(url);
+ } else {
+ window.open(url, '_blank', 'noopener,noreferrer');
+ }
+ };
+ const backfillCommands: BackfillCommandRow[] = [
+ {
+ testid: 'command-palette-new-from-template',
+ label: t`New from template`,
+ keywords: ['template', 'create', 'new'],
+ icon: ,
+ group: 'file',
+ available: !singleFile,
+ run: () => runMenuAction('new-from-template'),
+ },
+ {
+ testid: 'command-palette-rename',
+ label: t`Rename`,
+ keywords: ['rename', 'file', 'folder'],
+ icon: ,
+ group: 'file',
+ available: contextualAvailable,
+ run: () => runMenuAction('rename'),
+ },
+ {
+ testid: 'command-palette-duplicate',
+ label: t`Duplicate`,
+ keywords: ['duplicate', 'copy', 'file', 'folder'],
+ icon: ,
+ group: 'file',
+ available:
+ bridge !== null && (contextualTargetKind === 'doc' || contextualTargetKind === 'folder'),
+ shortcut: formatShortcut('file-tree-duplicate'),
+ run: () => runMenuAction('duplicate'),
+ },
+ {
+ testid: 'command-palette-move-to-trash',
+ label: t`Move to Trash`,
+ keywords: ['delete', 'trash', 'remove'],
+ icon: ,
+ group: 'file',
+ available: contextualAvailable,
+ shortcut: formatShortcut('file-tree-delete'),
+ run: () => runMenuAction('move-to-trash'),
+ },
+ {
+ testid: 'command-palette-reveal-in-finder',
+ label: t`Reveal in Finder`,
+ keywords: ['finder', 'reveal', 'show', 'file'],
+ icon: ,
+ group: 'file',
+ available: contextualAvailable,
+ run: () => runMenuAction('reveal-in-finder'),
+ },
+ {
+ testid: 'command-palette-copy-full-path',
+ label: t`Copy full path`,
+ keywords: ['copy', 'path', 'absolute', 'full'],
+ icon: ,
+ group: 'file',
+ available: contextualAvailable,
+ run: () => runMenuAction('copy-full-path'),
+ },
+ {
+ testid: 'command-palette-copy-relative-path',
+ label: t`Copy relative path`,
+ keywords: ['copy', 'path', 'relative'],
+ icon: ,
+ group: 'file',
+ available: contextualAvailable,
+ run: () => runMenuAction('copy-relative-path'),
+ },
+ {
+ testid: 'command-palette-close-tab',
+ label: t`Close tab`,
+ keywords: ['close', 'tab', 'window'],
+ icon: ,
+ group: 'file',
+ available: bridge !== null,
+ run: () => runMenuAction('close-active-tab-or-window'),
+ },
+ {
+ testid: 'command-palette-new-worktree',
+ label: t`New worktree`,
+ keywords: ['worktree', 'branch', 'new'],
+ icon: ,
+ group: 'file',
+ available: bridge !== null,
+ run: () => runMenuAction('new-worktree'),
+ },
+ {
+ testid: 'command-palette-switch-worktree',
+ label: t`Switch worktree`,
+ keywords: ['worktree', 'switch', 'branch'],
+ icon: ,
+ group: 'file',
+ available: bridge !== null,
+ run: () => runMenuAction('switch-worktree'),
+ },
+ {
+ testid: 'command-palette-toggle-sidebar',
+ label: viewMenuState.sidebarVisible ? t`Hide sidebar` : t`Show sidebar`,
+ keywords: ['sidebar', 'files', 'panel', 'toggle'],
+ icon: ,
+ group: 'view',
+ available: true,
+ shortcut: formatShortcut('toggle-files-sidebar'),
+ run: () => runMenuAction('toggle-sidebar'),
+ },
+ {
+ testid: 'command-palette-toggle-doc-panel',
+ label: viewMenuState.docPanelVisible ? t`Hide document panel` : t`Show document panel`,
+ keywords: ['document', 'panel', 'info', 'toggle'],
+ icon: ,
+ group: 'view',
+ available: true,
+ shortcut: formatShortcut('toggle-document-panel'),
+ run: () => runMenuAction('toggle-doc-panel'),
+ },
+ {
+ testid: 'command-palette-toggle-terminal',
+ label: viewMenuState.terminalVisible ? t`Hide Terminal` : t`Show Terminal`,
+ keywords: ['terminal', 'shell', 'console', 'toggle'],
+ icon: ,
+ group: 'view',
+ available: bridge !== null,
+ shortcut: formatShortcut('toggle-terminal-panel'),
+ run: () => runMenuAction('toggle-terminal'),
+ },
+ {
+ testid: 'command-palette-toggle-show-hidden-files',
+ label: t`Show hidden files`,
+ keywords: ['hidden', 'dotfiles', 'files', 'show'],
+ icon: ,
+ group: 'view',
+ available: true,
+ checked: viewMenuState.showHiddenFiles === true,
+ run: () => runMenuAction('toggle-show-hidden-files'),
+ },
+ {
+ testid: 'command-palette-toggle-show-ok-folders',
+ label: t`Show .ok folders`,
+ keywords: ['ok', 'folders', 'hidden', 'show'],
+ icon: ,
+ group: 'view',
+ available: true,
+ checked: viewMenuState.showOkFolders === true,
+ run: () => runMenuAction('toggle-show-ok-folders'),
+ },
+ {
+ testid: 'command-palette-toggle-show-only-markdown-files',
+ label: t`Show only markdown files`,
+ keywords: ['markdown', 'filter', 'files', 'only'],
+ icon: ,
+ group: 'view',
+ available: true,
+ checked: viewMenuState.showOnlyMarkdownFiles === true,
+ run: () => runMenuAction('toggle-show-only-markdown-files'),
+ },
+ {
+ testid: 'command-palette-toggle-show-skills-section',
+ label: t`Show skills section`,
+ keywords: ['skills', 'section', 'sidebar', 'show'],
+ icon: ,
+ group: 'view',
+ available: true,
+ checked: viewMenuState.showSkillsSection === true,
+ run: () => runMenuAction('toggle-show-skills-section'),
+ },
+ {
+ testid: 'command-palette-expand-all-tree',
+ label: t`Expand all`,
+ keywords: ['expand', 'tree', 'folders', 'all'],
+ icon: ,
+ group: 'view',
+ // Mirror the native menu's smart-hide (visible: canExpandAll ?? true):
+ // suppress the row when the tree is already fully expanded, so searching
+ // "expand" doesn't surface a pure no-op. Unknown (web/pre-push) stays available.
+ available: viewMenuState.canExpandAll !== false,
+ run: () => runMenuAction('expand-all-tree'),
+ },
+ {
+ testid: 'command-palette-collapse-all-tree',
+ label: t`Collapse all`,
+ keywords: ['collapse', 'tree', 'folders', 'all'],
+ icon: ,
+ group: 'view',
+ // Mirror the native menu's smart-hide (visible: canCollapseAll ?? true).
+ available: viewMenuState.canCollapseAll !== false,
+ run: () => runMenuAction('collapse-all-tree'),
+ },
+ {
+ testid: 'command-palette-new-terminal',
+ label: t`New Terminal`,
+ keywords: ['terminal', 'shell', 'new', 'tab'],
+ icon: ,
+ group: 'terminal',
+ available: bridge !== null,
+ run: () => runMenuAction('new-terminal'),
+ },
+ {
+ testid: 'command-palette-kill-terminal',
+ label: t`Kill Terminal`,
+ keywords: ['terminal', 'kill', 'close', 'session'],
+ icon: ,
+ group: 'terminal',
+ available: bridge !== null && viewMenuState.terminalLive === true,
+ run: () => runMenuAction('kill-terminal'),
+ },
+ {
+ testid: 'command-palette-check-for-updates',
+ label: t`Check for updates`,
+ keywords: ['update', 'upgrade', 'version', 'check'],
+ icon: ,
+ group: 'app',
+ available: bridge !== null,
+ run: () =>
+ runAction(async () => {
+ await bridge?.update.checkNow();
+ }),
+ },
+ {
+ testid: 'command-palette-set-up-integrations',
+ label: t`Set up OpenKnowledge integrations`,
+ keywords: ['integrations', 'mcp', 'setup', 'claude', 'configure'],
+ icon: ,
+ group: 'app',
+ available: bridge !== null,
+ run: () =>
+ runAction(async () => {
+ await bridge?.mcpWiring.reconfigure();
+ }),
+ },
+ {
+ testid: 'command-palette-toggle-spell-check',
+ label: t`Check spelling while typing`,
+ keywords: ['spell', 'spelling', 'check', 'typing'],
+ icon: ,
+ group: 'app',
+ available: bridge !== null,
+ run: () =>
+ runAction(async () => {
+ await bridge?.spellcheck.toggle();
+ }),
+ },
+ {
+ testid: 'command-palette-open-github',
+ label: t`OpenKnowledge on GitHub`,
+ keywords: ['github', 'source', 'repository', 'code'],
+ icon: ,
+ group: 'app',
+ available: true,
+ run: () => openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL),
+ },
+ ];
+ const visibleBackfillCommands = backfillCommands.filter(
+ (cmd) =>
+ cmd.available &&
+ trimmedDeferredQuery !== '' &&
+ matchesCommandQuery(cmd.label, deferredQuery, cmd.keywords),
+ );
+ const hasBackfillCommand = visibleBackfillCommands.length > 0;
+
const hasAnyResults =
inExclusiveMode ||
showRecentNavigation ||
@@ -912,7 +1262,8 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
showInstallClaudeDesktop ||
showReportBug ||
showProjectRecents ||
- showAgentGroup;
+ showAgentGroup ||
+ hasBackfillCommand;
function navigateToTagDocs(tagName: string) {
setQuery(`${TAG_QUERY_PREFIX}${tagName}`);
@@ -1674,6 +2025,38 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal
) : null}
+ {(['file', 'view', 'terminal', 'app'] as const).map((groupKey) => {
+ const rows = visibleBackfillCommands.filter((cmd) => cmd.group === groupKey);
+ if (rows.length === 0) return null;
+ const heading =
+ groupKey === 'file'
+ ? t`File`
+ : groupKey === 'view'
+ ? t`View`
+ : groupKey === 'terminal'
+ ? t`Terminal`
+ : t`Application`;
+ return (
+
+ {rows.map((cmd) => (
+
+ {cmd.icon}
+ {cmd.label}
+ {cmd.checked ? (
+
+ ) : null}
+ {cmd.shortcut ? {cmd.shortcut} : null}
+
+ ))}
+
+ );
+ })}
+
{showNavigation ? (
{visibleSearchResults.map((entry) => (
diff --git a/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx b/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx
index dc532de8..4c1f6ec9 100644
--- a/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx
+++ b/packages/app/src/components/CreateProjectMenuTrigger.dom.test.tsx
@@ -1,26 +1,27 @@
/**
* DOM mount test for CreateProjectMenuTrigger — the App-root surface that
- * opens CreateProjectDialog when main fires the `new-project` menu action
+ * opens CreateProjectDialog when the `new-project` menu action fires
* (File → New project…).
*
* Pins the user-visible contract: the dialog is closed until the menu action
* fires, opens on `new-project`, and ignores unrelated menu actions. The
- * trigger subscribes to `bridge.onMenuAction`; this test captures the
- * subscribed callback through a fake bridge and invokes it directly — the
- * same path main's `sendMenuActionToFocused('new-project')` drives over IPC.
+ * trigger subscribes to the renderer-local menu-action bus (a real menu click
+ * reaches it via main → `ok:menu-action` → the bus forwarder), so this test
+ * drives it with `emitLocalMenuAction`. The `bridge` prop is still threaded
+ * into CreateProjectDialog, so the fake bridge keeps the surface the dialog
+ * touches on open.
*
* Invocation: `bun run test:dom` from `packages/app/`.
*/
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
import { CreateProjectMenuTrigger } from './CreateProjectMenuTrigger';
-// `OkMenuAction` is module-private in desktop-bridge-types.ts; mirror just the
-// members this test fires. The trigger only branches on the literal
-// 'new-project', so an exact union is unnecessary.
-type MenuActionLike = 'new-project' | 'new-doc' | 'toggle-sidebar';
-
// Radix UI primitives (shadcn Dialog) reach for DOM globals at mount. The
// broadly-needed constructors (MutationObserver) live in the shared
// tests/dom/jsdom-preload.ts; NodeFilter (react-focus-scope) and
@@ -47,31 +48,19 @@ if (globalWithDomShims.ResizeObserver === undefined) {
const ASYNC_TIMEOUT_MS = 2000;
-interface MenuActionBridgeStub {
- bridge: OkDesktopBridge;
- /** Invoke the most recently subscribed onMenuAction callback. */
- fire(action: MenuActionLike): void;
- readonly unsubscribeCalls: number;
+// Wrap the bus emit in act so the resulting setOpen state flush is applied
+// before assertions run (mirrors fireEvent's internal act wrapping).
+function fireMenuAction(action: Parameters[0]): void {
+ act(() => emitLocalMenuAction(action));
}
/**
- * Fake bridge exposing just the surface CreateProjectMenuTrigger +
- * CreateProjectDialog touch on open: `onMenuAction` (subscription) and
- * `fs.defaultProjectsRoot` (hydrated when the dialog opens). The captured
- * callback is fired by `fire(...)` to simulate main's menu dispatch.
+ * Fake bridge exposing just the surface CreateProjectDialog touches on open:
+ * `fs.defaultProjectsRoot` + a few project/dialog stubs. `onMenuAction` is no
+ * longer read by the trigger (it listens on the bus), so it is omitted.
*/
-function makeMenuActionBridge(): MenuActionBridgeStub {
- let captured: ((action: MenuActionLike) => void) | null = null;
- let unsubscribeCalls = 0;
-
- const bridge = {
- onMenuAction: (cb: (action: MenuActionLike) => void) => {
- captured = cb;
- return () => {
- unsubscribeCalls += 1;
- captured = null;
- };
- },
+function makeBridge(): OkDesktopBridge {
+ return {
fs: {
defaultProjectsRoot: async (): Promise => '/Users/test/Projects',
},
@@ -84,20 +73,6 @@ function makeMenuActionBridge(): MenuActionBridgeStub {
openFolder: async (): Promise => null,
},
} as unknown as OkDesktopBridge;
-
- return {
- bridge,
- fire: (action) => {
- if (captured) {
- // Wrap in act so the resulting setOpen state flush is applied before
- // assertions run (mirrors fireEvent's internal act wrapping).
- act(() => captured?.(action));
- }
- },
- get unsubscribeCalls() {
- return unsubscribeCalls;
- },
- };
}
describe('CreateProjectMenuTrigger', () => {
@@ -111,21 +86,20 @@ describe('CreateProjectMenuTrigger', () => {
afterEach(() => {
cleanup();
+ __resetLocalMenuActionBusForTests();
consoleWarnSpy.mockRestore();
});
test('dialog is closed until the new-project menu action fires', () => {
- const stub = makeMenuActionBridge();
- render();
+ render();
// Radix Dialog renders nothing when closed — no portal, no testid.
expect(screen.queryByTestId('create-project-dialog') !== null).toBe(false);
});
test('new-project menu action opens CreateProjectDialog', async () => {
- const stub = makeMenuActionBridge();
- render();
+ render();
- stub.fire('new-project');
+ fireMenuAction('new-project');
await waitFor(
() => {
@@ -138,22 +112,23 @@ describe('CreateProjectMenuTrigger', () => {
});
test('unrelated menu actions do not open the dialog', async () => {
- const stub = makeMenuActionBridge();
- render();
+ render();
- stub.fire('new-doc');
- stub.fire('toggle-sidebar');
+ fireMenuAction('new-doc');
+ fireMenuAction('toggle-sidebar');
// Give any erroneous open a chance to render before asserting absence.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByTestId('create-project-dialog') !== null).toBe(false);
});
- test('unsubscribes from onMenuAction on unmount', () => {
- const stub = makeMenuActionBridge();
- const { unmount } = render();
- expect(stub.unsubscribeCalls).toBe(0);
+ test('unsubscribes from the bus on unmount', async () => {
+ const { unmount } = render();
unmount();
- expect(stub.unsubscribeCalls).toBe(1);
+
+ // After unmount the subscription is gone, so a later emit must not reopen.
+ fireMenuAction('new-project');
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByTestId('create-project-dialog') !== null).toBe(false);
});
});
diff --git a/packages/app/src/components/CreateProjectMenuTrigger.tsx b/packages/app/src/components/CreateProjectMenuTrigger.tsx
index 7278faaf..40e46a15 100644
--- a/packages/app/src/components/CreateProjectMenuTrigger.tsx
+++ b/packages/app/src/components/CreateProjectMenuTrigger.tsx
@@ -12,15 +12,16 @@
import { useEffect, useState } from 'react';
import { CreateProjectDialog } from '@/components/CreateProjectDialog';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
export function CreateProjectMenuTrigger({ bridge }: { bridge: OkDesktopBridge }) {
const [open, setOpen] = useState(false);
useEffect(() => {
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'new-project') setOpen(true);
});
- }, [bridge]);
+ }, []);
return ;
}
diff --git a/packages/app/src/components/EditorArea.tsx b/packages/app/src/components/EditorArea.tsx
index 918119c5..5d693a70 100644
--- a/packages/app/src/components/EditorArea.tsx
+++ b/packages/app/src/components/EditorArea.tsx
@@ -40,6 +40,7 @@ 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';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import { ProfilerBoundary } from '@/lib/perf';
import {
matchesShareReceiveMiss,
@@ -55,6 +56,7 @@ import {
} from '@/lib/terminal-width-store';
import { useSettingsRoute } from '@/lib/use-settings-route';
import { cn } from '@/lib/utils';
+import { setViewMenuState } from '@/lib/view-menu-state-store';
import { useSyncStatus } from '@/presence/use-sync-status';
import { BottomComposer } from './BottomComposer';
import { shouldShowBottomComposer, shouldShowFolderComposer } from './bottom-composer-gate';
@@ -660,13 +662,16 @@ function EditorAreaInner({
}, [isCollapsed]);
useEffect(() => {
+ // Mirror into the renderer store unconditionally (works on web) so the
+ // Cmd+K palette can show a state-reflecting "Show/Hide document panel"
+ // label. The bridge push below is desktop-only (drives the native menu).
+ setViewMenuState({ docPanelVisible: !isCollapsed });
if (window.okDesktop == null) return;
window.okDesktop.editor.notifyViewMenuStateChanged({ docPanelVisible: !isCollapsed });
}, [isCollapsed]);
useEffect(() => {
- if (window.okDesktop == null) return;
- return window.okDesktop.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'toggle-doc-panel') {
togglePanel();
}
diff --git a/packages/app/src/components/EditorPane.tsx b/packages/app/src/components/EditorPane.tsx
index 1c61df6e..01700006 100644
--- a/packages/app/src/components/EditorPane.tsx
+++ b/packages/app/src/components/EditorPane.tsx
@@ -13,6 +13,7 @@ import { useWorktreeAutoSyncNotice } from '@/hooks/use-worktree-autosync-notice'
import { useConfigContext } from '@/lib/config-provider';
import { resolveDefaultCli } from '@/lib/default-cli-resolver';
import { matchesKeyboardShortcut } from '@/lib/keyboard-shortcuts';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import {
getInitialTerminalDock,
type TerminalDockPosition,
@@ -21,6 +22,7 @@ import {
import { readPreferBareTerminal } from '@/lib/terminal-new-tab-store';
import { recordTerminalOpened } from '@/lib/terminal-telemetry';
import { loadStickyAgent } from '@/lib/unified-agent-store';
+import { setViewMenuState } from '@/lib/view-menu-state-store';
import { AuthModal } from './AuthModal';
import { AutoSyncOnboardingDialog } from './AutoSyncOnboardingDialog';
import { shouldShowAutoSyncOnboarding } from './auto-sync-onboarding-gate';
@@ -250,9 +252,7 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) {
// With a selection, ⌘J sends it to the terminal (reusing the active tab)
// instead of toggling.
useEffect(() => {
- const bridge = window.okDesktop;
- if (bridge == null) return;
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'toggle-terminal') {
if (sendSelectionToTerminalEvent(false)) return;
setTerminalVisible((visible) => !visible);
@@ -330,6 +330,14 @@ export function EditorPane({ onOpenSearch }: EditorPaneProps = {}) {
useEffect(() => {
if (window.okDesktop == null) return;
if (!dockRestoreSettled) return;
+ // Mirror into the renderer store so the Cmd+K palette can show a
+ // state-reflecting "Show/Hide terminal" label (bridge push is main-only).
+ // Deliberately behind BOTH gates, unlike the unconditional sibling mirrors
+ // in FileSidebar / EditorArea: publishing the mount-initial `false` before
+ // the restore settles would flash a wrong palette label on a window whose
+ // dock is about to re-expand, and the web host has no terminal (and never
+ // settles the gate) so there is no state to mirror.
+ setViewMenuState({ terminalVisible });
window.okDesktop.editor.notifyViewMenuStateChanged({ terminalVisible });
}, [terminalVisible, dockRestoreSettled]);
diff --git a/packages/app/src/components/FileSidebar.tsx b/packages/app/src/components/FileSidebar.tsx
index c558f43e..83a5160c 100644
--- a/packages/app/src/components/FileSidebar.tsx
+++ b/packages/app/src/components/FileSidebar.tsx
@@ -90,11 +90,13 @@ import {
emitFileTreeMenuActionRename,
} from '@/lib/file-tree-menu-action-events';
import { VISIBLE_TARGETS } from '@/lib/handoff/targets';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import { ProfilerBoundary } from '@/lib/perf';
import { scheduleClipboardWrite } from '@/lib/share/clipboard-adapter';
import { buildFolderShareInput, runShareAction } from '@/lib/share/run-share-action';
import { useWorkspace } from '@/lib/use-workspace';
import { cn } from '@/lib/utils';
+import { setViewMenuState } from '@/lib/view-menu-state-store';
interface FileSidebarProps {
onOpenSearch: () => void;
@@ -479,8 +481,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
// so every surface agrees on what counts as a no-op action.
// `sidebarVisible` flips the View → Show/Hide Sidebar label main-side.
useEffect(() => {
- if (!bridge) return;
- bridge.editor.notifyViewMenuStateChanged({
+ const snapshot = {
showHiddenFiles,
showOkFolders,
showOnlyMarkdownFiles,
@@ -488,7 +489,13 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
canExpandAll: showExpandAll,
canCollapseAll: showCollapseAll,
sidebarVisible: sidebarState === 'expanded',
- });
+ };
+ // Mirror into the renderer store unconditionally (works on web) so the
+ // Cmd+K palette can render state-reflecting toggle labels. The bridge push
+ // below is desktop-only (drives the native View menu's Show/Hide labels).
+ setViewMenuState(snapshot);
+ if (!bridge) return;
+ bridge.editor.notifyViewMenuStateChanged(snapshot);
}, [
bridge,
showHiddenFiles,
@@ -532,8 +539,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) {
// without stale-closure bugs.
// biome-ignore lint/correctness/useExhaustiveDependencies: patchSidebarVisibility is behaviorally stable — it reads only projectLocalBinding + t, both already deps; listing the helper itself would re-create the subscription every render (sibling pattern: CommandPalette's refreshSemanticStatus).
useEffect(() => {
- if (!bridge) return;
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
// Revealed `.ok` targets are read-only: the mutating picks below
// (rename / duplicate / move-to-trash) quietly no-op on them, mirroring
// the row menu's suppressed affordances. The server's reserved-path
diff --git a/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx b/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx
index 603ca275..8ccd5978 100644
--- a/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx
+++ b/packages/app/src/components/NavigatorApp.menu-action.dom.test.tsx
@@ -16,6 +16,10 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
// `next-themes` is consumed at the top of NavigatorApp; provide a stable
// stub so the test mount doesn't require a ThemeProvider.
@@ -76,8 +80,6 @@ interface NavigatorBridgeStub {
* callback; `fire(...)` invokes it the way main's menu dispatch would.
*/
function makeNavigatorBridge(): NavigatorBridgeStub {
- let captured: ((action: MenuActionLike) => void) | null = null;
-
const bridge = {
config: {
collabUrl: '',
@@ -86,12 +88,7 @@ function makeNavigatorBridge(): NavigatorBridgeStub {
projectName: 'Project Navigator',
mode: 'navigator',
},
- onMenuAction: (cb: (action: MenuActionLike) => void) => {
- captured = cb;
- return () => {
- captured = null;
- };
- },
+ onMenuAction: () => () => {},
project: {
listRecent: async () => [],
removeRecent: async () => undefined,
@@ -124,11 +121,9 @@ function makeNavigatorBridge(): NavigatorBridgeStub {
return {
bridge,
fire: (action) => {
- if (captured) {
- // Wrap in act so the resulting setOpen state flush is applied before
- // assertions run (mirrors fireEvent's internal act wrapping).
- act(() => captured?.(action));
- }
+ // Wrap in act so the resulting setOpen state flush is applied before
+ // assertions run (mirrors fireEvent's internal act wrapping).
+ act(() => emitLocalMenuAction(action));
},
};
}
@@ -147,6 +142,7 @@ describe('NavigatorApp new-project menu-action subscription', () => {
afterEach(() => {
cleanup();
+ __resetLocalMenuActionBusForTests();
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
diff --git a/packages/app/src/components/NavigatorApp.tsx b/packages/app/src/components/NavigatorApp.tsx
index 78b0debc..ebc24d16 100644
--- a/packages/app/src/components/NavigatorApp.tsx
+++ b/packages/app/src/components/NavigatorApp.tsx
@@ -31,6 +31,7 @@ import {
resolveErrorMessage,
runWithErrorStatePure as runWithErrorStatePureBase,
} from '@/lib/error-state';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import { seedClient } from '@/lib/seed-client';
import { createCloneController } from '@/lib/share/clone-controller';
import { ipcAuthQueryTransport } from '@/lib/transports/auth-query-transport';
@@ -204,12 +205,12 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) {
// Help → Report a Bug…, which here opens the report dialog scoped
// system-wide (the Navigator has no project).
useEffect(() => {
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'new-project') setCreateDialogOpen(true);
if (action === 'report-bug') setReportBugOpen(true);
if (action === 'close-active-tab-or-window') window.close();
});
- }, [bridge]);
+ }, []);
/**
* Wrap any bridge call in a visible error state. Without this the IPC
diff --git a/packages/app/src/components/ProblemsPanel.dom.test.tsx b/packages/app/src/components/ProblemsPanel.dom.test.tsx
index 5b7420eb..7a735484 100644
--- a/packages/app/src/components/ProblemsPanel.dom.test.tsx
+++ b/packages/app/src/components/ProblemsPanel.dom.test.tsx
@@ -13,14 +13,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import type { ReactNode } from 'react';
import { renderLinguiTemplate } from '@/test-utils/lingui-mock';
-mock.module('@lingui/core/macro', () => ({ t: renderLinguiTemplate }));
-mock.module('@lingui/react/macro', () => ({
+// Both lingui macro specifiers alias to ONE shim module under the vitest dom
+// config, so two mock registrations race for a single resolved module id and
+// only one factory survives. The factories must be the same superset object —
+// a specifier-shaped split (t on core, components on react) loses whichever
+// half the race drops (observed: useLingui vanishing when the core factory
+// won).
+const linguiMacroMock = {
+ t: renderLinguiTemplate,
Trans: ({ children }: { children: ReactNode }) => children,
Plural: ({ value, one, other }: { value: number; one: string; other: string }) => (
<>{(value === 1 ? one : other).replace('#', String(value))}>
),
useLingui: () => ({ t: renderLinguiTemplate }),
-}));
+};
+mock.module('@lingui/core/macro', () => linguiMacroMock);
+mock.module('@lingui/react/macro', () => linguiMacroMock);
let auditCalls = 0;
let runLintAuditImpl: () => Promise = async () => null;
diff --git a/packages/app/src/components/ProjectSwitcher.dom.test.tsx b/packages/app/src/components/ProjectSwitcher.dom.test.tsx
index 42a5dcbe..dfd60036 100644
--- a/packages/app/src/components/ProjectSwitcher.dom.test.tsx
+++ b/packages/app/src/components/ProjectSwitcher.dom.test.tsx
@@ -1,6 +1,10 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { createContext, type ReactNode, use } from 'react';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
type MenuProps = {
children?: ReactNode;
@@ -242,17 +246,10 @@ function createBridge() {
list: mock(() => Promise.resolve({ ok: false as const, reason: 'no-git' as const })),
create: mock(() => Promise.resolve({ ok: false as const, reason: 'no-git' as const })),
},
- onMenuAction: mock((cb: (action: string) => void) => {
- menuActionCb = cb;
- return () => {
- menuActionCb = null;
- };
- }),
+ onMenuAction: mock(() => () => {}),
};
}
-let menuActionCb: ((action: string) => void) | null = null;
-
async function openMenu() {
fireEvent.click(screen.getByTestId('project-switcher-trigger'));
act(() => {
@@ -274,7 +271,7 @@ describe('ProjectSwitcher dropdown behavior', () => {
keydownBubbleCount = 0;
createDialogProps = [];
newWorktreeProps = [];
- menuActionCb = null;
+ __resetLocalMenuActionBusForTests();
(window as unknown as { okDesktop?: unknown }).okDesktop = undefined;
});
@@ -519,14 +516,13 @@ describe('ProjectSwitcher dropdown behavior', () => {
test('File menu "new-worktree" action opens the New Worktree dialog; "switch-worktree" opens the dropdown', async () => {
const bridge = createBridge();
render();
- expect(menuActionCb).not.toBeNull();
- act(() => menuActionCb?.('new-worktree'));
+ act(() => emitLocalMenuAction('new-worktree'));
await waitFor(() =>
expect(screen.getByTestId('new-worktree-dialog').getAttribute('data-open')).toBe('true'),
);
- act(() => menuActionCb?.('switch-worktree'));
+ act(() => emitLocalMenuAction('switch-worktree'));
await waitFor(() => {
expect(screen.getByTestId('project-switcher-search')).not.toBeNull();
});
diff --git a/packages/app/src/components/ProjectSwitcher.tsx b/packages/app/src/components/ProjectSwitcher.tsx
index 6425f85d..6937f55c 100644
--- a/packages/app/src/components/ProjectSwitcher.tsx
+++ b/packages/app/src/components/ProjectSwitcher.tsx
@@ -33,6 +33,7 @@ import { useCurrentBranch } from '@/hooks/use-current-branch';
import { useWorktrees } from '@/hooks/use-worktrees';
import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types';
import { runWithToast as runWithToastBase } from '@/lib/error-state';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import { cn } from '@/lib/utils';
import { CreateProjectDialog } from './CreateProjectDialog';
import { NewWorktreeDialog } from './NewWorktreeDialog';
@@ -153,7 +154,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) {
// `new-worktree` opens the create dialog; `switch-worktree` opens this
// dropdown (which lazy-loads the worktree list).
useEffect(() => {
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'new-worktree') {
setOpen(false);
setFlyoutPath(null);
@@ -163,7 +164,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) {
setOpen(true);
}
});
- }, [bridge]);
+ }, []);
const onOpenFolder = () => {
handleOpenChange(false);
diff --git a/packages/app/src/components/ReportBugDialog.dom.test.tsx b/packages/app/src/components/ReportBugDialog.dom.test.tsx
index 49b231cc..b4495cd0 100644
--- a/packages/app/src/components/ReportBugDialog.dom.test.tsx
+++ b/packages/app/src/components/ReportBugDialog.dom.test.tsx
@@ -190,7 +190,10 @@ async function renderDialog(
);
// ReportBugDialog is lazy-loaded — wait for the body chunk to resolve and
// mount before returning so callers' synchronous queries see the dialog.
- await screen.findByRole('dialog');
+ // Generous deadline: the file's first render pays the chunk's cold
+ // transform+import cost, which can exceed findByRole's 1s default on a
+ // contended CI runner (only the failure path ever waits this long).
+ await screen.findByRole('dialog', {}, { timeout: 15_000 });
return { openChangeCalls };
}
diff --git a/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx b/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx
index 4972fd7a..534cc1a0 100644
--- a/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx
+++ b/packages/app/src/components/ReportBugMenuTrigger.dom.test.tsx
@@ -1,26 +1,23 @@
/**
* DOM mount test for ReportBugMenuTrigger — the App-root surface that opens
- * ReportBugDialog when main fires the `report-bug` menu action
- * (Help → Report a Bug…).
+ * ReportBugDialog when the `report-bug` menu action fires (Help → Report a Bug…).
*
* Pins the user-visible contract: the dialog is closed until the menu action
- * fires, opens on `report-bug`, and ignores unrelated menu actions. The
- * trigger subscribes to `bridge.onMenuAction`; this test captures the
- * subscribed callback through a fake bridge and invokes it directly — the
- * same path main's `sendMenuActionToFocused('report-bug')` drives over IPC.
+ * fires, opens on `report-bug`, and ignores unrelated menu actions. The trigger
+ * now subscribes to the renderer-local menu-action bus (a real menu click
+ * reaches it via main → `ok:menu-action` → the bus forwarder), so this test
+ * drives it with `emitLocalMenuAction` — the same fan-out a menu click hits.
*
* Invocation: `bun run test:dom` from `packages/app/`.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
-import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
import { ReportBugMenuTrigger } from './ReportBugMenuTrigger';
-// `OkMenuAction` is module-private in desktop-bridge-types.ts; mirror just the
-// members this test fires. The trigger only branches on the literal
-// 'report-bug', so an exact union is unnecessary.
-type MenuActionLike = 'report-bug' | 'new-doc' | 'toggle-sidebar';
-
// Radix UI primitives (shadcn Dialog) reach for DOM globals at mount. The
// broadly-needed constructors (MutationObserver) live in the shared
// tests/dom/jsdom-preload.ts; NodeFilter (react-focus-scope) and
@@ -47,65 +44,28 @@ if (globalWithDomShims.ResizeObserver === undefined) {
const ASYNC_TIMEOUT_MS = 2000;
-interface MenuActionBridgeStub {
- bridge: OkDesktopBridge;
- /** Invoke the most recently subscribed onMenuAction callback. */
- fire(action: MenuActionLike): void;
- readonly unsubscribeCalls: number;
-}
-
-/**
- * Fake bridge exposing just the surface ReportBugMenuTrigger touches:
- * `onMenuAction` (subscription). ReportBugDialog itself talks to
- * `window.okDesktop` only on user actions (create/send), which this test
- * never reaches — opening the compose phase needs no further bridge surface.
- */
-function makeMenuActionBridge(): MenuActionBridgeStub {
- let captured: ((action: MenuActionLike) => void) | null = null;
- let unsubscribeCalls = 0;
-
- const bridge = {
- onMenuAction: (cb: (action: MenuActionLike) => void) => {
- captured = cb;
- return () => {
- unsubscribeCalls += 1;
- captured = null;
- };
- },
- } as unknown as OkDesktopBridge;
-
- return {
- bridge,
- fire: (action) => {
- if (captured) {
- // Wrap in act so the resulting setOpen state flush is applied before
- // assertions run (mirrors fireEvent's internal act wrapping).
- act(() => captured?.(action));
- }
- },
- get unsubscribeCalls() {
- return unsubscribeCalls;
- },
- };
+// Wrap the bus emit in act so the resulting setOpen state flush is applied
+// before assertions run (mirrors fireEvent's internal act wrapping).
+function fireMenuAction(action: Parameters[0]): void {
+ act(() => emitLocalMenuAction(action));
}
describe('ReportBugMenuTrigger', () => {
afterEach(() => {
cleanup();
+ __resetLocalMenuActionBusForTests();
});
test('dialog is closed until the report-bug menu action fires', () => {
- const stub = makeMenuActionBridge();
- render();
+ render();
// Radix Dialog renders nothing when closed — no portal, no dialog role.
expect(screen.queryByRole('dialog')).toBeNull();
});
test('report-bug menu action opens ReportBugDialog', async () => {
- const stub = makeMenuActionBridge();
- render();
+ render();
- stub.fire('report-bug');
+ fireMenuAction('report-bug');
await waitFor(
() => {
@@ -118,22 +78,23 @@ describe('ReportBugMenuTrigger', () => {
});
test('unrelated menu actions do not open the dialog', async () => {
- const stub = makeMenuActionBridge();
- render();
+ render();
- stub.fire('new-doc');
- stub.fire('toggle-sidebar');
+ fireMenuAction('new-doc');
+ fireMenuAction('toggle-sidebar');
// Give any erroneous open a chance to render before asserting absence.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByRole('dialog')).toBeNull();
});
- test('unsubscribes from onMenuAction on unmount', () => {
- const stub = makeMenuActionBridge();
- const { unmount } = render();
- expect(stub.unsubscribeCalls).toBe(0);
+ test('unsubscribes from the bus on unmount', async () => {
+ const { unmount } = render();
unmount();
- expect(stub.unsubscribeCalls).toBe(1);
+
+ // After unmount the subscription is gone, so a later emit must not reopen.
+ fireMenuAction('report-bug');
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByRole('dialog')).toBeNull();
});
});
diff --git a/packages/app/src/components/ReportBugMenuTrigger.tsx b/packages/app/src/components/ReportBugMenuTrigger.tsx
index 4c7ea4e0..706e8af3 100644
--- a/packages/app/src/components/ReportBugMenuTrigger.tsx
+++ b/packages/app/src/components/ReportBugMenuTrigger.tsx
@@ -11,16 +11,16 @@
import { useEffect, useState } from 'react';
import { ReportBugDialog } from '@/components/ReportBugDialog';
-import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
-export function ReportBugMenuTrigger({ bridge }: { bridge: OkDesktopBridge }) {
+export function ReportBugMenuTrigger() {
const [open, setOpen] = useState(false);
useEffect(() => {
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'report-bug') setOpen(true);
});
- }, [bridge]);
+ }, []);
return ;
}
diff --git a/packages/app/src/components/TerminalDock.dom.test.tsx b/packages/app/src/components/TerminalDock.dom.test.tsx
index 8afacfd7..18346526 100644
--- a/packages/app/src/components/TerminalDock.dom.test.tsx
+++ b/packages/app/src/components/TerminalDock.dom.test.tsx
@@ -21,7 +21,11 @@ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/
import userEvent from '@testing-library/user-event';
import { useEffect, useRef, useState } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
-import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import type { OkDesktopBridge, OkMenuAction } from '@/lib/desktop-bridge-types';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
import type { TerminalDockPosition } from '@/lib/terminal-dock-store';
import { requestActiveTerminalInput } from './handoff/terminal-input-events';
@@ -156,7 +160,6 @@ const { TerminalSessionsHost } = await import('./TerminalSessionsHost');
const { STAGE_PASTE_SETTLE_MS } = await import('./TerminalPanel');
function makeBridge() {
- const menuHandlers: Array<(action: string) => void> = [];
const viewMenuPushes: Array<{ terminalLive?: boolean }> = [];
// Hand each session a distinct PTY id (pty-1, pty-2, …) so a close can assert
// exactly which session's PTY was reaped — the demux the dock owns.
@@ -170,13 +173,7 @@ function makeBridge() {
// existing PTY (it opens its own tab), so tests assert `input` stays unused.
const input = mock((_ptyId: string, _data: string) => {});
const bridge = {
- onMenuAction(cb: (action: string) => void) {
- menuHandlers.push(cb);
- return () => {
- const index = menuHandlers.indexOf(cb);
- if (index >= 0) menuHandlers.splice(index, 1);
- };
- },
+ onMenuAction: () => () => {},
editor: {
notifyViewMenuStateChanged(state: { terminalLive?: boolean }) {
viewMenuPushes.push(state);
@@ -190,8 +187,11 @@ function makeBridge() {
kill,
input,
viewMenuPushes,
- dispatchMenuAction(action: string) {
- for (const cb of menuHandlers) cb(action);
+ // TerminalSessionsHost now listens on the renderer-local menu-action bus
+ // (a real menu click reaches it via main → the bus forwarder), so the test
+ // drives it with emitLocalMenuAction.
+ dispatchMenuAction(action: OkMenuAction) {
+ emitLocalMenuAction(action);
},
};
}
@@ -330,9 +330,11 @@ describe('TerminalDock multi-session', () => {
panelHandle.expand.mockClear();
sharedPanelRef.current = panelHandle;
titleEmitters.clear();
+ __resetLocalMenuActionBusForTests();
});
afterEach(() => {
cleanup();
+ __resetLocalMenuActionBusForTests();
});
test('tab strip exposes the dock-toggle + collapse buttons and no drag grip', () => {
diff --git a/packages/app/src/components/TerminalSessionsHost.tsx b/packages/app/src/components/TerminalSessionsHost.tsx
index a2a4fd1e..0b1f1713 100644
--- a/packages/app/src/components/TerminalSessionsHost.tsx
+++ b/packages/app/src/components/TerminalSessionsHost.tsx
@@ -5,6 +5,7 @@ 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 { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import type { TerminalDockPosition } from '@/lib/terminal-dock-store';
import {
getInitialPreferBareTerminal,
@@ -12,6 +13,7 @@ import {
} from '@/lib/terminal-new-tab-store';
import { loadStickyAgent, saveStickyAgent, terminalCliId } from '@/lib/unified-agent-store';
import { cn } from '@/lib/utils';
+import { setViewMenuState } from '@/lib/view-menu-state-store';
import type { TerminalLaunchIntent } from './EditorPane';
import { visibleTerminalClis } from './handoff/terminal-cli-display';
import { subscribeToActiveTerminalInput } from './handoff/terminal-input-events';
@@ -577,13 +579,13 @@ export function TerminalSessionsHost({
// editor window the doc tree owns ⌘W (DocumentContext closes the active doc
// tab); handling it here too would double-close.
useEffect(() => {
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action === 'new-terminal') openSessionRef.current(null);
else if (action === 'kill-terminal') closeActiveRef.current();
else if (action === 'close-active-tab-or-window' && variant === 'window')
closeActiveRef.current();
});
- }, [bridge, variant]);
+ }, [variant]);
// ⌘1–⌘9 jump straight to the Nth tab. Capture phase so a focused xterm can't
// swallow the chord; scoped to focus inside the stable host div (which follows
@@ -654,7 +656,11 @@ export function TerminalSessionsHost({
// enables only while at least one session is live. A collapsed-but-alive
// terminal still counts: the session list tracks the latch, not visibility.
useEffect(() => {
- bridge.editor.notifyViewMenuStateChanged({ terminalLive: sessions.length > 0 });
+ const terminalLive = sessions.length > 0;
+ // Mirror into the renderer store so the Cmd+K palette can gate "Kill
+ // terminal" on a live session (the bridge push below is main-only).
+ setViewMenuState({ terminalLive });
+ bridge.editor.notifyViewMenuStateChanged({ terminalLive });
}, [bridge, sessions.length]);
useEffect(() => {
diff --git a/packages/app/src/components/TerminalWindowApp.dom.test.tsx b/packages/app/src/components/TerminalWindowApp.dom.test.tsx
index 0c16d946..b94401ff 100644
--- a/packages/app/src/components/TerminalWindowApp.dom.test.tsx
+++ b/packages/app/src/components/TerminalWindowApp.dom.test.tsx
@@ -18,7 +18,11 @@ import { act, cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect, useRef } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
-import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
+import type { OkDesktopBridge, OkMenuAction } from '@/lib/desktop-bridge-types';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+} from '@/lib/local-menu-action-bus';
mock.module('./TerminalGate', () => ({
// biome-ignore lint/suspicious/noExplicitAny: test stub
@@ -52,7 +56,6 @@ const { TerminalWindowApp } = await import('./TerminalWindowApp');
const { selectDesktopRootApp } = await import('./desktop-root-app');
function makeBridge() {
- const menuHandlers: Array<(action: string) => void> = [];
const viewMenuPushes: Array<{ terminalLive?: boolean }> = [];
let ptyCounter = 0;
const create = mock(async () => {
@@ -62,13 +65,7 @@ function makeBridge() {
const kill = mock(async (_id: string) => {});
const bridge = {
config: { mode: 'terminal' },
- onMenuAction(cb: (action: string) => void) {
- menuHandlers.push(cb);
- return () => {
- const index = menuHandlers.indexOf(cb);
- if (index >= 0) menuHandlers.splice(index, 1);
- };
- },
+ onMenuAction: () => () => {},
editor: {
notifyViewMenuStateChanged(state: { terminalLive?: boolean }) {
viewMenuPushes.push(state);
@@ -81,8 +78,11 @@ function makeBridge() {
create,
kill,
viewMenuPushes,
- dispatchMenuAction(action: string) {
- for (const cb of menuHandlers) cb(action);
+ // TerminalSessionsHost now listens on the renderer-local menu-action bus
+ // (a real menu click reaches it via main → the bus forwarder), so the test
+ // drives it with emitLocalMenuAction.
+ dispatchMenuAction(action: OkMenuAction) {
+ emitLocalMenuAction(action);
},
};
}
@@ -114,6 +114,7 @@ function activePanelId(): string | null {
describe('TerminalWindowApp', () => {
afterEach(() => {
cleanup();
+ __resetLocalMenuActionBusForTests();
});
test('opens with exactly one shell tab on mount', () => {
@@ -299,6 +300,7 @@ describe('selectDesktopRootApp routing', () => {
});
afterEach(() => {
cleanup();
+ __resetLocalMenuActionBusForTests();
});
test('mode=terminal selects the terminal window app', () => {
diff --git a/packages/app/src/editor/DocumentContext.dom.test.tsx b/packages/app/src/editor/DocumentContext.dom.test.tsx
index 4ad7de58..1a1f8155 100644
--- a/packages/app/src/editor/DocumentContext.dom.test.tsx
+++ b/packages/app/src/editor/DocumentContext.dom.test.tsx
@@ -4,6 +4,7 @@ import userEvent from '@testing-library/user-event';
import { type ReactNode, useLayoutEffect, useState } from 'react';
import type { OkDesktopBridge } from '@/lib/desktop-bridge-types';
import { hashFromAssetPath } from '@/lib/doc-hash';
+import { emitLocalMenuAction } from '@/lib/local-menu-action-bus';
import { assetTabId, docTabId, localTabSessionStorageKey } from './editor-tabs';
let mockCollabUrl: string | null = null;
@@ -91,7 +92,6 @@ interface DeferredSessionBridgeStub {
}
function makeEditorBridgeStub(): EditorBridgeStub {
- let captured: ((action: MenuActionLike) => void) | null = null;
const bridge = {
config: {
mode: 'editor',
@@ -100,12 +100,7 @@ function makeEditorBridgeStub(): EditorBridgeStub {
projectPath: '',
projectName: 'Test Project',
},
- onMenuAction: (cb: (action: MenuActionLike) => void) => {
- captured = cb;
- return () => {
- captured = null;
- };
- },
+ onMenuAction: () => () => {},
project: {
getSessionState: async () => ({
openTabs: [],
@@ -120,8 +115,11 @@ function makeEditorBridgeStub(): EditorBridgeStub {
return {
bridge,
+ // The editor window's close-tab handler now listens on the renderer-local
+ // menu-action bus (a real menu click reaches it via the bus forwarder), so
+ // the test drives it with emitLocalMenuAction.
fire: (action) => {
- act(() => captured?.(action));
+ act(() => emitLocalMenuAction(action));
},
};
}
diff --git a/packages/app/src/editor/DocumentContext.tsx b/packages/app/src/editor/DocumentContext.tsx
index 2cb27d53..2badc4e2 100644
--- a/packages/app/src/editor/DocumentContext.tsx
+++ b/packages/app/src/editor/DocumentContext.tsx
@@ -18,6 +18,7 @@ import {
skillFileFromHash,
} from '@/lib/doc-hash';
import { emitBranchChanged, emitDocumentsChanged } from '@/lib/documents-events';
+import { subscribeLocalMenuAction } from '@/lib/local-menu-action-bus';
import { mark } from '@/lib/perf';
import { refreshServerInfo } from '@/lib/server-info-refresh';
import { useCollabUrl } from '@/lib/use-collab-url';
@@ -1428,14 +1429,12 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
};
useEffect(() => {
- const bridge = getDesktopBridge();
- if (!bridge) return;
- return bridge.onMenuAction((action) => {
+ return subscribeLocalMenuAction((action) => {
if (action !== 'close-active-tab-or-window') return;
if (!closeActiveTabOrWindow()) window.close();
});
}, [
- // biome-ignore lint/correctness/useExhaustiveDependencies: closeActiveTabOrWindow is render-bound; re-subscribing keeps the desktop menu handler fresh for current tab state.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: closeActiveTabOrWindow is render-bound; re-subscribing keeps the menu handler fresh for current tab state.
closeActiveTabOrWindow,
]);
diff --git a/packages/app/src/lib/command-menu-parity.test-helper.ts b/packages/app/src/lib/command-menu-parity.test-helper.ts
new file mode 100644
index 00000000..c36c380c
--- /dev/null
+++ b/packages/app/src/lib/command-menu-parity.test-helper.ts
@@ -0,0 +1,67 @@
+/**
+ * Shared OkMenuAction classification for the command-palette / menu parity
+ * ratchets. Lives in a test-helper (not a `.test.ts`) so the ratchet suite
+ * (Ratchet A/B, `command-menu-parity.test.ts`) and the DOM render suite
+ * (Ratchet C, `CommandPalette.dom.test.tsx`) consume ONE source of truth. That
+ * shared list is what makes Ratchet C durable: a newly-classified palette id
+ * with no rendered row (and not on the pre-existing escape hatch) turns the DOM
+ * suite red instead of silently satisfying only the id-classification ratchets.
+ */
+
+// Ids reachable from Cmd+K after Phase 1 (backfilled or already present).
+// `send-to-ai` is palette-present via the per-target Open-with-AI group.
+export const PALETTE_COMMAND_IDS = new Set([
+ 'new-doc',
+ 'new-folder',
+ 'new-project',
+ 'new-from-template',
+ 'rename',
+ 'duplicate',
+ 'move-to-trash',
+ 'reveal-in-finder',
+ 'send-to-ai',
+ 'copy-full-path',
+ 'copy-relative-path',
+ 'toggle-sidebar',
+ 'toggle-show-hidden-files',
+ 'toggle-show-ok-folders',
+ 'toggle-show-only-markdown-files',
+ 'toggle-show-skills-section',
+ 'expand-all-tree',
+ 'collapse-all-tree',
+ 'toggle-doc-panel',
+ 'toggle-terminal',
+ 'new-terminal',
+ 'kill-terminal',
+ 'new-worktree',
+ 'switch-worktree',
+ 'close-active-tab-or-window',
+ 'report-bug',
+]);
+
+// Ids deliberately NOT palette rows — each with a stated reason.
+export const APP_RESERVED_IDS = new Map([
+ ['delete', 'sidebar Trash id, distinct from the menu move-to-trash; not separately surfaced'],
+ ['toggle-source', 'source-mode toggle owned by the editor, not a palette action today'],
+ ['save-version', 'deferred Project menu — not yet a shipped command anywhere'],
+ ['version-history', 'deferred Project menu — not yet a shipped command anywhere'],
+ ['focus-search', 'focus-routing id, not a user-facing command'],
+ ['focus-command-palette', 'focus-routing id; self-referential inside the palette'],
+]);
+
+// Palette-command ids that reach Cmd+K through a PRE-EXISTING surface rather than
+// a backfilled id-backed row, so they carry no `ID_BACKED` entry in the DOM
+// suite. Each has its own rendered palette row/group (verified below), so Ratchet
+// C treats them as covered:
+// new-doc → the pre-existing "New file" row (testid command-palette-new-file)
+// new-folder → the pre-existing "New folder" row
+// new-project → the pre-existing "New project" row
+// send-to-ai → the per-target "Open with AI" group
+// report-bug → the pre-existing "Report a Bug" row
+export const PRE_EXISTING_PALETTE_IDS = new Set([
+ 'new-doc',
+ 'new-folder',
+ 'new-project',
+ 'send-to-ai',
+ 'report-bug',
+]);
diff --git a/packages/app/src/lib/command-menu-parity.test.ts b/packages/app/src/lib/command-menu-parity.test.ts
new file mode 100644
index 00000000..f0475e4b
--- /dev/null
+++ b/packages/app/src/lib/command-menu-parity.test.ts
@@ -0,0 +1,363 @@
+/**
+ * Command-palette / menu parity ratchet (Ratchet A + B).
+ *
+ * Modeled on `attribution-sweep-coverage.test.ts`: partitions every command
+ * identity into exactly one classification and fails when something new is
+ * neither reachable from Cmd+K nor explicitly reserved with a reason. This is
+ * the durable "nothing is missed" guarantee — a new `OkMenuAction` id or a new
+ * native-menu leaf that no one classified turns this test red.
+ *
+ * Two id-spaces are swept:
+ * - Ratchet A: the `OK_MENU_ACTIONS` runtime array (its drift from the
+ * `OkMenuAction` type is a compile error in `ok-menu-actions.ts`; here we
+ * assert every id is classified palette-command vs. app-reserved).
+ * - Ratchet B: every actionable leaf parsed from `buildMenuTemplate` across
+ * both platform branches is classified palette-command / OS-role / reserved.
+ */
+
+import { describe, expect, test } from 'bun:test';
+import { readdirSync, readFileSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import { APP_RESERVED_IDS, PALETTE_COMMAND_IDS } from '@/lib/command-menu-parity.test-helper';
+import { formatShortcut, type KeyboardShortcutId } from '@/lib/keyboard-shortcuts';
+import { OK_MENU_ACTIONS } from '@/lib/ok-menu-actions';
+import { buildMenuTemplate, type MenuDeps } from '../../../desktop/src/main/menu.ts';
+
+// Derive the menu-item type from buildMenuTemplate itself, so the ratchet does
+// not take a direct `electron` dependency (the app package has none).
+type MenuTemplateItem = ReturnType[number];
+
+// ─── Ratchet A: OkMenuAction id classification ──────────────────────────────
+// The classification sets (PALETTE_COMMAND_IDS / APP_RESERVED_IDS) are shared
+// with the DOM render suite via command-menu-parity.test-helper.
+
+// ─── Ratchet B: native-menu leaf classification ─────────────────────────────
+
+// Electron `role:` items — keyboard-native / platform-standard / dev-only. This
+// allowlist is maintained by hand against Electron's role set.
+const OS_ROLE_EXEMPT = new Set([
+ 'about',
+ 'services',
+ 'hide',
+ 'hideOthers',
+ 'unhide',
+ 'quit',
+ 'undo',
+ 'redo',
+ 'cut',
+ 'copy',
+ 'paste',
+ 'selectAll',
+ 'reload',
+ 'forceReload',
+ 'toggleDevTools',
+ 'resetZoom',
+ 'zoomIn',
+ 'zoomOut',
+ 'togglefullscreen',
+ 'minimize',
+ 'zoom',
+ 'front',
+ 'close',
+]);
+
+// Actionable menu leaves that ARE palette commands (labels, ellipsis-normalized;
+// state-aware View toggles contribute both their Show/Hide variants). "Full path"
+// / "Relative path" are the Copy-path submenu leaves the palette flattens.
+const PALETTE_COMMAND_LABELS = new Set([
+ 'New file',
+ 'New folder',
+ 'New from template',
+ 'New project',
+ 'Switch project',
+ 'Open folder',
+ 'New worktree',
+ 'Switch worktree',
+ 'Duplicate',
+ 'Rename',
+ 'Move to Trash',
+ 'Reveal in Finder',
+ 'Open with AI',
+ 'Full path',
+ 'Relative path',
+ 'Set up OpenKnowledge integrations',
+ 'Close tab',
+ 'Check for updates',
+ 'Settings',
+ 'Check spelling while typing',
+ 'Show sidebar',
+ 'Hide sidebar',
+ 'Show document panel',
+ 'Hide document panel',
+ 'Show Terminal',
+ 'Hide Terminal',
+ 'Show hidden files',
+ 'Show .ok folders',
+ 'Show only markdown files',
+ 'Show skills section',
+ 'Expand all',
+ 'Collapse all',
+ 'New Terminal',
+ 'Kill Terminal',
+ 'OpenKnowledge on GitHub',
+ 'Report a Bug',
+ // Present already; not a single registry command but reachable from the palette
+ // as its own surface (Install for Claude, gated behind SHOW_INSTALL_SKILL).
+ 'Install for Claude Chat & Cowork (desktop app)',
+]);
+
+// Non-role, non-palette leaves deliberately kept out of Cmd+K — each reasoned.
+const APP_RESERVED_LABELS = new Map([
+ ['Uninstall OpenKnowledge', 'rare + destructive; deliberately not a quick-launch row'],
+ ['New Terminal Window', 'opens directly in main with no renderer handler; window management'],
+]);
+
+/** All the deps present so every conditional menu item renders. */
+function makeFullDeps(): MenuDeps {
+ const noop = () => {};
+ return {
+ appName: 'OpenKnowledge',
+ showDevToolsMenu: true,
+ dialog: {} as MenuDeps['dialog'],
+ openNavigator: noop,
+ openProject: () => Promise.resolve(),
+ getRecentProjects: () => [],
+ clearRecentProjects: noop,
+ openExternalUrl: noop,
+ reconfigureMcpWiring: noop,
+ openInstallSkillDialog: noop,
+ openSettings: noop,
+ onReportBug: noop,
+ onCheckForUpdates: noop,
+ onUninstall: noop,
+ activeTarget: { kind: 'doc', target: 'doc.md' } as MenuDeps['activeTarget'],
+ onNewFile: noop,
+ onNewFolder: noop,
+ onNewFromTemplate: noop,
+ onNewProject: noop,
+ onNewWorktree: noop,
+ onSwitchWorktree: noop,
+ onRename: noop,
+ onDuplicate: noop,
+ onMoveToTrash: noop,
+ onCloseActiveTabOrWindow: noop,
+ onRevealInFinder: noop,
+ onSendToAi: noop,
+ onCopyFullPath: noop,
+ onCopyRelativePath: noop,
+ showHiddenFilesChecked: false,
+ onToggleShowHiddenFiles: noop,
+ showOkFoldersChecked: false,
+ onToggleShowOkFolders: noop,
+ showOnlyMarkdownFilesChecked: false,
+ onToggleShowOnlyMarkdownFiles: noop,
+ showSkillsSectionChecked: false,
+ onToggleShowSkillsSection: noop,
+ sidebarVisible: true,
+ onToggleSidebar: noop,
+ docPanelVisible: true,
+ onToggleDocPanel: noop,
+ terminalVisible: true,
+ onToggleTerminal: noop,
+ onNewTerminal: noop,
+ onKillTerminal: noop,
+ onNewTerminalWindow: noop,
+ terminalLive: true,
+ canExpandAll: true,
+ canCollapseAll: true,
+ onExpandAll: noop,
+ onCollapseAll: noop,
+ spellCheckEnabled: true,
+ onToggleSpellCheck: noop,
+ };
+}
+
+interface Leaf {
+ label: string;
+ role?: string;
+ accelerator?: string;
+}
+
+/** Recurse the template, collecting actionable leaves (skip separators, disabled
+ * placeholders, and submenu parents — which contribute their children). */
+function collectLeaves(items: readonly MenuTemplateItem[], out: Leaf[]): void {
+ for (const item of items) {
+ if (item.type === 'separator') continue;
+ const sub = item.submenu;
+ if (Array.isArray(sub)) {
+ collectLeaves(sub, out);
+ continue;
+ }
+ if (item.enabled === false) continue; // disabled placeholder (e.g. "No recent projects")
+ const accelerator = typeof item.accelerator === 'string' ? item.accelerator : undefined;
+ if (item.role) {
+ out.push({
+ label: typeof item.label === 'string' ? item.label : '',
+ role: item.role,
+ accelerator,
+ });
+ continue;
+ }
+ if (typeof item.label === 'string') {
+ out.push({ label: item.label, accelerator });
+ }
+ }
+}
+
+/** Strip the trailing platform ellipsis the native menu appends at call time. */
+function normalizeLabel(label: string): string {
+ return label.replace(/…$/, '').trim();
+}
+
+function collectLeavesForPlatform(platform: NodeJS.Platform): Leaf[] {
+ const original = process.platform;
+ Object.defineProperty(process, 'platform', { value: platform, configurable: true });
+ try {
+ const leaves: Leaf[] = [];
+ collectLeaves(buildMenuTemplate(makeFullDeps()), leaves);
+ return leaves;
+ } finally {
+ Object.defineProperty(process, 'platform', { value: original, configurable: true });
+ }
+}
+
+describe('command-menu parity ratchet', () => {
+ test('Ratchet A: every OkMenuAction id is classified palette-command or app-reserved', () => {
+ const untracked = OK_MENU_ACTIONS.filter(
+ (id) => !PALETTE_COMMAND_IDS.has(id) && !APP_RESERVED_IDS.has(id),
+ );
+ expect(untracked).toEqual([]);
+ });
+
+ test('Ratchet A: every classified id is a real OkMenuAction (no stale entries)', () => {
+ const known = new Set(OK_MENU_ACTIONS);
+ const stale = [...PALETTE_COMMAND_IDS, ...APP_RESERVED_IDS.keys()].filter(
+ (id) => !known.has(id),
+ );
+ expect(stale).toEqual([]);
+ });
+
+ test('Ratchet B: every actionable menu leaf is classified across both platforms', () => {
+ const leaves = [...collectLeavesForPlatform('darwin'), ...collectLeavesForPlatform('win32')];
+ const untracked = leaves.filter((leaf) => {
+ if (leaf.role) return !OS_ROLE_EXEMPT.has(leaf.role);
+ const label = normalizeLabel(leaf.label);
+ return !PALETTE_COMMAND_LABELS.has(label) && !APP_RESERVED_LABELS.has(label);
+ });
+ expect(untracked.map((l) => l.role ?? normalizeLabel(l.label))).toEqual([]);
+ });
+
+ test('Ratchet B sanity: the sweep actually found the backfilled leaves', () => {
+ const labels = new Set(collectLeavesForPlatform('darwin').map((l) => normalizeLabel(l.label)));
+ // A few representative backfilled palette leaves must be present, or the sweep is inert.
+ // makeFullDeps sets the panels visible, so the state-aware View toggle
+ // renders its "Hide …" variant.
+ expect(labels.has('Check for updates')).toBe(true);
+ expect(labels.has('Move to Trash')).toBe(true);
+ expect(labels.has('Hide sidebar')).toBe(true);
+ expect(labels.has('New Terminal')).toBe(true);
+ });
+
+ // ─── Ratchet D: menu accelerator ↔ keyboard-shortcut registry parity ────────
+ // Commands with BOTH a native-menu accelerator AND a keyboard-shortcuts.ts
+ // binding must agree. Guards the live drift between menu.ts's hand-typed
+ // accelerators and the shortcut registry (they share no import); the id-spaces
+ // differ, so this map bridges menu label → shortcut id.
+ const MENU_SHORTCUT_PAIRS: Array<{ menuLabel: string; shortcutId: KeyboardShortcutId }> = [
+ { menuLabel: 'New file', shortcutId: 'new-item' },
+ { menuLabel: 'New folder', shortcutId: 'new-folder' },
+ { menuLabel: 'Switch project', shortcutId: 'switch-project' },
+ { menuLabel: 'Open folder', shortcutId: 'open-folder' },
+ { menuLabel: 'Duplicate', shortcutId: 'file-tree-duplicate' },
+ { menuLabel: 'Move to Trash', shortcutId: 'file-tree-delete' },
+ { menuLabel: 'Settings', shortcutId: 'settings' },
+ { menuLabel: 'Hide sidebar', shortcutId: 'toggle-files-sidebar' },
+ { menuLabel: 'Hide document panel', shortcutId: 'toggle-document-panel' },
+ { menuLabel: 'Hide Terminal', shortcutId: 'toggle-terminal-panel' },
+ ];
+
+ // Canonical token set — order-insensitive; treats Cmd/Ctrl as MOD and
+ // Delete/Backspace as one key, so the Electron accelerator string and the
+ // display-glyph binding compare equal when they mean the same chord.
+ function chordTokens(s: string): string {
+ const tokens = new Set();
+ if (/CmdOrCtrl|Cmd|Ctrl|⌘|⌃/.test(s)) tokens.add('MOD');
+ if (/Shift|⇧/.test(s)) tokens.add('SHIFT');
+ if (/Alt|Option|⌥/.test(s)) tokens.add('ALT');
+ let base = s.replace(/CmdOrCtrl|Cmd|Ctrl|Shift|Alt|Option/g, '').replace(/[⌘⌃⇧⌥+\s]/g, '');
+ if (/^(Delete|Backspace|⌫)$/i.test(base)) base = 'DEL';
+ tokens.add(`KEY:${base.toUpperCase()}`);
+ return [...tokens].sort().join(',');
+ }
+
+ test('Ratchet D: menu accelerators agree with the keyboard-shortcut registry', () => {
+ const accelByLabel = new Map();
+ for (const leaf of collectLeavesForPlatform('darwin')) {
+ if (leaf.accelerator) accelByLabel.set(normalizeLabel(leaf.label), leaf.accelerator);
+ }
+ const mismatches: Array<{ menuLabel: string; accelerator?: string; shortcut: string }> = [];
+ for (const { menuLabel, shortcutId } of MENU_SHORTCUT_PAIRS) {
+ const accelerator = accelByLabel.get(menuLabel);
+ const shortcut = formatShortcut(shortcutId, 'mac');
+ if (accelerator === undefined || chordTokens(accelerator) !== chordTokens(shortcut)) {
+ mismatches.push({ menuLabel, accelerator, shortcut });
+ }
+ }
+ expect(mismatches).toEqual([]);
+ });
+
+ // Exactly one bridge.onMenuAction listener (the bus forwarder) — no subscriber
+ // may double-fire alongside it.
+ test('FR3: the bus forwarder is the only bridge.onMenuAction listener', () => {
+ const appSrc = join(import.meta.dir, '..', '..', 'src');
+ const migrated = [
+ 'components/FileSidebar.tsx',
+ 'components/EditorArea.tsx',
+ 'components/EditorPane.tsx',
+ 'components/TerminalSessionsHost.tsx',
+ 'components/ProjectSwitcher.tsx',
+ 'components/CreateProjectMenuTrigger.tsx',
+ 'components/ReportBugMenuTrigger.tsx',
+ 'components/NavigatorApp.tsx',
+ 'editor/DocumentContext.tsx',
+ ];
+ for (const rel of migrated) {
+ const source = readFileSync(join(appSrc, rel), 'utf8');
+ // No migrated subscriber may still listen on the bridge directly (that
+ // would double-fire alongside the forwarder); each attaches to the bus.
+ expect({ file: rel, listensOnBridge: source.includes('.onMenuAction(') }).toEqual({
+ file: rel,
+ listensOnBridge: false,
+ });
+ expect(source.includes('subscribeLocalMenuAction')).toBe(true);
+ }
+ // The bus module owns exactly one bridge.onMenuAction call — the forwarder.
+ const busSource = readFileSync(join(appSrc, 'lib/local-menu-action-bus.ts'), 'utf8');
+ expect(busSource.split('.onMenuAction(').length - 1).toBe(1);
+ });
+
+ // Repo-wide guard: exactly one production `.onMenuAction(` call site — the bus
+ // forwarder. Unlike the hand-maintained `migrated` allowlist above, this walks
+ // all of src, so a NEW subscriber that listens on the bridge directly (a
+ // double-fire) turns this red without being added to any list.
+ test('FR3: exactly one production bridge.onMenuAction call site (the bus forwarder)', () => {
+ const appSrc = join(import.meta.dir, '..', '..', 'src');
+ const isTestLike = (name: string) => /\.(test|test-helper)\.[cm]?tsx?$/.test(name);
+ const callSites: string[] = [];
+ const walk = (dir: string): void => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ continue;
+ }
+ if (!/\.[cm]?tsx?$/.test(entry.name) || isTestLike(entry.name)) continue;
+ if (readFileSync(full, 'utf8').includes('.onMenuAction(')) {
+ callSites.push(relative(appSrc, full));
+ }
+ }
+ };
+ walk(appSrc);
+ expect(callSites.sort()).toEqual(['lib/local-menu-action-bus.ts']);
+ });
+});
diff --git a/packages/app/src/lib/desktop-bridge-types.ts b/packages/app/src/lib/desktop-bridge-types.ts
index 93622162..0e93cae0 100644
--- a/packages/app/src/lib/desktop-bridge-types.ts
+++ b/packages/app/src/lib/desktop-bridge-types.ts
@@ -193,7 +193,7 @@ export interface OkDesktopConfig {
readonly startupTraceparent?: string;
}
-type OkMenuAction =
+export type OkMenuAction =
| 'new-doc'
| 'new-folder'
// Opens the create-new-project dialog in the focused window (a whole new
@@ -709,7 +709,7 @@ type OkEditorActiveTargetSnapshot =
* Sibling of `OkEditorActiveTargetSnapshot`. See canonical JSDoc in
* `packages/desktop/src/shared/bridge-contract.ts`.
*/
-interface OkEditorViewMenuStateSnapshot {
+export interface OkEditorViewMenuStateSnapshot {
readonly showHiddenFiles: boolean;
readonly showOkFolders: boolean;
readonly showOnlyMarkdownFiles: boolean;
@@ -1256,6 +1256,21 @@ export interface OkDesktopBridge {
signalReady(): void;
confirm(request: OkMcpWiringConfirmRequest): Promise;
skip(): Promise;
+ /**
+ * Re-arm the MCP consent dialog (File → "Set up OpenKnowledge integrations…"
+ * and the Cmd+K command). Resolves `true` when armed, `false` when
+ * unavailable (non-darwin / unpackaged / arming threw). Mirror of the
+ * canonical `bridge-contract.ts` method.
+ */
+ reconfigure(): Promise;
+ };
+ /**
+ * App-wide spell-check toggle (Edit → "Check spelling while typing" and the
+ * Cmd+K command). `toggle()` flips the flag and resolves to the new enabled
+ * state. Mirror of the canonical `bridge-contract.ts` method.
+ */
+ spellcheck: {
+ toggle(): Promise;
};
/**
* Settings → AI tools: live per-component install state + one-component
diff --git a/packages/app/src/lib/local-menu-action-bus.test.ts b/packages/app/src/lib/local-menu-action-bus.test.ts
new file mode 100644
index 00000000..7162628a
--- /dev/null
+++ b/packages/app/src/lib/local-menu-action-bus.test.ts
@@ -0,0 +1,162 @@
+import { afterEach, describe, expect, mock, test } from 'bun:test';
+import type { OkMenuAction } from './desktop-bridge-types';
+import {
+ __resetLocalMenuActionBusForTests,
+ emitLocalMenuAction,
+ subscribeLocalMenuAction,
+} from './local-menu-action-bus';
+
+describe('local menu-action bus', () => {
+ afterEach(() => {
+ __resetLocalMenuActionBusForTests();
+ });
+
+ test('emit reaches every subscriber exactly once (no double-fire)', () => {
+ const a = mock(() => {});
+ const b = mock(() => {});
+ subscribeLocalMenuAction(a);
+ subscribeLocalMenuAction(b);
+
+ emitLocalMenuAction('toggle-sidebar');
+
+ expect(a).toHaveBeenCalledTimes(1);
+ expect(b).toHaveBeenCalledTimes(1);
+ expect(a).toHaveBeenLastCalledWith('toggle-sidebar');
+ expect(b).toHaveBeenLastCalledWith('toggle-sidebar');
+ });
+
+ test('the same handler subscribed once fires once per emit', () => {
+ const handler = mock(() => {});
+ subscribeLocalMenuAction(handler);
+
+ emitLocalMenuAction('new-terminal');
+ emitLocalMenuAction('new-terminal');
+
+ expect(handler).toHaveBeenCalledTimes(2);
+ });
+
+ test('unsubscribe stops delivery to that handler only', () => {
+ const kept = mock(() => {});
+ const dropped = mock(() => {});
+ subscribeLocalMenuAction(kept);
+ const unsubscribe = subscribeLocalMenuAction(dropped);
+
+ unsubscribe();
+ emitLocalMenuAction('duplicate');
+
+ expect(kept).toHaveBeenCalledTimes(1);
+ expect(dropped).toHaveBeenCalledTimes(0);
+ });
+
+ test('a handler that unsubscribes itself mid-dispatch still lets siblings run', () => {
+ const order: string[] = [];
+ let unsub: (() => void) | null = null;
+ const first = mock(() => {
+ order.push('first');
+ unsub?.();
+ });
+ const second = mock(() => {
+ order.push('second');
+ });
+ unsub = subscribeLocalMenuAction(first);
+ subscribeLocalMenuAction(second);
+
+ emitLocalMenuAction('rename');
+
+ expect(order).toEqual(['first', 'second']);
+ expect(second).toHaveBeenCalledTimes(1);
+ });
+
+ test('a throwing subscriber does not block delivery to later subscribers', () => {
+ const originalConsoleError = console.error;
+ const errorSpy = mock(() => {});
+ console.error = errorSpy;
+ try {
+ const order: string[] = [];
+ subscribeLocalMenuAction(() => {
+ order.push('thrower');
+ throw new Error('subscriber bug');
+ });
+ subscribeLocalMenuAction(() => {
+ order.push('sibling');
+ });
+
+ expect(() => emitLocalMenuAction('rename')).not.toThrow();
+ expect(order).toEqual(['thrower', 'sibling']);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ } finally {
+ console.error = originalConsoleError;
+ }
+ });
+
+ test('emitting with no subscribers is a no-op', () => {
+ expect(() => emitLocalMenuAction('report-bug')).not.toThrow();
+ });
+});
+
+// The forwarder path reads `window.okDesktop`, absent in this non-DOM unit env.
+// These tests stub it and MUST restore `globalThis.window` afterward — a leaked
+// stub breaks unrelated non-DOM tests on Linux CI.
+describe('local menu-action bus — bridge forwarder', () => {
+ const originalWindow = globalThis.window;
+
+ function setDesktop(okDesktop: unknown): void {
+ globalThis.window = { okDesktop } as unknown as Window & typeof globalThis;
+ }
+
+ afterEach(() => {
+ __resetLocalMenuActionBusForTests();
+ if (originalWindow === undefined) {
+ delete (globalThis as { window?: unknown }).window;
+ } else {
+ globalThis.window = originalWindow;
+ }
+ });
+
+ test('a single inbound native menu action fires each handler exactly once', () => {
+ let inbound: ((action: OkMenuAction) => void) | null = null;
+ setDesktop({
+ onMenuAction: (cb: (action: OkMenuAction) => void) => {
+ inbound = cb;
+ return () => {};
+ },
+ });
+
+ const handler = mock(() => {});
+ subscribeLocalMenuAction(handler);
+ // The forwarder installed exactly one bridge listener.
+ expect(inbound).not.toBeNull();
+ // One inbound native action → exactly one handler invocation (no double-fire).
+ inbound?.('toggle-sidebar');
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenLastCalledWith('toggle-sidebar');
+ });
+
+ test('the forwarder installs once (ref-counted) and tears down when the last subscriber leaves', () => {
+ let installs = 0;
+ const unsubscribe = mock(() => {});
+ setDesktop({
+ onMenuAction: () => {
+ installs += 1;
+ return unsubscribe;
+ },
+ });
+
+ const off1 = subscribeLocalMenuAction(() => {});
+ const off2 = subscribeLocalMenuAction(() => {});
+ // One forwarder shared across subscribers, not one per subscriber.
+ expect(installs).toBe(1);
+ off1();
+ expect(unsubscribe).toHaveBeenCalledTimes(0); // one subscriber remains
+ off2();
+ expect(unsubscribe).toHaveBeenCalledTimes(1); // last out → forwarder torn down
+ });
+
+ test('a partial bridge without onMenuAction never throws; direct emits still deliver', () => {
+ setDesktop({}); // truthy-but-thin host (session-only / test stub)
+ const handler = mock(() => {});
+ expect(() => subscribeLocalMenuAction(handler)).not.toThrow();
+ emitLocalMenuAction('rename');
+ expect(handler).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/app/src/lib/local-menu-action-bus.ts b/packages/app/src/lib/local-menu-action-bus.ts
new file mode 100644
index 00000000..b60c482e
--- /dev/null
+++ b/packages/app/src/lib/local-menu-action-bus.ts
@@ -0,0 +1,100 @@
+import type { OkMenuAction } from './desktop-bridge-types';
+
+/**
+ * Renderer-local fan-out for menu actions, so both invocation surfaces converge
+ * on ONE dispatch path per window:
+ *
+ * - Native menu → main sends `ok:menu-action` → a single forwarder (installed
+ * here, ref-counted) re-emits it onto this bus.
+ * - Cmd+K command palette → emits directly onto this bus for id-backed commands.
+ *
+ * Every renderer subscriber (FileSidebar, EditorArea, EditorPane, …) listens on
+ * this bus instead of `bridge.onMenuAction` directly. That decoupling is what
+ * makes host-agnostic commands (view/panel/tree toggles) reachable from the
+ * palette on the WEB host, where `window.okDesktop` is absent and no bridge
+ * listener exists: the forwarder simply never installs and the palette's direct
+ * emit still reaches every subscriber.
+ *
+ * The forwarder is the ONLY remaining `bridge.onMenuAction` listener (precedent:
+ * one inbound path). No subscriber listens on both the bridge and this bus, so a
+ * real menu click fires each handler exactly once (no double-fire).
+ */
+
+type Listener = (action: OkMenuAction) => void;
+
+const listeners = new Set();
+
+// The single `bridge.onMenuAction` forwarder. Installed lazily when the first
+// subscriber attaches and torn down when the last detaches, so the bus owns the
+// bridge wiring rather than every window root having to install it.
+let bridgeForwarderUnsubscribe: (() => void) | null = null;
+
+function ensureBridgeForwarder(): void {
+ if (bridgeForwarderUnsubscribe !== null) return;
+ const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined;
+ // Web host (or pre-preload): no bridge to forward from. The palette still
+ // emits directly onto the bus, so host-agnostic subscribers keep working.
+ // A partial bridge (session-only host, or a test stub) may not expose
+ // `onMenuAction` — guard so the forwarder never throws on a truthy-but-thin
+ // `window.okDesktop`; direct palette emits still reach every subscriber.
+ if (bridge == null || typeof bridge.onMenuAction !== 'function') return;
+ bridgeForwarderUnsubscribe = bridge.onMenuAction((action) => {
+ emitLocalMenuAction(action);
+ });
+}
+
+function teardownBridgeForwarderIfIdle(): void {
+ if (listeners.size === 0 && bridgeForwarderUnsubscribe !== null) {
+ bridgeForwarderUnsubscribe();
+ bridgeForwarderUnsubscribe = null;
+ }
+}
+
+/**
+ * Subscribe to menu actions delivered on this window's bus. Returns an
+ * unsubscribe closure with the same contract as `bridge.onMenuAction` (call it
+ * on effect cleanup / unmount), so a migrating subscriber is a drop-in swap.
+ */
+export function subscribeLocalMenuAction(cb: Listener): () => void {
+ listeners.add(cb);
+ ensureBridgeForwarder();
+ return () => {
+ listeners.delete(cb);
+ teardownBridgeForwarderIfIdle();
+ };
+}
+
+/**
+ * Fan an action out to every current subscriber synchronously. The palette
+ * calls this for id-backed commands; the inbound-IPC forwarder calls it for
+ * native menu clicks. Iterates the live subscriber set — JS Set iteration
+ * tolerates a subscriber that unsubscribes itself mid-dispatch; a subscriber
+ * must not synchronously unsubscribe a sibling from within its handler.
+ */
+export function emitLocalMenuAction(action: OkMenuAction): void {
+ // Iterate the live set: JS Set iteration tolerates a subscriber that
+ // unsubscribes itself mid-dispatch. Subscribers must not synchronously
+ // unsubscribe a sibling during their handler.
+ for (const cb of listeners) {
+ try {
+ cb(action);
+ } catch (err) {
+ // Isolate subscriber faults: this bus is the single dispatch path for
+ // native menu clicks AND palette commands, so one throwing handler must
+ // not silently starve every subscriber after it in insertion order.
+ console.error('[local-menu-action-bus] subscriber threw during dispatch:', err);
+ }
+ }
+}
+
+/**
+ * Test-only reset — clears subscribers and drops the bridge forwarder so each
+ * test starts from a clean module singleton. Not part of the runtime contract.
+ */
+export function __resetLocalMenuActionBusForTests(): void {
+ listeners.clear();
+ if (bridgeForwarderUnsubscribe !== null) {
+ bridgeForwarderUnsubscribe();
+ bridgeForwarderUnsubscribe = null;
+ }
+}
diff --git a/packages/app/src/lib/ok-menu-actions.ts b/packages/app/src/lib/ok-menu-actions.ts
new file mode 100644
index 00000000..737fe7cf
--- /dev/null
+++ b/packages/app/src/lib/ok-menu-actions.ts
@@ -0,0 +1,58 @@
+import type { OkMenuAction } from './desktop-bridge-types';
+
+/**
+ * Runtime enumeration of every {@link OkMenuAction} id. TypeScript unions are
+ * erased at runtime, so the command-parity ratchet needs this array to iterate
+ * the id-space and assert each id is classified (palette command vs. reserved).
+ *
+ * Ratchet A (drift guard): `satisfies readonly OkMenuAction[]` rejects an id
+ * here that is not in the union, and the `_MenuActionDrift` guard below rejects
+ * a union member missing from this array — so adding an `OkMenuAction` without
+ * listing it here fails to compile, and this array can never silently drift
+ * from the type.
+ */
+export const OK_MENU_ACTIONS = [
+ 'new-doc',
+ 'new-folder',
+ 'new-project',
+ 'rename',
+ 'delete',
+ 'close-active-tab-or-window',
+ 'toggle-sidebar',
+ 'toggle-source',
+ 'save-version',
+ 'version-history',
+ 'focus-search',
+ 'focus-command-palette',
+ 'new-from-template',
+ 'duplicate',
+ 'move-to-trash',
+ 'reveal-in-finder',
+ 'send-to-ai',
+ 'copy-full-path',
+ 'copy-relative-path',
+ 'toggle-show-hidden-files',
+ 'toggle-show-ok-folders',
+ 'toggle-show-only-markdown-files',
+ 'toggle-show-skills-section',
+ 'expand-all-tree',
+ 'collapse-all-tree',
+ 'toggle-doc-panel',
+ 'toggle-terminal',
+ 'new-terminal',
+ 'kill-terminal',
+ 'new-worktree',
+ 'switch-worktree',
+ 'report-bug',
+] as const satisfies readonly OkMenuAction[];
+
+// Compile-time completeness: any OkMenuAction member missing from the array
+// above resolves to a non-`never` type here, and assigning `true` to the
+// resulting tuple type fails — so the typecheck gate goes red until the id is
+// listed. (The reverse — an array entry that is not a real id — is caught by
+// the `satisfies` clause.)
+type _MenuActionDrift = Exclude;
+const _menuActionDriftGuard: [_MenuActionDrift] extends [never]
+ ? true
+ : ['OK_MENU_ACTIONS is missing an OkMenuAction id', _MenuActionDrift] = true;
+void _menuActionDriftGuard;
diff --git a/packages/app/src/lib/view-menu-state-store.dom.test.tsx b/packages/app/src/lib/view-menu-state-store.dom.test.tsx
new file mode 100644
index 00000000..0bac87da
--- /dev/null
+++ b/packages/app/src/lib/view-menu-state-store.dom.test.tsx
@@ -0,0 +1,55 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { act, cleanup, renderHook } from '@testing-library/react';
+import {
+ __resetViewMenuStateForTests,
+ setViewMenuState,
+ useViewMenuState,
+} from './view-menu-state-store';
+
+describe('view-menu-state store', () => {
+ afterEach(() => {
+ cleanup();
+ __resetViewMenuStateForTests();
+ });
+
+ test('partials from different producers merge instead of replacing the snapshot', () => {
+ const { result } = renderHook(() => useViewMenuState());
+
+ act(() => setViewMenuState({ docPanelVisible: true }));
+ act(() => setViewMenuState({ terminalVisible: false }));
+
+ expect(result.current).toEqual({ docPanelVisible: true, terminalVisible: false });
+ });
+
+ test('a later partial overwrites the same field', () => {
+ const { result } = renderHook(() => useViewMenuState());
+
+ act(() => setViewMenuState({ terminalVisible: false }));
+ act(() => setViewMenuState({ terminalVisible: true }));
+
+ expect(result.current.terminalVisible).toBe(true);
+ });
+
+ test('writes notify subscribers; the snapshot is referentially stable between writes', () => {
+ const { result, rerender } = renderHook(() => useViewMenuState());
+
+ act(() => setViewMenuState({ sidebarVisible: true }));
+ const afterWrite = result.current;
+ rerender();
+ // No write between renders → same snapshot reference (useSyncExternalStore
+ // relies on this to skip re-renders).
+ expect(result.current).toBe(afterWrite);
+
+ act(() => setViewMenuState({ sidebarVisible: false }));
+ expect(result.current).not.toBe(afterWrite);
+ expect(result.current.sidebarVisible).toBe(false);
+ });
+
+ test('the test-only reset returns the store to an empty snapshot', () => {
+ act(() => setViewMenuState({ docPanelVisible: true }));
+ __resetViewMenuStateForTests();
+
+ const { result } = renderHook(() => useViewMenuState());
+ expect(result.current).toEqual({});
+ });
+});
diff --git a/packages/app/src/lib/view-menu-state-store.ts b/packages/app/src/lib/view-menu-state-store.ts
new file mode 100644
index 00000000..c91536c2
--- /dev/null
+++ b/packages/app/src/lib/view-menu-state-store.ts
@@ -0,0 +1,58 @@
+import { useSyncExternalStore } from 'react';
+import type { OkEditorViewMenuStateSnapshot } from './desktop-bridge-types';
+
+/**
+ * Renderer-side mirror of the View-menu state that components already push to
+ * main via `bridge.editor.notifyViewMenuStateChanged(...)`. That push is
+ * write-only (main consumes it to build "Show/Hide …" menu labels); nothing in
+ * the renderer could read it back. The command palette needs the same values to
+ * render state-reflecting toggle labels and to gate `kill-terminal` on a live
+ * terminal, so each producer writes the SAME partial here alongside its bridge
+ * push. This store is that single readable snapshot.
+ *
+ * All fields are optional: producers push partials, and a field stays undefined
+ * until its producer mounts. Consumers treat undefined as "unknown / default".
+ */
+export type ViewMenuState = Partial;
+
+let state: ViewMenuState = {};
+const listeners = new Set<() => void>();
+
+/** Current snapshot. Referentially stable between `setViewMenuState` calls. */
+function getViewMenuState(): ViewMenuState {
+ return state;
+}
+
+/**
+ * Merge a partial into the snapshot and notify subscribers. Producers call this
+ * with the same object they pass to `notifyViewMenuStateChanged`, keeping the
+ * renderer mirror and the main-side menu in lockstep.
+ */
+export function setViewMenuState(partial: ViewMenuState): void {
+ state = { ...state, ...partial };
+ for (const cb of listeners) {
+ cb();
+ }
+}
+
+function subscribeViewMenuState(cb: () => void): () => void {
+ listeners.add(cb);
+ return () => {
+ listeners.delete(cb);
+ };
+}
+
+/** Test-only reset so each test starts from an empty module singleton. */
+export function __resetViewMenuStateForTests(): void {
+ state = {};
+ listeners.clear();
+}
+
+/**
+ * React binding for the palette. `getViewMenuState` returns a stable reference
+ * between updates (a new object is only created on `setViewMenuState`), so
+ * `useSyncExternalStore` re-renders exactly when a producer changes state.
+ */
+export function useViewMenuState(): ViewMenuState {
+ return useSyncExternalStore(subscribeViewMenuState, getViewMenuState, getViewMenuState);
+}
diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json
index c0c22043..bb710461 100644
--- a/packages/app/src/locales/en/messages.json
+++ b/packages/app/src/locales/en/messages.json
@@ -86,6 +86,7 @@
"0yL--g": ["Review conflicts"],
"10g8I_": ["Turn off Only markdown files"],
"12F9WL": ["(no id)"],
+ "151wvk": ["Show sidebar"],
"16D5Zj": ["Are you sure you want to delete ", ["itemName"], "? This action cannot be undone."],
"1AumWo": ["Push failed — check the server logs for details."],
"1CalO6": ["Style"],
@@ -284,6 +285,7 @@
"5Umg9i": ["Disable external link previews"],
"5WDhAk": ["Link to a page or external URL."],
"5ZB7XC": ["File drop zone"],
+ "5_j-DA": ["Kill Terminal"],
"5e1Dri": ["The menu label agents pick this template by (required)."],
"5gr_ev": [
"Becomes the document's content when someone creates a doc from this template. Type <0>",
@@ -418,6 +420,7 @@
"8PmoQF": ["Open the insert menu in an empty or active text position."],
"8RtNI0": ["Failed to load documents"],
"8TvlHF": ["Crash dump"],
+ "8Vq20-": ["Switch worktree"],
"8XQRON": ["Loading repositories"],
"8_brI5": ["Name is required"],
"8cS0nH": ["Config sharing unavailable: ", ["0"], "."],
@@ -471,6 +474,7 @@
"9oE4C3": ["Browse your repos:"],
"9sBe85": ["Follow us on"],
"9tJX8r": ["By meaning"],
+ "9xCnv8": ["Hide Terminal"],
"9zb2WA": ["Connecting"],
"A1taO8": ["Search"],
"A50vr3": ["Use lowercase letters, digits, and <0>-0> only."],
@@ -505,6 +509,7 @@
"AddR83": ["Could not create project. Try again or pick a different location."],
"AeHuzT": ["# One pattern per line.\n# Examples:\n# drafts/\n# *.draft.md\n# !keep.md"],
"AeXO77": ["Account"],
+ "AhoGwP": ["Set up OpenKnowledge integrations"],
"AiNMQ7": [
"This project lives only on this computer. Connect it to GitHub to back it up and share it with other people."
],
@@ -775,6 +780,7 @@
"GLxIKR": ["How to push manually"],
"GN6L5F": ["Filename"],
"GQDSBN": ["Layout options"],
+ "GSYWCm": ["Hide document panel"],
"GUYwCr": ["Dismiss notice"],
"GUqTzn": ["Competitor research"],
"GXf9hG": ["Cloning ", ["repoName"], "..."],
@@ -861,6 +867,7 @@
"Server-side cleanup failed for ",
["failedCount", "plural", { "one": ["#", " item"], "other": ["#", " items"] }]
],
+ "IrC12v": ["Application"],
"IreQBq": ["Repository"],
"It9ARY": ["Toggle the right document panel."],
"Ix0K8o": ["Hide external URL nodes"],
@@ -963,6 +970,7 @@
"Lcehxn": ["Save resolution"],
"LdABLa": ["Could not open ", ["target"], ". Try opening it manually."],
"LdKIHS": ["Draft a spec"],
+ "LeGeTf": ["OpenKnowledge on GitHub"],
"LhQlGR": [
"This project is running an older version of OpenKnowledge (v",
["0"],
@@ -1000,6 +1008,7 @@
],
"M5_9s2": ["Sending report"],
"M5mfAe": ["Add tag"],
+ "M6mvVi": ["Copy relative path"],
"M9597s": ["Preparing share URL..."],
"MCjC9A": ["Delete ", ["deleteDescriptorLabel"]],
"MDZSJe": ["When the agent runs its scheduled work."],
@@ -1177,6 +1186,7 @@
"PrJYSN": ["This folder isn't on GitHub yet. The link won't work until it's pushed."],
"PrhzDE": ["Pan up"],
"Pyp1NA": ["Restore to this point?"],
+ "Q1mtyd": ["Hide sidebar"],
"Q5fqMz": ["Connection error — try again"],
"Q6hhn8": ["Preferences"],
"Q7W-mC": ["Same folder as current file"],
@@ -1421,6 +1431,7 @@
"WHPvOb": ["All files are hidden by view filters."],
"WJjviG": ["What to include"],
"WL6puM": ["Path resolves outside project"],
+ "WMCwmR": ["Check for updates"],
"WMIKXK": [
"Are you sure you want to delete ",
["targetCount"],
@@ -1561,6 +1572,7 @@
"). Other files and writers are unaffected."
],
"ZBt4rI": ["Instruction for the AI"],
+ "ZBxvV3": ["New Terminal"],
"ZI8uvA": ["No problems found."],
"ZLQol-": ["You can turn this on later in <0>Settings → Sync0>."],
"ZNK2Tq": ["root"],
@@ -1582,6 +1594,7 @@
"Take a piece of writing from a rough idea to a finished draft to something you're ready to share. Good for blog posts, essays, or anything you want to move through clear stages."
],
"ZgGuA4": [["0", "plural", { "one": ["File"], "other": ["Files"] }]],
+ "ZgJVnp": ["Check spelling while typing"],
"ZhhOwV": ["Quote"],
"ZjtgI-": ["Command palette"],
"ZoOlzR": [
@@ -1833,6 +1846,7 @@
"fJgQHX": ["Failed to create file"],
"fNr-MX": ["Disable semantic search"],
"fR-Kdc": ["(project-level only)"],
+ "fSRzLo": ["Show document panel"],
"fVcm9t": [["0", "plural", { "one": ["#", " doc"], "other": ["#", " docs"] }]],
"fWi7pn": ["Initialize starter pack"],
"fWsBTs": ["Something went wrong. Please try again."],
@@ -2036,6 +2050,7 @@
"jhtG3I": [" Your selection has been restored."],
"jjtmV_": ["First file name"],
"jol95m": ["Failed to open Project Navigator."],
+ "jpctdh": ["View"],
"jtg_8-": [
"Build a knowledge wiki about the different types of flightless birds. Start with an overview page, then add cross-linked pages for each major group (ratites like ostriches, emus, and kiwis; penguins; and flightless rails), the regions they live in, and a timeline of how flightlessness evolved."
],
@@ -2337,6 +2352,7 @@
"pvnfJD": ["Dark"],
"pzutoc": ["Italic"],
"q2kXMG": ["Inline Math"],
+ "q3TWG7": ["Move to Trash"],
"q4YJl_": ["The terminal session ended (exit code ", ["0"], ")."],
"q6-JVU": ["Loading pages"],
"q7F5am": ["title"],
@@ -2378,6 +2394,7 @@
"r2huLx": ["Undo, redo, or undo selection changes through CodeMirror history."],
"r4R7AZ": ["Network error during cleanup"],
"r51jLC": ["File saved & Claude Desktop App opened"],
+ "r6ncaO": ["Close tab"],
"r871ed": [
"Your knowledge base is now on GitHub at <0>",
["ownerLogin"],
@@ -2648,6 +2665,7 @@
"xeXS4P": ["Or use a starter pack"],
"xeiujy": ["Text"],
"xhlFsx": ["Inherited — set a value here to replace it."],
+ "xi9KGG": ["Show Terminal"],
"xiv-dW": ["No files yet."],
"xloBjn": ["Relative Path"],
"xnP32l": ["No description yet — add one so agents know when to use this skill."],
diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po
index 7c34081e..62f3f255 100644
--- a/packages/app/src/locales/en/messages.po
+++ b/packages/app/src/locales/en/messages.po
@@ -999,6 +999,10 @@ msgstr "App & system info, recent app logs, and project server logs: the essenti
msgid "Append-only log of what changed."
msgstr "Append-only log of what changed."
+#: src/components/CommandPalette.tsx
+msgid "Application"
+msgstr "Application"
+
#: src/components/settings/TerminalSection.tsx
msgid "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent."
msgstr "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent."
@@ -1379,6 +1383,10 @@ msgstr "Cancelled"
msgid "Cannot upload: no document is open"
msgstr "Cannot upload: no document is open"
+#: src/components/CommandPalette.tsx
+msgid "Check for updates"
+msgstr "Check for updates"
+
#: src/components/NewWorktreeDialog.tsx
msgid "Check out remote branch"
msgstr "Check out remote branch"
@@ -1387,6 +1395,10 @@ msgstr "Check out remote branch"
msgid "Check out worktree"
msgstr "Check out worktree"
+#: src/components/CommandPalette.tsx
+msgid "Check spelling while typing"
+msgstr "Check spelling while typing"
+
#: src/components/PropertyWidgets.tsx
msgid "Checkbox"
msgstr "Checkbox"
@@ -1585,6 +1597,10 @@ msgstr "Close new tab"
msgid "Close others"
msgstr "Close others"
+#: src/components/CommandPalette.tsx
+msgid "Close tab"
+msgstr "Close tab"
+
#: src/components/TerminalRefusalNotice.tsx
msgid "Close terminal"
msgstr "Close terminal"
@@ -1642,6 +1658,7 @@ msgstr "Collapse {docName}"
msgid "Collapse {keyName}"
msgstr "Collapse {keyName}"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
msgid "Collapse all"
@@ -1937,6 +1954,7 @@ msgstr "Copy code {userCode}"
msgid "Copy command"
msgstr "Copy command"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Copy full path"
msgstr "Copy full path"
@@ -1953,6 +1971,10 @@ msgstr "Copy path"
msgid "Copy Path"
msgstr "Copy Path"
+#: src/components/CommandPalette.tsx
+msgid "Copy relative path"
+msgstr "Copy relative path"
+
#: src/lib/keyboard-shortcuts.ts
msgid "Copy selected file or folder"
msgstr "Copy selected file or folder"
@@ -2848,6 +2870,7 @@ msgstr "Drag {patternText} to reorder"
msgid "Drag item {0} to reorder"
msgstr "Drag item {0} to reorder"
+#: src/components/CommandPalette.tsx
#: src/components/FileTree.tsx
#: src/components/skill-actions.tsx
msgid "Duplicate"
@@ -3144,6 +3167,7 @@ msgstr "Expand {docName}"
msgid "Expand {keyName}"
msgstr "Expand {keyName}"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
msgid "Expand all"
@@ -3462,6 +3486,7 @@ msgstr "Fetch failed — check the server logs for details."
msgid "Fetching"
msgstr "Fetching"
+#: src/components/CommandPalette.tsx
#: src/components/settings/AiToolsSection.tsx
#: src/components/settings/ProjectAiToolsSection.tsx
#: src/components/share-metadata-rows.tsx
@@ -3865,6 +3890,10 @@ msgstr "Hide"
msgid "Hide advanced"
msgstr "Hide advanced"
+#: src/components/CommandPalette.tsx
+msgid "Hide document panel"
+msgstr "Hide document panel"
+
#: src/components/GraphPanel.tsx
msgid "Hide external URL nodes"
msgstr "Hide external URL nodes"
@@ -3909,6 +3938,14 @@ msgstr "Hide replace"
msgid "Hide selection preview"
msgstr "Hide selection preview"
+#: src/components/CommandPalette.tsx
+msgid "Hide sidebar"
+msgstr "Hide sidebar"
+
+#: src/components/CommandPalette.tsx
+msgid "Hide Terminal"
+msgstr "Hide Terminal"
+
#: src/components/FileTree.tsx
msgid "Hide this file"
msgstr "Hide this file"
@@ -4279,6 +4316,10 @@ msgstr "Key ending in {keyHint}"
msgid "Keyboard shortcuts available in the editor and workspace."
msgstr "Keyboard shortcuts available in the editor and workspace."
+#: src/components/CommandPalette.tsx
+msgid "Kill Terminal"
+msgstr "Kill Terminal"
+
#: src/editor/extensions/WikiLinkPropPanel.tsx
msgid "Label <0>(optional display text)0>"
msgstr "Label <0>(optional display text)0>"
@@ -4708,6 +4749,10 @@ msgstr "Move to the next visual-editor find result."
msgid "Move to the previous visual-editor find result."
msgstr "Move to the previous visual-editor find result."
+#: src/components/CommandPalette.tsx
+msgid "Move to Trash"
+msgstr "Move to Trash"
+
#: src/editor/extensions/JsxComponentView.tsx
msgid "Move up"
msgstr "Move up"
@@ -4864,6 +4909,7 @@ msgstr "New file from template \"{displayTitle}\" ({fileName}) in the project ro
msgid "New folder"
msgstr "New folder"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
msgid "New from template"
@@ -4903,10 +4949,15 @@ msgstr "New template"
msgid "New terminal"
msgstr "New terminal"
+#: src/components/CommandPalette.tsx
+msgid "New Terminal"
+msgstr "New Terminal"
+
#: src/lib/keyboard-shortcuts.ts
msgid "New terminal tab"
msgstr "New terminal tab"
+#: src/components/CommandPalette.tsx
#: src/components/NewWorktreeDialog.tsx
#: src/components/ProjectSwitcher.tsx
msgid "New worktree"
@@ -5595,6 +5646,10 @@ msgstr "OpenKnowledge is using a GitHub account provided by the gh CLI. There's
msgid "OpenKnowledge is using this GitHub account."
msgstr "OpenKnowledge is using this GitHub account."
+#: src/components/CommandPalette.tsx
+msgid "OpenKnowledge on GitHub"
+msgstr "OpenKnowledge on GitHub"
+
#: src/components/ReportBugDialogBody.tsx
msgid "OpenKnowledge quit unexpectedly last time."
msgstr "OpenKnowledge quit unexpectedly last time."
@@ -6259,6 +6314,7 @@ msgstr "Removes this skill from your editors."
msgid "Removing"
msgstr "Removing"
+#: src/components/CommandPalette.tsx
#: src/components/FileTree.tsx
#: src/components/skill-actions.tsx
#: src/components/SkillRenameDialog.tsx
@@ -6524,6 +6580,7 @@ msgstr "Reveal in File Explorer"
msgid "Reveal in File Explorer, {hint}"
msgstr "Reveal in File Explorer, {hint}"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
#: src/components/ReportBugDialogBody.tsx
@@ -6883,6 +6940,10 @@ msgstr "Set identity"
msgid "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository."
msgstr "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository."
+#: src/components/CommandPalette.tsx
+msgid "Set up OpenKnowledge integrations"
+msgstr "Set up OpenKnowledge integrations"
+
#: src/components/settings/SettingsDialogBody.tsx
msgid "Set up syncing"
msgstr "Set up syncing"
@@ -6979,6 +7040,7 @@ msgstr "Sharing a doc needs a GitHub repository. Create one for this project."
msgid "Show"
msgstr "Show"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Show .ok folders"
msgstr "Show .ok folders"
@@ -7005,6 +7067,10 @@ msgstr "Show advanced"
msgid "Show diagram"
msgstr "Show diagram"
+#: src/components/CommandPalette.tsx
+msgid "Show document panel"
+msgstr "Show document panel"
+
#: src/components/GraphPanel.tsx
msgid "Show external URL nodes"
msgstr "Show external URL nodes"
@@ -7025,6 +7091,7 @@ msgstr "Show Files ({sidebarShortcut})"
msgid "Show Files ({sidebarShortcutLabel})"
msgstr "Show Files ({sidebarShortcutLabel})"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/NotInSidebarIndicator.tsx
msgid "Show hidden files"
@@ -7038,6 +7105,7 @@ msgstr "Show HTML preview"
msgid "Show less"
msgstr "Show less"
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Show only markdown files"
msgstr "Show only markdown files"
@@ -7070,6 +7138,11 @@ msgstr "Show replace"
msgid "Show selection preview"
msgstr "Show selection preview"
+#: src/components/CommandPalette.tsx
+msgid "Show sidebar"
+msgstr "Show sidebar"
+
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Show skills section"
msgstr "Show skills section"
@@ -7082,6 +7155,10 @@ msgstr "Show source"
msgid "Show terminal"
msgstr "Show terminal"
+#: src/components/CommandPalette.tsx
+msgid "Show Terminal"
+msgstr "Show Terminal"
+
#: src/editor/components/Pdf.tsx
msgid "Show thumbnails"
msgstr "Show thumbnails"
@@ -7381,6 +7458,10 @@ msgstr "Switch project"
msgid "Switch to local-only refused"
msgstr "Switch to local-only refused"
+#: src/components/CommandPalette.tsx
+msgid "Switch worktree"
+msgstr "Switch worktree"
+
#: src/components/ShareBranchSwitchDialog.tsx
msgid "Switching branches"
msgstr "Switching branches"
@@ -7580,6 +7661,7 @@ msgstr "Templates available"
#. Group label above the shell-PATH row in Settings → AI tools & CLI
#. Section label above the shell-PATH toggle in the first-launch dialog
+#: src/components/CommandPalette.tsx
#: src/components/empty-state/CreatePromptComposer.tsx
#: src/components/handoff/AgentSplitButton.tsx
#: src/components/handoff/OpenInAgentContextSubmenu.tsx
@@ -8410,6 +8492,10 @@ msgstr "Value is required"
msgid "via {viaTags}"
msgstr "via {viaTags}"
+#: src/components/CommandPalette.tsx
+msgid "View"
+msgstr "View"
+
#: src/components/AssetPreview.tsx
msgid "View as text"
msgstr "View as text"
diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json
index 886b65e9..6ee04960 100644
--- a/packages/app/src/locales/pseudo/messages.json
+++ b/packages/app/src/locales/pseudo/messages.json
@@ -86,6 +86,7 @@
"0yL--g": ["Ŕēvĩēŵ ćōńƒĺĩćţś"],
"10g8I_": ["Ţũŕń ōƒƒ Ōńĺŷ ḿàŕķďōŵń ƒĩĺēś"],
"12F9WL": ["(ńō ĩď)"],
+ "151wvk": ["Śĥōŵ śĩďēƀàŕ"],
"16D5Zj": ["Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē ", ["itemName"], "? Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē."],
"1AumWo": ["Ƥũśĥ ƒàĩĺēď — ćĥēćķ ţĥē śēŕvēŕ ĺōĝś ƒōŕ ďēţàĩĺś."],
"1CalO6": ["Śţŷĺē"],
@@ -284,6 +285,7 @@
"5Umg9i": ["Ďĩśàƀĺē ēxţēŕńàĺ ĺĩńķ ƥŕēvĩēŵś"],
"5WDhAk": ["Ĺĩńķ ţō à ƥàĝē ōŕ ēxţēŕńàĺ ŨŔĹ."],
"5ZB7XC": ["Ƒĩĺē ďŕōƥ źōńē"],
+ "5_j-DA": ["Ķĩĺĺ Ţēŕḿĩńàĺ"],
"5e1Dri": ["Ţĥē ḿēńũ ĺàƀēĺ àĝēńţś ƥĩćķ ţĥĩś ţēḿƥĺàţē ƀŷ (ŕēǫũĩŕēď)."],
"5gr_ev": [
"ßēćōḿēś ţĥē ďōćũḿēńţ'ś ćōńţēńţ ŵĥēń śōḿēōńē ćŕēàţēś à ďōć ƒŕōḿ ţĥĩś ţēḿƥĺàţē. Ţŷƥē <0>",
@@ -418,6 +420,7 @@
"8PmoQF": ["Ōƥēń ţĥē ĩńśēŕţ ḿēńũ ĩń àń ēḿƥţŷ ōŕ àćţĩvē ţēxţ ƥōśĩţĩōń."],
"8RtNI0": ["Ƒàĩĺēď ţō ĺōàď ďōćũḿēńţś"],
"8TvlHF": ["Ćŕàśĥ ďũḿƥ"],
+ "8Vq20-": ["Śŵĩţćĥ ŵōŕķţŕēē"],
"8XQRON": ["Ĺōàďĩńĝ ŕēƥōśĩţōŕĩēś"],
"8_brI5": ["Ńàḿē ĩś ŕēǫũĩŕēď"],
"8cS0nH": ["Ćōńƒĩĝ śĥàŕĩńĝ ũńàvàĩĺàƀĺē: ", ["0"], "."],
@@ -471,6 +474,7 @@
"9oE4C3": ["ßŕōŵśē ŷōũŕ ŕēƥōś:"],
"9sBe85": ["Ƒōĺĺōŵ ũś ōń"],
"9tJX8r": ["ßŷ ḿēàńĩńĝ"],
+ "9xCnv8": ["Ĥĩďē Ţēŕḿĩńàĺ"],
"9zb2WA": ["Ćōńńēćţĩńĝ"],
"A1taO8": ["Śēàŕćĥ"],
"A50vr3": ["Ũśē ĺōŵēŕćàśē ĺēţţēŕś, ďĩĝĩţś, àńď <0>-0> ōńĺŷ."],
@@ -505,6 +509,7 @@
"AddR83": ["Ćōũĺď ńōţ ćŕēàţē ƥŕōĴēćţ. Ţŕŷ àĝàĩń ōŕ ƥĩćķ à ďĩƒƒēŕēńţ ĺōćàţĩōń."],
"AeHuzT": ["# Ōńē ƥàţţēŕń ƥēŕ ĺĩńē.\n# Ēxàḿƥĺēś:\n# ďŕàƒţś/\n# *.ďŕàƒţ.ḿď\n# !ķēēƥ.ḿď"],
"AeXO77": ["Àććōũńţ"],
+ "AhoGwP": ["Śēţ ũƥ ŌƥēńĶńōŵĺēďĝē ĩńţēĝŕàţĩōńś"],
"AiNMQ7": [
"Ţĥĩś ƥŕōĴēćţ ĺĩvēś ōńĺŷ ōń ţĥĩś ćōḿƥũţēŕ. Ćōńńēćţ ĩţ ţō ĜĩţĤũƀ ţō ƀàćķ ĩţ ũƥ àńď śĥàŕē ĩţ ŵĩţĥ ōţĥēŕ ƥēōƥĺē."
],
@@ -775,6 +780,7 @@
"GLxIKR": ["Ĥōŵ ţō ƥũśĥ ḿàńũàĺĺŷ"],
"GN6L5F": ["Ƒĩĺēńàḿē"],
"GQDSBN": ["Ĺàŷōũţ ōƥţĩōńś"],
+ "GSYWCm": ["Ĥĩďē ďōćũḿēńţ ƥàńēĺ"],
"GUYwCr": ["Ďĩśḿĩśś ńōţĩćē"],
"GUqTzn": ["Ćōḿƥēţĩţōŕ ŕēśēàŕćĥ"],
"GXf9hG": ["Ćĺōńĩńĝ ", ["repoName"], "..."],
@@ -861,6 +867,7 @@
"Śēŕvēŕ-śĩďē ćĺēàńũƥ ƒàĩĺēď ƒōŕ ",
["failedCount", "plural", { "one": ["#", " ĩţēḿ"], "other": ["#", " ĩţēḿś"] }]
],
+ "IrC12v": ["Àƥƥĺĩćàţĩōń"],
"IreQBq": ["Ŕēƥōśĩţōŕŷ"],
"It9ARY": ["Ţōĝĝĺē ţĥē ŕĩĝĥţ ďōćũḿēńţ ƥàńēĺ."],
"Ix0K8o": ["Ĥĩďē ēxţēŕńàĺ ŨŔĹ ńōďēś"],
@@ -963,6 +970,7 @@
"Lcehxn": ["Śàvē ŕēśōĺũţĩōń"],
"LdABLa": ["Ćōũĺď ńōţ ōƥēń ", ["target"], ". Ţŕŷ ōƥēńĩńĝ ĩţ ḿàńũàĺĺŷ."],
"LdKIHS": ["Ďŕàƒţ à śƥēć"],
+ "LeGeTf": ["ŌƥēńĶńōŵĺēďĝē ōń ĜĩţĤũƀ"],
"LhQlGR": [
"Ţĥĩś ƥŕōĴēćţ ĩś ŕũńńĩńĝ àń ōĺďēŕ vēŕśĩōń ōƒ ŌƥēńĶńōŵĺēďĝē (v",
["0"],
@@ -1000,6 +1008,7 @@
],
"M5_9s2": ["Śēńďĩńĝ ŕēƥōŕţ"],
"M5mfAe": ["Àďď ţàĝ"],
+ "M6mvVi": ["Ćōƥŷ ŕēĺàţĩvē ƥàţĥ"],
"M9597s": ["Ƥŕēƥàŕĩńĝ śĥàŕē ŨŔĹ..."],
"MCjC9A": ["Ďēĺēţē ", ["deleteDescriptorLabel"]],
"MDZSJe": ["Ŵĥēń ţĥē àĝēńţ ŕũńś ĩţś śćĥēďũĺēď ŵōŕķ."],
@@ -1177,6 +1186,7 @@
"PrJYSN": ["Ţĥĩś ƒōĺďēŕ ĩśń'ţ ōń ĜĩţĤũƀ ŷēţ. Ţĥē ĺĩńķ ŵōń'ţ ŵōŕķ ũńţĩĺ ĩţ'ś ƥũśĥēď."],
"PrhzDE": ["Ƥàń ũƥ"],
"Pyp1NA": ["Ŕēśţōŕē ţō ţĥĩś ƥōĩńţ?"],
+ "Q1mtyd": ["Ĥĩďē śĩďēƀàŕ"],
"Q5fqMz": ["Ćōńńēćţĩōń ēŕŕōŕ — ţŕŷ àĝàĩń"],
"Q6hhn8": ["Ƥŕēƒēŕēńćēś"],
"Q7W-mC": ["Śàḿē ƒōĺďēŕ àś ćũŕŕēńţ ƒĩĺē"],
@@ -1421,6 +1431,7 @@
"WHPvOb": ["Àĺĺ ƒĩĺēś àŕē ĥĩďďēń ƀŷ vĩēŵ ƒĩĺţēŕś."],
"WJjviG": ["Ŵĥàţ ţō ĩńćĺũďē"],
"WL6puM": ["Ƥàţĥ ŕēśōĺvēś ōũţśĩďē ƥŕōĴēćţ"],
+ "WMCwmR": ["Ćĥēćķ ƒōŕ ũƥďàţēś"],
"WMIKXK": [
"Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē ",
["targetCount"],
@@ -1561,6 +1572,7 @@
"). Ōţĥēŕ ƒĩĺēś àńď ŵŕĩţēŕś àŕē ũńàƒƒēćţēď."
],
"ZBt4rI": ["Ĩńśţŕũćţĩōń ƒōŕ ţĥē ÀĨ"],
+ "ZBxvV3": ["Ńēŵ Ţēŕḿĩńàĺ"],
"ZI8uvA": ["Ńō ƥŕōƀĺēḿś ƒōũńď."],
"ZLQol-": ["Ŷōũ ćàń ţũŕń ţĥĩś ōń ĺàţēŕ ĩń <0>Śēţţĩńĝś → Śŷńć0>."],
"ZNK2Tq": ["ŕōōţ"],
@@ -1582,6 +1594,7 @@
"Ţàķē à ƥĩēćē ōƒ ŵŕĩţĩńĝ ƒŕōḿ à ŕōũĝĥ ĩďēà ţō à ƒĩńĩśĥēď ďŕàƒţ ţō śōḿēţĥĩńĝ ŷōũ'ŕē ŕēàďŷ ţō śĥàŕē. Ĝōōď ƒōŕ ƀĺōĝ ƥōśţś, ēśśàŷś, ōŕ àńŷţĥĩńĝ ŷōũ ŵàńţ ţō ḿōvē ţĥŕōũĝĥ ćĺēàŕ śţàĝēś."
],
"ZgGuA4": [["0", "plural", { "one": ["Ƒĩĺē"], "other": ["Ƒĩĺēś"] }]],
+ "ZgJVnp": ["Ćĥēćķ śƥēĺĺĩńĝ ŵĥĩĺē ţŷƥĩńĝ"],
"ZhhOwV": ["Ǫũōţē"],
"ZjtgI-": ["Ćōḿḿàńď ƥàĺēţţē"],
"ZoOlzR": [
@@ -1833,6 +1846,7 @@
"fJgQHX": ["Ƒàĩĺēď ţō ćŕēàţē ƒĩĺē"],
"fNr-MX": ["Ďĩśàƀĺē śēḿàńţĩć śēàŕćĥ"],
"fR-Kdc": ["(ƥŕōĴēćţ-ĺēvēĺ ōńĺŷ)"],
+ "fSRzLo": ["Śĥōŵ ďōćũḿēńţ ƥàńēĺ"],
"fVcm9t": [["0", "plural", { "one": ["#", " ďōć"], "other": ["#", " ďōćś"] }]],
"fWi7pn": ["Ĩńĩţĩàĺĩźē śţàŕţēŕ ƥàćķ"],
"fWsBTs": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ. Ƥĺēàśē ţŕŷ àĝàĩń."],
@@ -2036,6 +2050,7 @@
"jhtG3I": [" Ŷōũŕ śēĺēćţĩōń ĥàś ƀēēń ŕēśţōŕēď."],
"jjtmV_": ["Ƒĩŕśţ ƒĩĺē ńàḿē"],
"jol95m": ["Ƒàĩĺēď ţō ōƥēń ƤŕōĴēćţ Ńàvĩĝàţōŕ."],
+ "jpctdh": ["Vĩēŵ"],
"jtg_8-": [
"ßũĩĺď à ķńōŵĺēďĝē ŵĩķĩ àƀōũţ ţĥē ďĩƒƒēŕēńţ ţŷƥēś ōƒ ƒĺĩĝĥţĺēśś ƀĩŕďś. Śţàŕţ ŵĩţĥ àń ōvēŕvĩēŵ ƥàĝē, ţĥēń àďď ćŕōśś-ĺĩńķēď ƥàĝēś ƒōŕ ēàćĥ ḿàĴōŕ ĝŕōũƥ (ŕàţĩţēś ĺĩķē ōśţŕĩćĥēś, ēḿũś, àńď ķĩŵĩś; ƥēńĝũĩńś; àńď ƒĺĩĝĥţĺēśś ŕàĩĺś), ţĥē ŕēĝĩōńś ţĥēŷ ĺĩvē ĩń, àńď à ţĩḿēĺĩńē ōƒ ĥōŵ ƒĺĩĝĥţĺēśśńēśś ēvōĺvēď."
],
@@ -2337,6 +2352,7 @@
"pvnfJD": ["Ďàŕķ"],
"pzutoc": ["Ĩţàĺĩć"],
"q2kXMG": ["Ĩńĺĩńē Ḿàţĥ"],
+ "q3TWG7": ["Ḿōvē ţō Ţŕàśĥ"],
"q4YJl_": ["Ţĥē ţēŕḿĩńàĺ śēśśĩōń ēńďēď (ēxĩţ ćōďē ", ["0"], ")."],
"q6-JVU": ["Ĺōàďĩńĝ ƥàĝēś"],
"q7F5am": ["ţĩţĺē"],
@@ -2378,6 +2394,7 @@
"r2huLx": ["Ũńďō, ŕēďō, ōŕ ũńďō śēĺēćţĩōń ćĥàńĝēś ţĥŕōũĝĥ ĆōďēḾĩŕŕōŕ ĥĩśţōŕŷ."],
"r4R7AZ": ["Ńēţŵōŕķ ēŕŕōŕ ďũŕĩńĝ ćĺēàńũƥ"],
"r51jLC": ["Ƒĩĺē śàvēď & Ćĺàũďē Ďēśķţōƥ Àƥƥ ōƥēńēď"],
+ "r6ncaO": ["Ćĺōśē ţàƀ"],
"r871ed": [
"Ŷōũŕ ķńōŵĺēďĝē ƀàśē ĩś ńōŵ ōń ĜĩţĤũƀ àţ <0>",
["ownerLogin"],
@@ -2648,6 +2665,7 @@
"xeXS4P": ["Ōŕ ũśē à śţàŕţēŕ ƥàćķ"],
"xeiujy": ["Ţēxţ"],
"xhlFsx": ["Ĩńĥēŕĩţēď — śēţ à vàĺũē ĥēŕē ţō ŕēƥĺàćē ĩţ."],
+ "xi9KGG": ["Śĥōŵ Ţēŕḿĩńàĺ"],
"xiv-dW": ["Ńō ƒĩĺēś ŷēţ."],
"xloBjn": ["Ŕēĺàţĩvē Ƥàţĥ"],
"xnP32l": ["Ńō ďēśćŕĩƥţĩōń ŷēţ — àďď ōńē śō àĝēńţś ķńōŵ ŵĥēń ţō ũśē ţĥĩś śķĩĺĺ."],
diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po
index 16dbfef1..2d0c30cd 100644
--- a/packages/app/src/locales/pseudo/messages.po
+++ b/packages/app/src/locales/pseudo/messages.po
@@ -995,6 +995,10 @@ msgstr ""
msgid "Append-only log of what changed."
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Application"
+msgstr ""
+
#: src/components/settings/TerminalSection.tsx
msgid "Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs \"ok open\"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent."
msgstr ""
@@ -1375,6 +1379,10 @@ msgstr ""
msgid "Cannot upload: no document is open"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Check for updates"
+msgstr ""
+
#: src/components/NewWorktreeDialog.tsx
msgid "Check out remote branch"
msgstr ""
@@ -1383,6 +1391,10 @@ msgstr ""
msgid "Check out worktree"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Check spelling while typing"
+msgstr ""
+
#: src/components/PropertyWidgets.tsx
msgid "Checkbox"
msgstr ""
@@ -1581,6 +1593,10 @@ msgstr ""
msgid "Close others"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Close tab"
+msgstr ""
+
#: src/components/TerminalRefusalNotice.tsx
msgid "Close terminal"
msgstr ""
@@ -1638,6 +1654,7 @@ msgstr ""
msgid "Collapse {keyName}"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
msgid "Collapse all"
@@ -1933,6 +1950,7 @@ msgstr ""
msgid "Copy command"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Copy full path"
msgstr ""
@@ -1949,6 +1967,10 @@ msgstr ""
msgid "Copy Path"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Copy relative path"
+msgstr ""
+
#: src/lib/keyboard-shortcuts.ts
msgid "Copy selected file or folder"
msgstr ""
@@ -2844,6 +2866,7 @@ msgstr ""
msgid "Drag item {0} to reorder"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileTree.tsx
#: src/components/skill-actions.tsx
msgid "Duplicate"
@@ -3140,6 +3163,7 @@ msgstr ""
msgid "Expand {keyName}"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
msgid "Expand all"
@@ -3458,6 +3482,7 @@ msgstr ""
msgid "Fetching"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/settings/AiToolsSection.tsx
#: src/components/settings/ProjectAiToolsSection.tsx
#: src/components/share-metadata-rows.tsx
@@ -3861,6 +3886,10 @@ msgstr ""
msgid "Hide advanced"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Hide document panel"
+msgstr ""
+
#: src/components/GraphPanel.tsx
msgid "Hide external URL nodes"
msgstr ""
@@ -3905,6 +3934,14 @@ msgstr ""
msgid "Hide selection preview"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Hide sidebar"
+msgstr ""
+
+#: src/components/CommandPalette.tsx
+msgid "Hide Terminal"
+msgstr ""
+
#: src/components/FileTree.tsx
msgid "Hide this file"
msgstr ""
@@ -4275,6 +4312,10 @@ msgstr ""
msgid "Keyboard shortcuts available in the editor and workspace."
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Kill Terminal"
+msgstr ""
+
#: src/editor/extensions/WikiLinkPropPanel.tsx
msgid "Label <0>(optional display text)0>"
msgstr ""
@@ -4704,6 +4745,10 @@ msgstr ""
msgid "Move to the previous visual-editor find result."
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Move to Trash"
+msgstr ""
+
#: src/editor/extensions/JsxComponentView.tsx
msgid "Move up"
msgstr ""
@@ -4860,6 +4905,7 @@ msgstr ""
msgid "New folder"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
msgid "New from template"
@@ -4899,10 +4945,15 @@ msgstr ""
msgid "New terminal"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "New Terminal"
+msgstr ""
+
#: src/lib/keyboard-shortcuts.ts
msgid "New terminal tab"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/NewWorktreeDialog.tsx
#: src/components/ProjectSwitcher.tsx
msgid "New worktree"
@@ -5591,6 +5642,10 @@ msgstr ""
msgid "OpenKnowledge is using this GitHub account."
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "OpenKnowledge on GitHub"
+msgstr ""
+
#: src/components/ReportBugDialogBody.tsx
msgid "OpenKnowledge quit unexpectedly last time."
msgstr ""
@@ -6255,6 +6310,7 @@ msgstr ""
msgid "Removing"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileTree.tsx
#: src/components/skill-actions.tsx
#: src/components/SkillRenameDialog.tsx
@@ -6520,6 +6576,7 @@ msgstr ""
msgid "Reveal in File Explorer, {hint}"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/FileTree.tsx
#: src/components/ReportBugDialogBody.tsx
@@ -6879,6 +6936,10 @@ msgstr ""
msgid "Set the auto-sync default for users opening this project for the first time. This setting is committed to your repository."
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Set up OpenKnowledge integrations"
+msgstr ""
+
#: src/components/settings/SettingsDialogBody.tsx
msgid "Set up syncing"
msgstr ""
@@ -6975,6 +7036,7 @@ msgstr ""
msgid "Show"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Show .ok folders"
msgstr ""
@@ -7001,6 +7063,10 @@ msgstr ""
msgid "Show diagram"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Show document panel"
+msgstr ""
+
#: src/components/GraphPanel.tsx
msgid "Show external URL nodes"
msgstr ""
@@ -7021,6 +7087,7 @@ msgstr ""
msgid "Show Files ({sidebarShortcutLabel})"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
#: src/components/NotInSidebarIndicator.tsx
msgid "Show hidden files"
@@ -7034,6 +7101,7 @@ msgstr ""
msgid "Show less"
msgstr ""
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Show only markdown files"
msgstr ""
@@ -7066,6 +7134,11 @@ msgstr ""
msgid "Show selection preview"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Show sidebar"
+msgstr ""
+
+#: src/components/CommandPalette.tsx
#: src/components/FileSidebar.tsx
msgid "Show skills section"
msgstr ""
@@ -7078,6 +7151,10 @@ msgstr ""
msgid "Show terminal"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Show Terminal"
+msgstr ""
+
#: src/editor/components/Pdf.tsx
msgid "Show thumbnails"
msgstr ""
@@ -7377,6 +7454,10 @@ msgstr ""
msgid "Switch to local-only refused"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "Switch worktree"
+msgstr ""
+
#: src/components/ShareBranchSwitchDialog.tsx
msgid "Switching branches"
msgstr ""
@@ -7576,6 +7657,7 @@ msgstr ""
#. Group label above the shell-PATH row in Settings → AI tools & CLI
#. Section label above the shell-PATH toggle in the first-launch dialog
+#: src/components/CommandPalette.tsx
#: src/components/empty-state/CreatePromptComposer.tsx
#: src/components/handoff/AgentSplitButton.tsx
#: src/components/handoff/OpenInAgentContextSubmenu.tsx
@@ -8406,6 +8488,10 @@ msgstr ""
msgid "via {viaTags}"
msgstr ""
+#: src/components/CommandPalette.tsx
+msgid "View"
+msgstr ""
+
#: src/components/AssetPreview.tsx
msgid "View as text"
msgstr ""
diff --git a/packages/app/tests/stress/command-palette-parity.e2e.ts b/packages/app/tests/stress/command-palette-parity.e2e.ts
new file mode 100644
index 00000000..298e265c
--- /dev/null
+++ b/packages/app/tests/stress/command-palette-parity.e2e.ts
@@ -0,0 +1,64 @@
+/**
+ * Real-cmdk smoke for the Cmd+K menu-parity backfill.
+ *
+ * The unit/DOM tests mock cmdk (they assert the registry-driven branch emits a
+ * row and wires its dispatch). This test closes that fidelity gap against the
+ * REAL palette in a running app: it proves a backfilled host-agnostic command
+ * (Show/Hide sidebar) is search-only, renders under a matching query in the
+ * real cmdk list, and — when activated — dispatches through the local
+ * menu-action bus to the real FileSidebar handler, which flips the sidebar and
+ * therefore the state-reflecting label on the next open.
+ *
+ * Runs on the web host (dev server, no desktop bridge), so it exercises exactly
+ * the host-agnostic path that Phase 1 makes reachable on web.
+ */
+
+import { expect, test } from './_helpers';
+
+const SEED_DOCS = [{ name: 'note', markdown: '# note\n\nHello there.\n' }];
+
+async function openPalette(page: import('@playwright/test').Page) {
+ // `ControlOrMeta+k` picks Meta on darwin, Control elsewhere — matches the
+ // in-app handler's `isMacOS() ? metaKey : ctrlKey`.
+ await page.keyboard.press('ControlOrMeta+k');
+ const list = page.locator('[data-slot="command-list"]');
+ await expect(list).toBeVisible({ timeout: 5_000 });
+ await expect(page.locator('[data-slot="command-input"]')).toBeFocused();
+ return list;
+}
+
+test.describe('command-palette — menu-parity backfill (real cmdk)', () => {
+ test('a backfilled toggle is search-only, renders in the real list, and dispatches to its handler', async ({
+ page,
+ api,
+ }) => {
+ await api.seedDocs(SEED_DOCS);
+ await page.goto('/');
+ await page.waitForSelector('[role="treeitem"]', { timeout: 15_000 });
+
+ // 1) Empty open: the backfilled long-tail row must NOT render (search-only).
+ const list = await openPalette(page);
+ await expect(page.getByTestId('command-palette-toggle-sidebar')).toHaveCount(0);
+
+ // 2) Type a matching query: the real cmdk renders the backfilled row with a
+ // state-reflecting label (sidebar starts expanded → "Hide sidebar").
+ await page.keyboard.type('sidebar');
+ const row = page.getByTestId('command-palette-toggle-sidebar');
+ await expect(row).toBeVisible({ timeout: 5_000 });
+ await expect(row).toContainText('Hide sidebar');
+
+ // 3) Activate it: the palette closes and the action dispatches through the
+ // bus to FileSidebar's handler (toggles the sidebar).
+ await row.click();
+ await expect(list).toBeHidden({ timeout: 5_000 });
+
+ // 4) Re-open + re-query: the label now reads "Show sidebar", proving the
+ // real dispatch actually flipped the sidebar state (round-trip through
+ // the bus → FileSidebar → view-menu-state store → label).
+ await openPalette(page);
+ await page.keyboard.type('sidebar');
+ const rowAfter = page.getByTestId('command-palette-toggle-sidebar');
+ await expect(rowAfter).toBeVisible({ timeout: 5_000 });
+ await expect(rowAfter).toContainText('Show sidebar');
+ });
+});
diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts
index b7d5a244..717b32c9 100644
--- a/packages/app/tests/stress/fixtures/handoff-mocks.ts
+++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts
@@ -492,6 +492,10 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P
signalReady: () => {},
confirm: async () => ({ ok: true }),
skip: async () => ({ ok: true }),
+ reconfigure: async () => false,
+ },
+ spellcheck: {
+ toggle: async () => false,
},
integrations: {
status: async () => ({
diff --git a/packages/core/src/constants/menu-labels.ts b/packages/core/src/constants/menu-labels.ts
index d316b708..8d1ee7e3 100644
--- a/packages/core/src/constants/menu-labels.ts
+++ b/packages/core/src/constants/menu-labels.ts
@@ -38,6 +38,33 @@ export const MENU_LABELS = {
showSkillsSection: 'Show skills section',
expandAll: 'Expand all',
collapseAll: 'Collapse all',
+ // Move to Trash keeps "Trash" capitalized — the macOS destination proper noun,
+ // same treatment as Finder / Terminal above.
+ moveToTrash: 'Move to Trash',
+ // Copy path is nested in the native menu (Copy path ▸ Full path / Relative
+ // path); the Cmd+K palette flattens it, so these two are palette-side labels
+ // that the parity test still keeps present in the compiled catalog.
+ copyFullPath: 'Copy full path',
+ copyRelativePath: 'Copy relative path',
+ // Backfilled Cmd+K commands whose native-menu counterparts are static-label
+ // items (not state-aware Show/Hide toggles). Shared so both surfaces read
+ // identically; the native menu appends the … ellipsis at its call site.
+ checkForUpdates: 'Check for updates',
+ setUpIntegrations: 'Set up OpenKnowledge integrations',
+ closeTab: 'Close tab',
+ newWorktree: 'New worktree',
+ switchWorktree: 'Switch worktree',
+ newTerminal: 'New Terminal',
+ killTerminal: 'Kill Terminal',
+ checkSpelling: 'Check spelling while typing',
+ openOnGithub: 'OpenKnowledge on GitHub',
} as const;
export type MenuLabelKey = keyof typeof MENU_LABELS;
+
+/**
+ * Canonical repository URL shared by the native Help menu and the Cmd+K
+ * palette's "OpenKnowledge on GitHub" command, so the two surfaces cannot
+ * drift (the parity ratchets check labels, not URLs).
+ */
+export const OPEN_KNOWLEDGE_GITHUB_URL = 'https://github.com/inkeep/open-knowledge';
diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts
index 7fc44c0c..c38e2218 100644
--- a/packages/core/src/desktop-bridge.ts
+++ b/packages/core/src/desktop-bridge.ts
@@ -1493,6 +1493,21 @@ export interface OkDesktopBridge {
confirm(request: OkMcpWiringConfirmRequest): Promise;
/** User clicked Skip (or pressed ESC). */
skip(): Promise;
+ /**
+ * Re-arm the consent dialog on demand (File → "Set up OpenKnowledge
+ * integrations…" and the Cmd+K command). Resolves `true` when armed,
+ * `false` when unavailable (non-darwin / unpackaged / arming threw).
+ */
+ reconfigure(): Promise;
+ };
+
+ /**
+ * App-wide spell-check toggle (Edit → "Check spelling while typing" and the
+ * Cmd+K command). `toggle()` flips the flag and resolves to the new enabled
+ * state. Bespoke main-side setting; NOT part of the View-menu-state snapshot.
+ */
+ spellcheck: {
+ toggle(): Promise;
};
/**
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index da9b8c94..27316654 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -226,7 +226,11 @@ export {
} from './constants/github.ts';
export { isOrphanMode, ORPHAN_MODES, type OrphanMode } from './constants/graph.ts';
export { GREP_MAX_RESULTS, MCP_SERVER_NAME, READ_DOCUMENT_HISTORY_DEPTH } from './constants/mcp.ts';
-export { MENU_LABELS, type MenuLabelKey } from './constants/menu-labels.ts';
+export {
+ MENU_LABELS,
+ type MenuLabelKey,
+ OPEN_KNOWLEDGE_GITHUB_URL,
+} from './constants/menu-labels.ts';
export { LOCAL_DIR, OK_DIR, OK_PROJECT_MARKER } from './constants/ok-dir.ts';
export {
PREVIEW_EMBED_STARTERS,
diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts
index 7d2591cb..84944ea9 100644
--- a/packages/desktop/src/main/index.ts
+++ b/packages/desktop/src/main/index.ts
@@ -2250,22 +2250,8 @@ async function runApplicationMenuRefresh(): Promise {
// window that opens.
reconfigureMcpWiring:
process.platform === 'darwin' && app.isPackaged
- ? async () => {
- mcpWiringHandle?.destroy();
- mcpWiringHandle = null;
- try {
- mcpWiringHandle = armMcpWiring({
- forceShow: true,
- immediateDispatchTarget: pickLoadedRendererForMcpDialog(),
- });
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- console.error('[main] reconfigureMcpWiring failed', { err: message });
- dialog.showErrorBox(
- 'Set up OpenKnowledge integrations failed',
- `OpenKnowledge couldn't re-arm the MCP consent dialog:\n\n${message}`,
- );
- }
+ ? () => {
+ reconfigureMcpWiringNow();
}
: undefined,
// Help → Install in Claude Desktop… opens the skill install dialog in
@@ -2956,6 +2942,37 @@ function armMcpWiring(opts: ArmMcpWiringOpts = {}): RunMcpWiringHandle {
return runMcpWiringOnFirstLaunch(createMcpWiringOpts(opts));
}
+/**
+ * Re-arm the MCP consent dialog on demand — the shared body behind both the
+ * File → "Set up OpenKnowledge integrations…" menu dep and the Cmd+K command's
+ * `ok:mcp-wiring:reconfigure` invoke. Tears down any prior handle then arms a
+ * fresh one with `forceShow: true` so the marker-present gate is bypassed, and
+ * hands it an already-loaded window so the dialog opens immediately. Returns
+ * `false` when the surface is unavailable (non-darwin / unpackaged — same gate
+ * that hides the menu leaf) or when arming threw, so the palette can toast
+ * rather than silently no-op.
+ */
+function reconfigureMcpWiringNow(): boolean {
+ if (!(process.platform === 'darwin' && app.isPackaged)) return false;
+ mcpWiringHandle?.destroy();
+ mcpWiringHandle = null;
+ try {
+ mcpWiringHandle = armMcpWiring({
+ forceShow: true,
+ immediateDispatchTarget: pickLoadedRendererForMcpDialog(),
+ });
+ return true;
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ console.error('[main] reconfigureMcpWiring failed', { err: message });
+ dialog.showErrorBox(
+ 'Set up OpenKnowledge integrations failed',
+ `OpenKnowledge couldn't re-arm the MCP consent dialog:\n\n${message}`,
+ );
+ return false;
+ }
+}
+
function formatUnknownError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
@@ -3194,6 +3211,18 @@ const RECENT_GIT_ROOTS_CAP = 256;
function registerIpcHandlers() {
const handle = createHandler(ipcMain);
+ // File → "Set up OpenKnowledge integrations…" / Cmd+K command. Shares the
+ // exact body the menu dep runs; resolves false when the surface is
+ // unavailable so the palette can toast instead of no-op.
+ handle('ok:mcp-wiring:reconfigure', async (): Promise => reconfigureMcpWiringNow());
+
+ // Edit → "Check spelling while typing" / Cmd+K command. Same single-source
+ // toggle the menu dep uses; returns the new app-wide enabled state.
+ handle('ok:spellcheck:toggle', async (): Promise => {
+ setSpellCheckEnabledAppWide(!appState.spellCheckEnabled);
+ return appState.spellCheckEnabled;
+ });
+
// Per-session membership set for `ok:fs:remove-git-folder`. Populated
// by `ok:fs:find-enclosing-git-root` returns; read by the destructive
// handler via the `allowedGitRoots` dep on `removeGitFolder`. Scope-
diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts
index 77017200..91a38e9b 100644
--- a/packages/desktop/src/main/menu.ts
+++ b/packages/desktop/src/main/menu.ts
@@ -31,7 +31,11 @@
* free of runtime electron bindings.
*/
-import { MENU_LABELS, SHOW_INSTALL_SKILL } from '@inkeep/open-knowledge-core';
+import {
+ MENU_LABELS,
+ OPEN_KNOWLEDGE_GITHUB_URL,
+ SHOW_INSTALL_SKILL,
+} from '@inkeep/open-knowledge-core';
import type { Dialog, MenuItemConstructorOptions } from 'electron';
import type { EntryPoint } from '../shared/entry-point.ts';
import type { EditorActiveTargetSnapshot } from '../shared/ipc-channels.ts';
@@ -366,7 +370,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
...(deps.onCheckForUpdates
? ([
{
- label: 'Check for updates…',
+ label: `${MENU_LABELS.checkForUpdates}\u2026`,
click: deps.onCheckForUpdates,
},
{ type: 'separator' as const },
@@ -471,12 +475,12 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
// renderer surface. Disabled (not hidden) in the Navigator window and
// in unit-test contexts, where the deps aren't wired.
{
- label: 'New worktree…',
+ label: `${MENU_LABELS.newWorktree}\u2026`,
enabled: deps.onNewWorktree !== undefined,
click: () => deps.onNewWorktree?.(),
},
{
- label: 'Switch worktree…',
+ label: `${MENU_LABELS.switchWorktree}\u2026`,
enabled: deps.onSwitchWorktree !== undefined,
click: () => deps.onSwitchWorktree?.(),
},
@@ -500,7 +504,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
click: () => deps.onRename?.(),
},
{
- label: 'Move to Trash',
+ label: MENU_LABELS.moveToTrash,
accelerator: 'CmdOrCtrl+Delete',
enabled:
deps.onMoveToTrash !== undefined &&
@@ -543,7 +547,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
...(deps.reconfigureMcpWiring
? ([
{
- label: 'Set up OpenKnowledge integrations…',
+ label: `${MENU_LABELS.setUpIntegrations}\u2026`,
click: () => {
void deps.reconfigureMcpWiring?.();
},
@@ -566,7 +570,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
: []),
isMac
? {
- label: 'Close tab',
+ label: MENU_LABELS.closeTab,
accelerator: 'CmdOrCtrl+W',
enabled: deps.onCloseActiveTabOrWindow !== undefined,
click: () => deps.onCloseActiveTabOrWindow?.(),
@@ -591,7 +595,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
// in-editor context menu) rebuilds the menu so the checkmark stays in
// sync. No accelerator — macOS has no canonical one for this item.
{
- label: 'Check spelling while typing',
+ label: MENU_LABELS.checkSpelling,
type: 'checkbox',
checked: deps.spellCheckEnabled ?? true,
enabled: deps.onToggleSpellCheck !== undefined,
@@ -721,7 +725,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
// Show/Hide Terminal toggle, and advertising the same key on two
// items only mislabels this one — the OS does not guarantee which
// item a duplicate accelerator dispatches to.
- label: 'New Terminal',
+ label: MENU_LABELS.newTerminal,
enabled: deps.onNewTerminal !== undefined,
click: () => deps.onNewTerminal?.(),
},
@@ -737,7 +741,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
// Closes the active tab — kills its shell (not just hide). Enabled
// only when at least one session is live; a collapsed-but-alive
// terminal still qualifies.
- label: 'Kill Terminal',
+ label: MENU_LABELS.killTerminal,
enabled: deps.onKillTerminal !== undefined && deps.terminalLive === true,
click: () => deps.onKillTerminal?.(),
},
@@ -771,23 +775,24 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
] satisfies MenuItemConstructorOptions[])
: []),
{
- label: 'OpenKnowledge on GitHub',
- click: () => deps.openExternalUrl('https://github.com/inkeep/open-knowledge'),
+ label: MENU_LABELS.openOnGithub,
+ click: () => deps.openExternalUrl(OPEN_KNOWLEDGE_GITHUB_URL),
},
{
label: 'Report a Bug…',
click: () => deps.onReportBug?.(),
},
- // Cross-platform "Check for updates…" — Windows/Linux convention
- // is to place this in Help, since those platforms have no
- // application menu. macOS users get the Apple-HIG-canonical
- // placement under the App menu instead, but the Help entry is
- // also kept for discoverability (mirrors VS Code, Slack, etc.).
- ...(deps.onCheckForUpdates
+ // "Check for updates…" — non-macOS only. Windows/Linux have no
+ // application menu, so Help is the convention there. macOS gets the
+ // Apple-HIG-canonical placement under the App menu (above), so gating
+ // this on `!isMac` keeps exactly one entry per platform — mirroring the
+ // Settings XOR (App menu on macOS, File menu elsewhere). Without the
+ // guard macOS renders the item in BOTH menus.
+ ...(!isMac && deps.onCheckForUpdates
? ([
{ type: 'separator' as const },
{
- label: 'Check for updates…',
+ label: `${MENU_LABELS.checkForUpdates}\u2026`,
click: deps.onCheckForUpdates,
},
] satisfies MenuItemConstructorOptions[])
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index 8fd02c48..7e3f1397 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -579,6 +579,11 @@ const bridge: OkDesktopBridge = {
skills: request.skills,
}),
skip: () => invoke('ok:mcp-wiring:skip'),
+ reconfigure: () => invoke('ok:mcp-wiring:reconfigure'),
+ },
+
+ spellcheck: {
+ toggle: () => invoke('ok:spellcheck:toggle'),
},
integrations: {
diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts
index 8701abd6..7f45bcc3 100644
--- a/packages/desktop/src/shared/bridge-contract.ts
+++ b/packages/desktop/src/shared/bridge-contract.ts
@@ -1731,6 +1731,21 @@ export interface OkDesktopBridge {
confirm(request: OkMcpWiringConfirmRequest): Promise;
/** User clicked Skip (or pressed ESC). */
skip(): Promise;
+ /**
+ * Re-arm the consent dialog on demand (File → "Set up OpenKnowledge
+ * integrations…" and the Cmd+K command). Resolves `true` when armed,
+ * `false` when unavailable (non-darwin / unpackaged / arming threw).
+ */
+ reconfigure(): Promise;
+ };
+
+ /**
+ * App-wide spell-check toggle (Edit → "Check spelling while typing" and the
+ * Cmd+K command). `toggle()` flips the flag and resolves to the new enabled
+ * state. Bespoke main-side setting; NOT part of the View-menu-state snapshot.
+ */
+ spellcheck: {
+ toggle(): Promise;
};
/**
diff --git a/packages/desktop/src/shared/ipc-channels.ts b/packages/desktop/src/shared/ipc-channels.ts
index 13f35556..15f55f57 100644
--- a/packages/desktop/src/shared/ipc-channels.ts
+++ b/packages/desktop/src/shared/ipc-channels.ts
@@ -30,7 +30,7 @@
* existing channels is preferred over net-new hand-rolled channels until
* that migration lands.
*
- * Count is 83 (ratchet cap 83). The 74→75 bump reconciled a merge collision:
+ * Count is 85 (ratchet cap 85). The 74→75 bump reconciled a merge collision:
* the worktree selector (`ok:worktree:dispatch`) and the terminal-controls PR
* (`ok:terminal:cli-installed-map`) each landed in the base tree's single free
* slot concurrently. The 75→76 bump then unioned in the desktop
@@ -49,8 +49,11 @@
* operations widen its payload rather than adding channels). The 82→83 bump
* added the terminal clickable-links out-of-project reveal
* (`ok:shell:reveal-external`): a distinct trust boundary from `reveal-asset`
- * (uncontained + dialog-gated), so it could not fold onto it. Full rationale in
- * the ratchet test header.
+ * (uncontained + dialog-gated), so it could not fold onto it. The 83→85 bump
+ * added the two Cmd+K / native-menu command invokes: `ok:mcp-wiring:reconfigure`
+ * (File → "Set up OpenKnowledge integrations…") and `ok:spellcheck:toggle`
+ * (Edit → "Check spelling while typing"), each delegating to an existing
+ * main-side function. Full rationale in the ratchet test header.
*/
import type {
@@ -1285,6 +1288,23 @@ export interface RequestChannels {
*/
'ok:mcp-wiring:renderer-ready': { args: []; result: undefined };
+ /**
+ * File → "Set up OpenKnowledge integrations…" and the Cmd+K command of the
+ * same name. Re-arms the MCP consent dialog (`armMcpWiring({forceShow:true})`)
+ * — the same body the menu dep runs. Resolves to `true` when the dialog was
+ * armed, `false` when the surface is unavailable (non-darwin / unpackaged /
+ * arming threw); callers may use the result to decide whether to surface
+ * feedback.
+ */
+ 'ok:mcp-wiring:reconfigure': { args: []; result: boolean };
+ /**
+ * Edit → "Check spelling while typing" and the Cmd+K command of the same name.
+ * Toggles the app-wide spell-check flag via `setSpellCheckEnabledAppWide` and
+ * resolves to the new enabled state so the caller can reflect it. Distinct from
+ * the View-menu-state snapshot (spell-check is a bespoke app-wide setting).
+ */
+ 'ok:spellcheck:toggle': { args: []; result: boolean };
+
/**
* Settings → AI tools. One consolidated discriminated channel — following
* the `ok:worktree:dispatch` precedent rather than adding two net-new
diff --git a/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts b/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts
index 7d968e8e..1a911af6 100644
--- a/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts
+++ b/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts
@@ -340,8 +340,14 @@ const CHANNELS_SRC = readFileSync(SRC_PATH, 'utf-8');
* onto the containment-checked asset reveal would wear one channel over two
* opposite security contracts. Single member; the typed-ipc migration remains the
* committed end state, with the `ipc-channels.ts` header updated in lock-step.
+ *
+ * Bumped from 83 to 85 for the two Cmd+K / native-menu command invokes:
+ * `ok:mcp-wiring:reconfigure` (File → "Set up OpenKnowledge integrations…")
+ * and `ok:spellcheck:toggle` (Edit → "Check spelling while typing"). Each
+ * delegates to an existing main-side function; the typed-ipc migration remains
+ * the committed end state, with the `ipc-channels.ts` header in lock-step.
*/
-const REQUEST_CHANNEL_CAP = 83;
+const REQUEST_CHANNEL_CAP = 85;
/**
* Extract the body of an interface block by name. Returns the substring
diff --git a/packages/desktop/tests/main/menu.test.ts b/packages/desktop/tests/main/menu.test.ts
index acaf7584..6ddb7cac 100644
--- a/packages/desktop/tests/main/menu.test.ts
+++ b/packages/desktop/tests/main/menu.test.ts
@@ -346,14 +346,17 @@ describe('buildMenuTemplate', () => {
expect(settingsIdx).toBeGreaterThan(checkIdx);
});
- test('macOS: also appears in Help menu (cross-platform discoverability)', () => {
+ test('macOS: NOT in the Help menu — exactly one entry, in the App menu', () => {
+ // De-duplicated: the Apple-HIG App-menu placement is the single macOS
+ // entry (mirroring the Settings platform-XOR). The Help entry is
+ // non-macOS only, so macOS must not render it twice.
const onCheckForUpdates = mock(() => {});
const deps = makeDeps({ onCheckForUpdates });
const template = buildMenuTemplate(deps);
const helpMenu = template.find((t) => t.label === 'Help');
const sub = helpMenu?.submenu as MenuItemConstructorOptions[] | undefined;
if (!sub) throw new Error('Help submenu missing');
- expect(sub.find((i) => i.label === 'Check for updates…')).toBeDefined();
+ expect(sub.find((i) => i.label === 'Check for updates…')).toBeUndefined();
});
} else {
test('non-mac: appears in Help menu only (no App menu on these platforms)', () => {