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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions docs/specs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/transport.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`). |
Expand Down
1 change: 1 addition & 0 deletions docs/specs/vscode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions lib/src/lib/platform/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AlertState } from '../alert-manager';
import type { VSCodeWorkbenchCommand } from '../vscode-keybindings';

export interface PtyInfo {
id: string;
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions lib/src/lib/platform/vscode-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];
Expand Down
5 changes: 5 additions & 0 deletions lib/src/lib/platform/vscode-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof acquireVsCodeApi>;
Expand Down Expand Up @@ -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);
}
Expand Down
17 changes: 16 additions & 1 deletion lib/src/lib/terminal-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
46 changes: 46 additions & 0 deletions lib/src/lib/vscode-keybindings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, it } from 'vitest';
import { vscodeWorkbenchCommandForKeydown } from './vscode-keybindings';

function keydown(init: Partial<KeyboardEventInit> & { 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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pin the fix from e878eb8 with a regression test so the !event.shiftKey guard on the F1 branch isn't silently dropped later.

Suggested change
expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'p', code: 'KeyP', ctrlKey: true, altKey: true }), opts)).toBe(null);
expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'p', code: 'KeyP', ctrlKey: true, altKey: true }), opts)).toBe(null);
expect(vscodeWorkbenchCommandForKeydown(keydown({ key: 'F1', code: 'F1', shiftKey: 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);
});
});
47 changes: 47 additions & 0 deletions lib/src/lib/vscode-keybindings.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions vscode-ext/src/message-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,7 @@ const clipboardOps = require('../../lib/clipboard-ops.cjs') as {
const globalOwnedPtyIds = new Set<string>();
const activeRouters = new Set<{ flushSessionSave(timeoutMs?: number): Promise<void> }>();
let nextFlushRequestId = 0;
const ALLOWED_WORKBENCH_COMMANDS = new Set<string>(VSCODE_WORKBENCH_COMMANDS);

// Shared alert manager — survives router disposal so alert state persists
// across webview collapse/expand cycles.
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions vscode-ext/src/message-types.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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 }
Expand Down
Loading