diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 7e3e7f3a..a137d925 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -140,6 +140,7 @@ Wall starts in `command` mode by default. Embedders may pass `initialMode="passt ### Passthrough mode - All keyboard input routes to the active session's xterm.js instance - Only the mode-exit gesture (LCmd → RCmd) is intercepted +- In the VS Code host, selected workbench chords are mirrored: xterm still processes the key, and Dormouse also asks the extension host to run the matching VS Code command. See [the VS Code host spec](vscode.md) for the allowlist. - Selection overlay shows 2px solid border with glow - Terminal has DOM focus @@ -375,6 +376,7 @@ The deferred spawn also only calls `selectPane` if selection is null. The kill h | `lib/src/components/wall/MouseOverrideBanner.tsx` | Temporary mouse override banner shown from the header icon | | `lib/src/components/wall/use-dockview-ready.ts` | Dockview ready/setup handler: restore/create panels, DnD swap wiring, active panel sync, auto-spawn | | `lib/src/components/wall/use-wall-keyboard.ts` | Capture-phase keyboard dispatch for mode switching, pane/door commands, copy/paste, selection drag keys | +| `lib/src/lib/vscode-keybindings.ts` | VS Code-hosted workbench chord mirror allowlist | | `lib/src/components/wall/use-session-persistence.ts` | Debounced layout/session save, flush requests, pagehide, PTY exit, file-drop paste routing | | `lib/src/components/wall/use-window-focused.ts` | Window focus tracking hook for header and selection overlay dimming | | `lib/src/components/Baseboard.tsx` | Always-visible bottom strip with door components, overflow arrows, and shortcut hints | diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 90d48f96..43a41f18 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -3,9 +3,12 @@ Complete reference for Dormouse's keyboard shortcuts. Shortcuts are grouped by the mode/context in which they apply. Dormouse has two modes: + - **Workspace mode** (a.k.a. "command" mode internally) — keys drive pane layout. - **Terminal mode** (a.k.a. "passthrough" mode) — keys go to the running program, except copy/paste and the mode-switch gesture. +In the VS Code extension host, selected workbench chords are mirrored: the terminal receives the key, and Dormouse also runs the matching VS Code workbench command. See [the VS Code host spec](vscode.md) for the exact allowlist. + ## Mode switching | Key | Action | Description | diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 1f74af28..823810c6 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -75,6 +75,8 @@ Source of truth: Non-obvious message contracts: +VS Code-only workbench chord mirroring uses `dormouse:runWorkbenchCommand` from webview to host. The host validates the requested command against the allowlist in `lib/src/lib/vscode-keybindings.ts` (see [the VS Code host spec](vscode.md)) before calling `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. + | Direction | Message | Source type | Contract | | --- | --- | --- | --- | | Webview → host | `dormouse:openExternal` | `WebviewMessage` | Request the host to open a user-confirmed external URI from an OSC 8 hyperlink. Hosts must revalidate and reject malformed, control-character-bearing, or blocked pseudo-scheme targets (`javascript:`, `data:`, `blob:`, `about:`). | diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index b4683d72..e832849e 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -71,6 +71,7 @@ Universal PTY/transport invariants live in `docs/specs/transport.md`. The rules - **mergeAlertStates on every save path.** Both the frontend periodic save (`onSaveState` callback) and the backend deactivate refresh (`refreshSavedSessionStateFromPtys`) must merge current alert states. Missing this causes alert state to revert on restore. - **retainContextWhenHidden.** Set on both `WebviewPanel` (editor tabs) and `WebviewView` (bottom panel) so that xterm.js DOM, scrollback, and PTY subscriptions survive panel hide/show without going through a resume. - **Two save sources.** Session state is saved from two places: the frontend (debounced 500ms + 30s interval via `dormouse:saveState`) and the backend (deactivate flushes webviews then refreshes from live PTYs). Both paths must produce consistent state. +- **Workbench keybindings mirror for selected chords.** `lib/src/lib/vscode-keybindings.ts` is the source of truth for the VS Code-hosted mirror allowlist. For `Ctrl/Cmd+P`, `Ctrl/Cmd+Shift+P`, `Ctrl/Cmd+B`, and `F1`, xterm still processes the key while the webview also posts `dormouse:runWorkbenchCommand`; `message-router.ts` validates that request against the same small command set before calling `vscode.commands.executeCommand`. ### Extension manifest (current) diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index ffa45d47..b131b2c6 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -1,4 +1,5 @@ import type { AlertState } from '../alert-manager'; +import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; export interface PtyInfo { id: string; @@ -40,6 +41,9 @@ export interface PlatformAdapter { // terminal output is untrusted. openExternal?(uri: string): void; + // VS Code-only escape hatch for mirrored workbench shortcuts from webviews. + runWorkbenchCommand?(command: VSCodeWorkbenchCommand): void; + // PTY event listeners onPtyData(handler: (detail: { id: string; data: string }) => void): void; offPtyData(handler: (detail: { id: string; data: string }) => void): void; diff --git a/lib/src/lib/platform/vscode-adapter.test.ts b/lib/src/lib/platform/vscode-adapter.test.ts index 6260a1cf..5a6db36c 100644 --- a/lib/src/lib/platform/vscode-adapter.test.ts +++ b/lib/src/lib/platform/vscode-adapter.test.ts @@ -80,6 +80,17 @@ describe('VSCodeAdapter PTY exit handling', () => { }); }); + it('posts allowlisted VS Code workbench commands to the extension host', () => { + const adapter = new VSCodeAdapter(); + + adapter.runWorkbenchCommand('workbench.action.quickOpen'); + + expect(postMessage).toHaveBeenCalledWith({ + type: 'dormouse:runWorkbenchCommand', + command: 'workbench.action.quickOpen', + }); + }); + it('parses replay buffers into semantic events and strips OSCs before forwarding', () => { const adapter = new VSCodeAdapter(); const replays: Array<{ id: string; data: string }> = []; diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 2bf4c65e..578fc766 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -7,6 +7,7 @@ import { import { applyTerminalSemanticEventsByPtyId, } from '../terminal-state-store'; +import type { VSCodeWorkbenchCommand } from '../vscode-keybindings'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; @@ -181,6 +182,10 @@ export class VSCodeAdapter implements PlatformAdapter { this.vscode.postMessage({ type: 'dormouse:openExternal', uri }); } + runWorkbenchCommand(command: VSCodeWorkbenchCommand): void { + this.vscode.postMessage({ type: 'dormouse:runWorkbenchCommand', command }); + } + onPtyData(handler: (detail: { id: string; data: string }) => void): void { this.dataHandlers.add(handler); } diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 2ed1809a..76f254b5 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -1,7 +1,7 @@ import { Terminal, type IBufferRange } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { UnicodeGraphemesAddon } from '@xterm/addon-unicode-graphemes'; -import { getPlatform } from './platform'; +import { getPlatform, IS_MAC } from './platform'; import { requestExternalLinkConfirmation } from './external-link-confirmation'; import { attachMouseModeObserver } from './mouse-mode-observer'; import { @@ -43,6 +43,7 @@ import { } from './terminal-state-store'; import { readLogicalLineFromBuffer, type BufferLike } from './terminal-buffer-read'; import { UNNAMED_PANEL_TITLE } from './terminal-state'; +import { vscodeWorkbenchCommandForKeydown } from './vscode-keybindings'; function makePromptLineReader(terminal: Terminal): PromptLineReader { return { @@ -115,6 +116,20 @@ function createXtermHost(): { terminal: Terminal; fit: FitAddon; element: HTMLDi }, }); + // Only hosts that can run workbench commands (the VS Code adapter) opt in; + // on every other platform runWorkbenchCommand is undefined, so the chords + // stay in xterm exactly as before. + if (getPlatform().runWorkbenchCommand) { + terminal.attachCustomKeyEventHandler((event) => { + const command = vscodeWorkbenchCommandForKeydown(event, { isMac: IS_MAC }); + if (!command) return true; + event.preventDefault(); + event.stopPropagation(); + getPlatform().runWorkbenchCommand?.(command); + return true; + }); + } + terminal.loadAddon(new UnicodeGraphemesAddon()); const fit = new FitAddon(); terminal.loadAddon(fit); diff --git a/lib/src/lib/vscode-keybindings.test.ts b/lib/src/lib/vscode-keybindings.test.ts new file mode 100644 index 00000000..d0fe7231 --- /dev/null +++ b/lib/src/lib/vscode-keybindings.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import { vscodeWorkbenchCommandForKeydown } from './vscode-keybindings'; + +function keydown(init: Partial & { key: string }): KeyboardEvent { + return new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...init, + }); +} + +describe('vscodeWorkbenchCommandForKeydown', () => { + it('maps Windows/Linux VS Code workbench chords', () => { + const opts = { isMac: false }; + + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'p', code: 'KeyP', ctrlKey: true }), opts)).toBe('workbench.action.quickOpen'); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'P', code: 'KeyP', ctrlKey: true, shiftKey: true }), opts)).toBe('workbench.action.showCommands'); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'b', code: 'KeyB', ctrlKey: true }), opts)).toBe('workbench.action.toggleSidebarVisibility'); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'F1', code: 'F1' }), opts)).toBe('workbench.action.showCommands'); + }); + + it('uses Cmd as the platform modifier on macOS', () => { + const opts = { isMac: true }; + + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'p', code: 'KeyP', metaKey: true }), opts)).toBe('workbench.action.quickOpen'); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'P', code: 'KeyP', metaKey: true, shiftKey: true }), opts)).toBe('workbench.action.showCommands'); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'b', code: 'KeyB', metaKey: true }), opts)).toBe('workbench.action.toggleSidebarVisibility'); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'p', code: 'KeyP', ctrlKey: true }), opts)).toBe(null); + }); + + it('keeps unrelated terminal control chords in xterm only', () => { + const opts = { isMac: false }; + + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'r', code: 'KeyR', ctrlKey: true }), opts)).toBe(null); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'c', code: 'KeyC', ctrlKey: true }), opts)).toBe(null); + expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'p', code: 'KeyP', ctrlKey: true, altKey: true }), opts)).toBe(null); + }); + + it('only maps keydown events', () => { + const event = new KeyboardEvent('keyup', { key: 'p', code: 'KeyP', ctrlKey: true }); + expect(vscodeWorkbenchCommandForKeydown(event, { isMac: false })).toBe(null); + }); +}); diff --git a/lib/src/lib/vscode-keybindings.ts b/lib/src/lib/vscode-keybindings.ts new file mode 100644 index 00000000..2b9cc400 --- /dev/null +++ b/lib/src/lib/vscode-keybindings.ts @@ -0,0 +1,47 @@ +type KeyboardEventLike = Pick< + KeyboardEvent, + 'altKey' | 'code' | 'ctrlKey' | 'isComposing' | 'key' | 'metaKey' | 'shiftKey' | 'type' +>; + +/** The workbench commands the VS Code host is allowed to run on the webview's behalf. */ +export const VSCODE_WORKBENCH_COMMANDS = [ + 'workbench.action.quickOpen', + 'workbench.action.showCommands', + 'workbench.action.toggleSidebarVisibility', +] as const; + +export type VSCodeWorkbenchCommand = (typeof VSCODE_WORKBENCH_COMMANDS)[number]; + +/** + * Xterm keyboard handling changes when foreground apps enable enhanced + * keyboard protocols, which makes VS Code workbench chords inconsistent. For + * the allowlisted chords, Dormouse lets xterm keep processing the key and also + * asks the VS Code host to run the matching workbench command. + */ +export function vscodeWorkbenchCommandForKeydown( + event: KeyboardEventLike, + options: { isMac: boolean }, +): VSCodeWorkbenchCommand | null { + if (event.type !== 'keydown') return null; + if (event.isComposing) return null; + + if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.key === 'F1') { + return 'workbench.action.showCommands'; + } + + const platformMod = options.isMac ? event.metaKey : event.ctrlKey; + if (!platformMod || event.altKey) return null; + + const key = event.key.toLowerCase(); + const isP = key === 'p' || event.code === 'KeyP'; + if (isP) { + return event.shiftKey + ? 'workbench.action.showCommands' + : 'workbench.action.quickOpen'; + } + + const isB = key === 'b' || event.code === 'KeyB'; + if (isB && !event.shiftKey) return 'workbench.action.toggleSidebarVisibility'; + + return null; +} diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 28f29484..ea319150 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -8,6 +8,7 @@ import { TerminalProtocolParser, } from '../../lib/src/lib/terminal-protocol'; import { normalizeExternalUri } from '../../lib/src/lib/external-links'; +import { VSCODE_WORKBENCH_COMMANDS } from '../../lib/src/lib/vscode-keybindings'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; import type { PersistedSession } from '../../lib/src/lib/session-types'; import type { WebviewMessage, ExtensionMessage } from './message-types'; @@ -23,6 +24,7 @@ const clipboardOps = require('../../lib/clipboard-ops.cjs') as { const globalOwnedPtyIds = new Set(); const activeRouters = new Set<{ flushSessionSave(timeoutMs?: number): Promise }>(); let nextFlushRequestId = 0; +const ALLOWED_WORKBENCH_COMMANDS = new Set(VSCODE_WORKBENCH_COMMANDS); // Shared alert manager — survives router disposal so alert state persists // across webview collapse/expand cycles. @@ -282,6 +284,11 @@ export function attachRouter( ); break; } + case 'dormouse:runWorkbenchCommand': + if (ALLOWED_WORKBENCH_COMMANDS.has(msg.command)) { + void vscode.commands.executeCommand(msg.command); + } + break; case 'dormouse:init': { // Webview has (re-)initialized — subscribe to live events. // Tear down previous subscriptions first (webview was destroyed and recreated). diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 278f9d23..95e18f3a 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -1,5 +1,6 @@ import type { ActivityNotification, SessionStatus, TodoState } from '../../lib/src/lib/alert-manager'; import type { TerminalSemanticEvent } from '../../lib/src/lib/terminal-state'; +import type { VSCodeWorkbenchCommand } from '../../lib/src/lib/vscode-keybindings'; // Messages from webview → extension host export type WebviewMessage = @@ -13,6 +14,7 @@ export type WebviewMessage = | { type: 'clipboard:readFiles'; requestId: string } | { type: 'clipboard:readImage'; requestId: string } | { type: 'dormouse:openExternal'; uri: string } + | { type: 'dormouse:runWorkbenchCommand'; command: VSCodeWorkbenchCommand } | { type: 'dormouse:init' } | { type: 'dormouse:saveState'; state: unknown } | { type: 'dormouse:flushSessionSaveDone'; requestId: string }