From 62ccaea123275c0898c41dac1791607865084786 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:15:59 -0700 Subject: [PATCH 1/3] Keep host modifier chords out of pane commands and kill answers --- docs/specs/layout.md | 6 +++- docs/specs/shortcuts.md | 6 ++-- lib/src/components/WorkspaceKillConfirm.tsx | 4 +-- .../wall/keyboard/handle-kill-confirm.test.ts | 36 +++++++++++++++++++ .../wall/keyboard/handle-kill-confirm.ts | 12 +++++-- .../keyboard/handle-pane-navigation.test.ts | 9 +++++ .../wall/keyboard/handle-pane-navigation.ts | 2 +- .../keyboard/handle-pane-shortcuts.test.ts | 14 ++++++++ .../wall/keyboard/handle-pane-shortcuts.ts | 5 ++- 9 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 lib/src/components/wall/keyboard/handle-kill-confirm.test.ts diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 62ab98c13..50596efb3 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -218,6 +218,8 @@ Source of truth: `handleDualTap` in `lib/src/components/wall/keyboard/handle-dua `docs/specs/shortcuts.md` tables every binding; this section owns the dispatch behavior behind it. +**Must leave Cmd/Ctrl/Alt-modified pane action keys unhandled, except the arrow swap chords** ([Spatial navigation](#spatial-navigation)). Shift may produce a binding's printed character. + **Must use `,` to rename the selected terminal pane or Workspace tab in command mode**, without activating an inactive Workspace. **Must support Workspace navigation in command mode through bare `1`–`9`, arrows, and `Enter`.** Digits select by strip position; out-of-range positions are consumed without switching. **Never bind `c`, `n`/`p`, `$`, or `&`.** Rename inputs and confirmation dialogs retain their own controls. @@ -261,7 +263,7 @@ A split from an existing pane (`|`/`%`/`-`/`"` or the header split buttons) spaw Dirty Tool close consent: `docs/specs/dor-tool.md` → Closing unsaved Tools. -`x`/`k` (or the kill button, which first leaves passthrough) shows a confirmation with a random lowercase letter; typing it confirms the kill. **Never draw `x`, `k`, or Reopen's `u` as that letter**, so neither a double-tap nor a reopen accepts it. Which closes skip it: `docs/specs/reopen.md` → "The rule"; under Labs one that would show it becomes a pending kill instead (`docs/specs/reopen.md` → "Labs: No-confirm delayed kill"). `Escape`, the cancel button, and clicking another panel cancel; any other key dismisses it. +`x`/`k` (or the kill button, which first leaves passthrough) shows a confirmation with a random lowercase letter; typing it confirms the kill. **Never draw `x`, `k`, or Reopen's `u` as that letter**, so neither a double-tap nor a reopen accepts it. Which closes skip it: `docs/specs/reopen.md` → "The rule"; under Labs one that would show it becomes a pending kill instead (`docs/specs/reopen.md` → "Labs: No-confirm delayed kill"). `Escape`, the cancel button, and clicking another panel cancel; any other answer key dismisses it. **Never answer a kill prompt with a bare modifier or a Cmd/Ctrl/Alt chord.** **Must return keyboard selection to the next surviving Door after killing a revealed Door**, falling back to previous Doors, then a pane only if no Doors remain. Apply this to confirmed and unasked kills only while the revealed pane, or the Door, is still selected in command mode; cancellation, refusal, or navigating away discards the return target. @@ -300,6 +302,8 @@ Source of truth: `resolvePaneElement` in `lib/src/components/wall/resolve-pane-e ## Spatial navigation +**Must leave Alt-modified arrows unhandled.** + **Must resolve arrow navigation through the engine-neutral `WallNav` seam to Lath's `neighborOf`, never a DOM rect scan.** Neighbor eligibility: `docs/specs/tiling-engine.md` → "Layout". Back-navigation: a breadcrumb tracks the last navigation direction and origin pane, and **the opposite direction must return to the origin instead of doing a spatial lookup**, so asymmetric layouts navigate reversibly. diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 22bd63cbe..f3bc61dbf 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -16,6 +16,8 @@ A focused cross-origin iframe surface swallows the gesture; the proxy shim detec ## Pane actions (command mode) +Unmodified keys; Shift may produce the printed character. + | Key | Action | Description | |-----|--------|-------------| | `\|` or `%` | Split left/right | New pane to the right, selected, in passthrough. | @@ -45,7 +47,7 @@ Standalone and the desktop playground — a bare Wall (VS Code) leaves these key | Key | Action | Description | |-----|--------|-------------| -| `↑` / `↓` / `←` / `→` | Move selection | Navigate panes and doors; opposite directions backtrack between panes. Down with no pane below selects the first door; Up from a door selects the last pane. Up from a top-edge pane highlights the active Workspace tab. | +| `↑` / `↓` / `←` / `→` (without Cmd/Ctrl/Alt) | Move selection | Navigate panes and doors; opposite directions backtrack between panes. Down with no pane below selects the first door; Up from a door selects the last pane. Up from a top-edge pane highlights the active Workspace tab. | | `←` / `→` (Workspace strip) | Highlight tab / `+` | Traverse in strip order (`docs/specs/layout.md` → Workspace tabs), without activation, stopping at either end. Down returns to the originating live pane, or the first if gone. | | `⌘`+arrows or `Ctrl`+arrows | Swap surfaces | Swap the two panes' Surfaces; the opposite chord swaps back exactly. Either modifier, every platform; consumed no-op on non-pane chrome. | @@ -85,7 +87,7 @@ Every key not claimed above forwards to the embedded page while a screencast pan | `Shift+F10` / `ContextMenu` | Workspace alert settings | On a focused baseboard alarm button (`docs/specs/alert.md` → Settings dialog). | | `Shift+F10` / `ContextMenu` | Workspace tab menu | On a focused Workspace tab. | | `↑` / `↓`, `Home` / `End` | Move in a menu | Tab menu rows; `Enter` chooses. | -| Prompted letter | Confirm kill | Type the letter shown to confirm; other keys reaching the prompt cancel (see layout's dispatch order). | +| Prompted letter (unmodified, Shift optional) | Confirm kill | Type the letter shown; prompt routing: [layout.md](layout.md#kill-confirmation). | ## VS Code host diff --git a/lib/src/components/WorkspaceKillConfirm.tsx b/lib/src/components/WorkspaceKillConfirm.tsx index 25705f1b8..ba4270235 100644 --- a/lib/src/components/WorkspaceKillConfirm.tsx +++ b/lib/src/components/WorkspaceKillConfirm.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import type { ModalLayer } from './design'; import { KillConfirmModal } from './KillConfirm'; -import { acceptsKillChar } from './wall/keyboard/handle-kill-confirm'; +import { acceptsKillChar, isKillAnswerKey } from './wall/keyboard/handle-kill-confirm'; /** One typed-letter interaction for Workspace close and move, window close, and * app quit. */ @@ -28,7 +28,7 @@ export function WorkspaceKillConfirm({ char, onConfirm, onCancel, canConfirm, ti // ModalFrame handles Escape first on the same capture target. if (event.key === 'Escape' && event.defaultPrevented) return; // Cmd+Q is another quit request, never an answer even when the letter is q. - if (event.metaKey || event.ctrlKey || event.altKey || ['Shift', 'Meta', 'Control', 'Alt'].includes(event.key)) return; + if (!isKillAnswerKey(event)) return; event.preventDefault(); event.stopImmediatePropagation(); const latest = latestRef.current; diff --git a/lib/src/components/wall/keyboard/handle-kill-confirm.test.ts b/lib/src/components/wall/keyboard/handle-kill-confirm.test.ts new file mode 100644 index 000000000..947c4885e --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-kill-confirm.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest'; +import { handleKillConfirm } from './handle-kill-confirm'; +import type { WallKeyboardCtx } from './types'; + +function context() { + return { confirmKillRef: { current: { char: 'q' } }, acceptKill: vi.fn(), rejectKill: vi.fn() } as unknown as WallKeyboardCtx; +} + +describe('pane kill answers', () => { + it.each([ + { key: 'q', metaKey: true }, { key: 'q', ctrlKey: true }, { key: 'q', altKey: true }, + { key: 'Meta' }, { key: 'Control' }, { key: 'Alt' }, { key: 'Shift' }, + ])('leaves modifier/chord %j unanswered and available to the host', init => { + const ctx = context(); + const event = new KeyboardEvent('keydown', { ...init, cancelable: true }); + expect(handleKillConfirm(event, ctx)).toBe(true); + expect(ctx.acceptKill).not.toHaveBeenCalled(); + expect(ctx.rejectKill).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it.each([{ key: 'q' }, { key: 'Q' }, { key: 'Q', shiftKey: true }])('accepts the typed letter %j', init => { + const ctx = context(); + const event = new KeyboardEvent('keydown', { ...init, cancelable: true }); + expect(handleKillConfirm(event, ctx)).toBe(true); + expect(ctx.acceptKill).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it('cancels on another ordinary key', () => { + const ctx = context(); + handleKillConfirm(new KeyboardEvent('keydown', { key: 'Escape' }), ctx); + expect(ctx.rejectKill).toHaveBeenCalledOnce(); + }); +}); diff --git a/lib/src/components/wall/keyboard/handle-kill-confirm.ts b/lib/src/components/wall/keyboard/handle-kill-confirm.ts index 7aa57e93d..450350944 100644 --- a/lib/src/components/wall/keyboard/handle-kill-confirm.ts +++ b/lib/src/components/wall/keyboard/handle-kill-confirm.ts @@ -2,7 +2,7 @@ import type { WallKeyboardCtx } from './types'; /** * Whether a key accepts a staged kill confirmation: a case-insensitive match of - * the confirm letter, so Caps Lock still confirms. Every other key rejects, which + * the confirm letter, so Caps Lock still confirms. Every other answer key rejects, which * is why the confirmation hijacks each key it sees rather than testing for one. * Shared with `WorkspaceKillConfirm`, which listens outside every * Wall (`docs/specs/shortcuts.md` → "Dialogs, menus & prompts"). @@ -11,13 +11,21 @@ export function acceptsKillChar(key: string, char: string): boolean { return key.toLowerCase() === char.toLowerCase(); } +/** Modifiers and host chords never answer a typed-letter confirmation. Shift + * may produce an uppercase letter, which acceptsKillChar handles normally. */ +export function isKillAnswerKey(event: KeyboardEvent): boolean { + return !event.metaKey && !event.ctrlKey && !event.altKey + && !['Shift', 'Meta', 'Control', 'Alt'].includes(event.key); +} + /** * Kill-confirmation second-key handler. Once a kill is staged in confirmKillRef, - * we hijack every key: matching letter accepts, anything else rejects. + * we hijack answer keys: matching letter accepts, anything else rejects. */ export function handleKillConfirm(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean { const ck = ctx.confirmKillRef.current; if (!ck) return false; + if (!isKillAnswerKey(e)) return true; e.preventDefault(); e.stopPropagation(); diff --git a/lib/src/components/wall/keyboard/handle-pane-navigation.test.ts b/lib/src/components/wall/keyboard/handle-pane-navigation.test.ts index cf41c4af4..f1d8127d3 100644 --- a/lib/src/components/wall/keyboard/handle-pane-navigation.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-navigation.test.ts @@ -24,6 +24,15 @@ function context(): WallKeyboardCtx { beforeEach(() => setWorkspaces({ workspaces: [{ id: 'ws-a', name: 'App' }, { id: 'ws-b', name: 'Build' }], activeId: 'ws-a' })); describe('workspace row navigation', () => { + it('leaves Alt+Arrow to its host instead of navigating the Wall', () => { + const ctx = context(); + const event = new KeyboardEvent('keydown', { key: 'ArrowUp', altKey: true, cancelable: true }); + expect(handlePaneNavigation(event, ctx, { current: null })).toBe(false); + expect(event.defaultPrevented).toBe(false); + expect(ctx.selectWorkspace).not.toHaveBeenCalled(); + expect(ctx.nav.findInDirection).not.toHaveBeenCalled(); + }); + it('moves from the top pane through tabs and + without activating, then returns to the pane', () => { const ctx = context(); const history: NavHistoryRef = { current: null }; diff --git a/lib/src/components/wall/keyboard/handle-pane-navigation.ts b/lib/src/components/wall/keyboard/handle-pane-navigation.ts index c68b8e4ea..f0a6438bf 100644 --- a/lib/src/components/wall/keyboard/handle-pane-navigation.ts +++ b/lib/src/components/wall/keyboard/handle-pane-navigation.ts @@ -11,7 +11,7 @@ export function handlePaneNavigation( ctx: WallKeyboardCtx, navHistory: NavHistoryRef, ): boolean { - if (!isArrowKey(e.key) || e.metaKey || e.ctrlKey) { + if (!isArrowKey(e.key) || e.metaKey || e.ctrlKey || e.altKey) { return false; } e.preventDefault(); diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index 45722e054..58eb9f8bd 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -71,6 +71,20 @@ function keydownMeta(key: string): KeyboardEvent { } describe('handlePaneShortcuts kill behavior', () => { + + it.each(['metaKey', 'ctrlKey', 'altKey'] as const)('leaves %s chords out of bare pane actions', modifier => { + for (const key of ['x', 't', '-', 'Enter', ',']) { + const ctx = makeCtx(); + const event = new KeyboardEvent('keydown', { key, [modifier]: true, cancelable: true }); + expect(handlePaneShortcuts(event, ctx, { current: null }), key).toBe(false); + expect(event.defaultPrevented, key).toBe(false); + expect(ctx.requestKill).not.toHaveBeenCalled(); + expect(ctx.enterTerminalMode).not.toHaveBeenCalled(); + expect(ctx.setRenamingPaneId).not.toHaveBeenCalled(); + expect(ctx.wallActionsRef.current.onSplitV).not.toHaveBeenCalled(); + } + }); + beforeEach(() => vi.clearAllMocks()); it.each(['x', 'k'])('hands the %s gesture to the Wall kill request', (key) => { diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index cc2bfae09..3c40e125e 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -27,9 +27,12 @@ export function handlePaneShortcuts( ctx: WallKeyboardCtx, navHistory: NavHistoryRef, ): boolean { + // Host chords must not become the corresponding plain pane action. Shift + // can produce a binding's printed character; Cmd/Ctrl arrows are explicit. + if (e.altKey || ((e.metaKey || e.ctrlKey) && !isArrowKey(e.key))) return false; const sid = ctx.selectedIdRef.current; // Reopen acts on the Window, so it answers whatever is selected. - if (e.key === 'u' && !e.metaKey && !e.ctrlKey && !e.altKey) { + if (e.key === 'u') { e.preventDefault(); e.stopPropagation(); ctx.reopenClosed(); From af0b0e7c714f5efaf34855191d1f111ffdd34be8 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:34:33 -0700 Subject: [PATCH 2/3] Preserve symbol bindings printed through Option and AltGr --- docs/specs/layout.md | 2 +- docs/specs/shortcuts.md | 2 +- .../keyboard/handle-pane-shortcuts.test.ts | 21 +++++++++++++++++++ .../wall/keyboard/handle-pane-shortcuts.ts | 7 ++++--- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 50596efb3..b1db7192d 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -218,7 +218,7 @@ Source of truth: `handleDualTap` in `lib/src/components/wall/keyboard/handle-dua `docs/specs/shortcuts.md` tables every binding; this section owns the dispatch behavior behind it. -**Must leave Cmd/Ctrl/Alt-modified pane action keys unhandled, except the arrow swap chords** ([Spatial navigation](#spatial-navigation)). Shift may produce a binding's printed character. +**Must leave Cmd/Ctrl/Alt-modified pane action keys unhandled, except arrow swap chords and printed symbol bindings** ([Spatial navigation](#spatial-navigation)). Shift may produce any binding; Option or AltGr may produce `|`, `%`, `"`, or `>`. **Must use `,` to rename the selected terminal pane or Workspace tab in command mode**, without activating an inactive Workspace. diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index f3bc61dbf..a8c6ba345 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -16,7 +16,7 @@ A focused cross-origin iframe surface swallows the gesture; the proxy shim detec ## Pane actions (command mode) -Unmodified keys; Shift may produce the printed character. +Modifier handling: [layout.md](layout.md#keyboard-shortcuts-command-mode). | Key | Action | Description | |-----|--------|-------------| diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts index 58eb9f8bd..da806b5da 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -85,6 +85,27 @@ describe('handlePaneShortcuts kill behavior', () => { } }); + it.each([ + ['|', { altKey: true }], + ['|', { ctrlKey: true, altKey: true }], + ['%', { altKey: true }], + ['"', { ctrlKey: true, altKey: true }], + ] as const)('accepts the printed split character %s through Option or AltGr', (key, modifiers) => { + const ctx = makeCtx(); + const event = new KeyboardEvent('keydown', { key, ...modifiers, cancelable: true }); + expect(handlePaneShortcuts(event, ctx, { current: null })).toBe(true); + expect(event.defaultPrevented).toBe(true); + expect(key === '"' ? ctx.wallActionsRef.current.onSplitV : ctx.wallActionsRef.current.onSplitH) + .toHaveBeenCalledWith('pane-a', 'keyboard'); + }); + + it('does not turn a Command chord into a printed symbol binding', () => { + const ctx = makeCtx(); + const event = new KeyboardEvent('keydown', { key: '|', metaKey: true, altKey: true, cancelable: true }); + expect(handlePaneShortcuts(event, ctx, { current: null })).toBe(false); + expect(event.defaultPrevented).toBe(false); + }); + beforeEach(() => vi.clearAllMocks()); it.each(['x', 'k'])('hands the %s gesture to the Wall kill request', (key) => { diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 3c40e125e..6b7fd4b7d 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -27,9 +27,10 @@ export function handlePaneShortcuts( ctx: WallKeyboardCtx, navHistory: NavHistoryRef, ): boolean { - // Host chords must not become the corresponding plain pane action. Shift - // can produce a binding's printed character; Cmd/Ctrl arrows are explicit. - if (e.altKey || ((e.metaKey || e.ctrlKey) && !isArrowKey(e.key))) return false; + // Option and AltGr can print the symbol bindings on non-US keyboards. + // Plain host chords still do not become pane actions; Cmd/Ctrl arrows are explicit. + const printedSymbol = e.altKey && ['|', '%', '"', '>'].includes(e.key); + if ((e.altKey && !printedSymbol) || ((e.metaKey || (e.ctrlKey && !printedSymbol)) && !isArrowKey(e.key))) return false; const sid = ctx.selectedIdRef.current; // Reopen acts on the Window, so it answers whatever is selected. if (e.key === 'u') { From 091650a3f8d75292a0d8a929d97ba213cbc57c83 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:35:35 -0700 Subject: [PATCH 3/3] Keep modifier contract within the layout spec budget --- docs/specs/layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index b1db7192d..888627aa3 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -218,7 +218,7 @@ Source of truth: `handleDualTap` in `lib/src/components/wall/keyboard/handle-dua `docs/specs/shortcuts.md` tables every binding; this section owns the dispatch behavior behind it. -**Must leave Cmd/Ctrl/Alt-modified pane action keys unhandled, except arrow swap chords and printed symbol bindings** ([Spatial navigation](#spatial-navigation)). Shift may produce any binding; Option or AltGr may produce `|`, `%`, `"`, or `>`. +**Must leave Cmd/Ctrl/Alt chords unhandled except swaps and printed symbols** ([Spatial navigation](#spatial-navigation)): Shift permits any binding; Option/AltGr permits `|`, `%`, `"`, `>`. **Must use `,` to rename the selected terminal pane or Workspace tab in command mode**, without activating an inactive Workspace.