From 4aa501692ede13668a550ef21c0ec637f21bf4e8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 16:05:08 -0700 Subject: [PATCH 1/3] Raise a prompt on a stray key in command mode instead of passing it Command mode dispatched each key on its own and let an unbound one fall through silently, so a word typed in the wrong mode taught the user nothing at its first letter and fired whatever verbs its later letters spelled: `ls -la` split at the `-`, `cd` minimized, `git` toggled TODO, `exit` opened the kill prompt, `sudo` ran Reopen. The command-mode chain now ends with a stray-key check: a printed character (Space included) or Backspace, unmodified but for Shift, that no binding claims raises an in-pane "That's not a shortcut" prompt shaped like the kill confirmation. A bound key whose verb is inert on the selection (`t` on a Door) is not a stray; a bare Wall's digits are. While the prompt shows it answers every key ahead of the dialog gate: Escape dismisses it, Enter dismisses it and then acts as usual, modifiers and host chords pass, and every other key is swallowed, so nothing reaches a verb or the Session. A mode or selection change, or a kill confirmation, takes it down. It lists the command-mode keys, a list a test pins against the real dispatch. Closes #68. Co-Authored-By: Claude Opus 5.5 --- docs/specs/layout.md | 15 +- docs/specs/shortcuts.md | 1 + lib/src/components/StrayKeyPrompt.tsx | 86 +++++++++++ lib/src/components/Wall.tsx | 32 ++++ lib/src/components/WorkspaceWindow.test.tsx | 16 +- .../keyboard/handle-pane-shortcuts.test.ts | 2 + .../wall/keyboard/handle-pane-shortcuts.ts | 5 +- .../wall/keyboard/handle-stray-key.test.ts | 142 ++++++++++++++++++ .../wall/keyboard/handle-stray-key.ts | 40 +++++ lib/src/components/wall/keyboard/types.ts | 4 + lib/src/components/wall/use-wall-keyboard.ts | 5 +- lib/src/stories/StrayKeyModal.stories.tsx | 40 +++++ lib/src/stories/Wall.stories.tsx | 14 ++ scripts/spec-word-budgets.json | 4 +- 14 files changed, 395 insertions(+), 11 deletions(-) create mode 100644 lib/src/components/StrayKeyPrompt.tsx create mode 100644 lib/src/components/wall/keyboard/handle-stray-key.test.ts create mode 100644 lib/src/components/wall/keyboard/handle-stray-key.ts create mode 100644 lib/src/stories/StrayKeyModal.stories.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 0aef8cd30..c4162afa1 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -234,9 +234,9 @@ flowchart TD X -- no --> D["dual-tap → editable-field clipboard → mouse-selection keys"] D --> P{"passthrough?"} P -- no --> R{"pane rename or chrome lease?"} - R -- no --> KC["kill confirmation"] + R -- no --> KC["kill confirmation → stray-key prompt"] KC --> DL{"dialog lease held?"} - DL -- no --> W["Workspace shortcuts → pane shortcuts → pane navigation"] + DL -- no --> W["Workspace shortcuts → pane shortcuts → pane navigation → stray key"] M["iframe shim leader message, proxy origin only"] --> MA{"Wall active and passthrough?"} MA -- yes --> CM["command mode"] ``` @@ -245,13 +245,22 @@ flowchart TD Every open dialog holds its own reference-counted lease on the dialog gate; command-mode dispatch resumes only once the last lease is released. +**Must raise the stray-key prompt on a key no command-mode binding claims**: a printed character (Space included) or Backspace, with no modifier but Shift. A bound key inert on the selection (`t` on a Door) is not one; a bare Wall's digit is. The prompt answers every key ahead of the dialog gate, so the rest of a word typed in the wrong mode reaches neither a verb nor the Session: + +- `Escape` dismisses it. +- `Enter` dismisses it, then acts as usual. +- Modifiers and host chords pass, as for the kill confirmation. +- Any other key is swallowed. + +A mode or selection change, or a kill confirmation, takes it down. + **Must defer the pending Workspace confirmation while an inline Workspace rename editor is open**, leaving its keys to the input; it appears after rename ends. Chrome outside every Wall takes the chrome keyboard lease instead: the Workspace strip's rename editor, close confirmation, and tab menu live in the app bar, where `stopPropagation` cannot reach a capture-phase window listener. The Workspace branch is inert on a Wall with no Workspace id. **Must leave Escape and Tab to IME composition in modal/popover focus traps and terminal-context dialogs, and Enter/Escape in shared inline editors** (`isComposingKey` in `lib/src/lib/dom.ts`). -Source of truth: `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `acquireChromeKeyboardLease` in `lib/src/components/wall/chrome-keyboard-lease.ts`. +Source of truth: `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `acquireChromeKeyboardLease` in `lib/src/components/wall/chrome-keyboard-lease.ts`; `handleStrayKey` in `lib/src/components/wall/keyboard/handle-stray-key.ts`. ### Split cwd inheritance diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index a8c6ba345..771074dae 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -88,6 +88,7 @@ Every key not claimed above forwards to the embedded page while a screencast pan | `Shift+F10` / `ContextMenu` | Workspace tab menu | On a focused Workspace tab. | | `↑` / `↓`, `Home` / `End` | Move in a menu | Tab menu rows; `Enter` chooses. | | Prompted letter (unmodified, Shift optional) | Confirm kill | Type the letter shown; prompt routing: [layout.md](layout.md#kill-confirmation). | +| `Enter` / `Esc` (stray-key prompt) | Type / stay | Raised by a key no command-mode binding claims ([layout.md](layout.md#keyboard-shortcuts-command-mode)). | ## VS Code host diff --git a/lib/src/components/StrayKeyPrompt.tsx b/lib/src/components/StrayKeyPrompt.tsx new file mode 100644 index 000000000..9cc321cd4 --- /dev/null +++ b/lib/src/components/StrayKeyPrompt.tsx @@ -0,0 +1,86 @@ +import { useRef } from 'react'; +import { ModalFrame, Shortcut } from './design'; + +/** How the prompt names the key that raised it. */ +export function strayKeyLabel(key: string): string { + return key === ' ' ? 'Space' : key; +} + +/** The bare command-mode keys, as the prompt lists them, space-separated. + * Every key here is dispatched by the command-mode chain, so none of them + * raises this prompt (pinned in `wall/keyboard/handle-stray-key.test.ts`). */ +export const COMMAND_KEYS: ReadonlyArray = [ + ['Enter', 'type'], + ['↑ ↓ ← →', 'move'], + ['| %', 'split right'], + ['- "', 'split down'], + ['z', 'zoom'], + ['m d', 'minimize'], + ['k x', 'kill'], + ['u', 'reopen'], + [',', 'rename'], + ['t', 'TODO'], + ['a', 'alert'], + ['>', 'context'], +]; + +/** Bound only on a Wall with Workspaces; a bare Wall's digits are strays. */ +export const WORKSPACE_KEYS = ['1–9', 'Workspace'] as const; + +/** Raised by a key that is not a command-mode shortcut: someone typing into a + * terminal they thought was in passthrough. */ +export function StrayKeyModal({ + strayKey, + workspaces, + targetElement, + onCancel, +}: { + strayKey: string; + /** Whether this Wall binds the Workspace digits. */ + workspaces: boolean; + targetElement?: HTMLElement | null; + onCancel?: () => void; +}) { + const cancelButtonRef = useRef(null); + const keys = workspaces ? [...COMMAND_KEYS, WORKSPACE_KEYS] : COMMAND_KEYS; + return ( + +

+ That's not a shortcut +

+

+ {strayKeyLabel(strayKey)} does nothing in command mode. +

+
+ Enter + to type into the terminal + +
+
+ {keys.map(([key, label]) => ( + + {key} + {label} + + ))} +
+
+ ); +} diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 39551ec41..4e4f92803 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -31,6 +31,8 @@ import { parseRenderMode, type BrowserAutomationProvider } from 'dor-lib-common/ import { isToolRender } from '../lib/platform/tool-types'; import { acquireAgentBrowserSurfaceController, closeBrowserSurface, requestBrowserRenderMode } from './wall/agent-browser-surface-controller'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; +import { StrayKeyModal } from './StrayKeyPrompt'; +import { resolvePaneElement } from './wall/resolve-pane-element'; import { messageOf } from '../lib/errors'; import { acknowledgeSession, @@ -372,6 +374,18 @@ export function Wall({ // UI state const [confirmKill, setConfirmKill] = useState(null); + // The key that raised the stray-key prompt. The ref leads the state so the + // next key of the same burst already finds the prompt up. + const [strayKey, setStrayKey] = useState(null); + const strayKeyRef = useRef(null); + const raiseStrayKey = useCallback((key: string) => { + strayKeyRef.current = key; + setStrayKey(key); + }, []); + const dismissStrayKey = useCallback(() => { + strayKeyRef.current = null; + setStrayKey(null); + }, []); const [renamingPaneId, setRenamingPaneId] = useState(null); // Runtime Doors carry id + token only; the restored rows' metadata goes into the // store via the seed effect below. @@ -484,6 +498,9 @@ export function Wall({ useEffect(() => { onEventRef.current?.({ type: 'zoomChange', zoomed }); }, [zoomed]); useEffect(() => { onEventRef.current?.({ type: 'minimizeChange', count: doors.length }); }, [doors]); useEffect(() => { onEventRef.current?.({ type: 'selectionChange', id: selectedId, kind: selectedType }); }, [selectedId, selectedType]); + // The prompt answers for the selection and mode it was raised in; a click + // elsewhere, a mode change, or a kill prompt takes it down. + useEffect(dismissStrayKey, [dismissStrayKey, mode, selectedId, selectedType, active, confirmKill]); // --- Helpers --- @@ -2432,6 +2449,9 @@ export function Wall({ reopenClosed: () => { void reopenClosed({ gesture: true }); }, acceptKill, rejectKill, + strayKeyRef, + raiseStrayKey, + dismissStrayKey, setRenamingPaneId, fireEvent, }); @@ -2560,6 +2580,18 @@ export function Wall({ /> )} + {/* Stray-key prompt — over the selected pane, or the Wall when a + Door or Workspace tab is selected. Gated on `active` for the + same reason as the kill overlay. */} + {active && strayKey !== null && ( + + )} + { expect(getWorkspaceUiSnapshot().renamingId).toBeNull(); expect(getWorkspaceUiSnapshot().confirmation).toBeNull(); expect(wallFor(first).querySelector('[data-focused="true"]')).toBeNull(); + // An unbound key raises the stray-key prompt, which holds the next keys. + expect(document.querySelector('[data-stray-key-prompt]')).not.toBeNull(); + await act(async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); }); + await flush(); } await act(async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: '2', bubbles: true, cancelable: true })); }); await flush(); @@ -855,8 +859,14 @@ describe('WorkspaceWindow', () => { await flush(); }; - // A bare `c` never creates a Workspace; that is the strip's `+`. - await press('c'); + // A bare `c` never creates a Workspace; that is the strip's `+`. It, like + // every unbound key, raises the stray-key prompt that Escape takes down. + const stray = async (key: string) => { + await press(key); + expect(document.querySelector('[data-stray-key-prompt]')).not.toBeNull(); + await press('Escape'); + }; + await stray('c'); const ids = () => getWorkspacesSnapshot().workspaces.map((workspace) => workspace.id); expect(ids()).toHaveLength(1); await act(async () => { createWorkspace(); }); @@ -864,7 +874,7 @@ describe('WorkspaceWindow', () => { const second = ids()[1]; expect(getActiveWorkspaceId()).toBe(second); - for (const key of ['n', 'p', '$', '&']) await press(key); + for (const key of ['n', 'p', '$', '&']) await stray(key); expect(getActiveWorkspaceId()).toBe(second); await press('1'); expect(getActiveWorkspaceId()).toBe(first); 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 da806b5da..cb3f72f35 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.test.ts @@ -43,6 +43,8 @@ function makeCtx(overrides: Partial = {}): WallKeyboardCtx { selectedTypeRef: { current: 'pane' }, doorsRef: { current: [{ id: 'pane-a', title: 'Pane A' }] }, dialogKeyboardActiveRef: { current: false }, + strayKeyRef: { current: null }, + raiseStrayKey: vi.fn(), wallActionsRef: { current: { onSplitH: vi.fn(), diff --git a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts index 6b7fd4b7d..03cf616bf 100644 --- a/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts +++ b/lib/src/components/wall/keyboard/handle-pane-shortcuts.ts @@ -16,9 +16,10 @@ function openHeaderContext(ctx: WallKeyboardCtx, id: string): void { } /** Keep aligned with the pane handlers below. Workspace selections consume - * these keys; an omitted key returns unhandled without dispatching a pane action. + * these keys; an omitted key returns unhandled without dispatching a pane action, + * and a printable one raises the stray-key prompt. * Pinned by the independent key list in handle-pane-navigation.test.ts. */ -const PANE_VERB_KEYS: ReadonlySet = new Set(['Enter', '|', '%', '-', '"', 'k', 'x', ',', 'm', 'd', 't', 'a', 'z', '>']); +export const PANE_VERB_KEYS: ReadonlySet = new Set(['Enter', '|', '%', '-', '"', 'k', 'x', ',', 'm', 'd', 't', 'a', 'z', '>']); /** Command-mode shortcuts acting on the selected pane or Door. The binding * table is `docs/specs/shortcuts.md`; the behavior is `docs/specs/layout.md`. */ diff --git a/lib/src/components/wall/keyboard/handle-stray-key.test.ts b/lib/src/components/wall/keyboard/handle-stray-key.test.ts new file mode 100644 index 000000000..95488a505 --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-stray-key.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { useWallKeyboard } from '../use-wall-keyboard'; +import { COMMAND_KEYS, WORKSPACE_KEYS } from '../../StrayKeyPrompt'; +import type { WallKeyboardCtx } from './types'; + +function makeCtx(workspaceId: string | undefined) { + const strayKeyRef = { current: null as string | null }; + const wallActions = { onSplitH: vi.fn(), onSplitV: vi.fn(), onZoom: vi.fn() }; + const ctx = { + nav: { findInDirection: () => null, paneParams: () => ({}), hasPane: () => true, panes: () => ['p1'] }, + activeRef: { current: true }, + workspaceId, + swapWithNeighbor: vi.fn(), + modeRef: { current: 'command' }, + selectedIdRef: { current: 'p1' }, + selectedTypeRef: { current: 'pane' }, + doorsRef: { current: [] }, + confirmKillRef: { current: null }, + renamingRef: { current: null }, + dialogKeyboardActiveRef: { current: false }, + wallActionsRef: { current: wallActions }, + handleReattachRef: { current: vi.fn() }, + selectPane: vi.fn(), + selectDoor: vi.fn(), + selectWorkspace: vi.fn(), + returnToPane: vi.fn(), + enterTerminalMode: vi.fn(), + exitTerminalMode: vi.fn(), + minimizePane: vi.fn(), + openTerminalContext: vi.fn(), + requestKill: vi.fn(), + reopenClosed: vi.fn(), + acceptKill: vi.fn(), + rejectKill: vi.fn(), + strayKeyRef, + raiseStrayKey: vi.fn((key: string) => { strayKeyRef.current = key; }), + dismissStrayKey: vi.fn(() => { strayKeyRef.current = null; }), + setRenamingPaneId: vi.fn(), + fireEvent: vi.fn(), + }; + return { ctx, wallActions, strayKeyRef }; +} + +let root: Root | null = null; +afterEach(async () => { + if (root) await act(async () => root!.unmount()); + root = null; +}); + +async function mount(workspaceId: string | undefined) { + const made = makeCtx(workspaceId); + function Harness() { + useWallKeyboard(made.ctx as unknown as WallKeyboardCtx); + return null; + } + root = createRoot(document.createElement('div')); + await act(async () => root!.render(createElement(Harness))); + return made; +} + +function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); + document.body.dispatchEvent(event); + return event; +} + +const ARROWS: Record = { '↑': 'ArrowUp', '↓': 'ArrowDown', '←': 'ArrowLeft', '→': 'ArrowRight' }; +const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; +const listedKeys = (keys: string) => keys === '1–9' ? DIGITS : keys.split(' ').map(key => ARROWS[key] ?? key); + +describe('stray keys in command mode', () => { + it('stops a word typed in the wrong mode at its first stray, before its letters fire verbs', async () => { + const { ctx, wallActions } = await mount('ws'); + const events = [...'ls -la'].map(key => press(key)); + expect(ctx.raiseStrayKey).toHaveBeenCalledExactlyOnceWith('l'); + expect(wallActions.onSplitV).not.toHaveBeenCalled(); + expect(ctx.enterTerminalMode).not.toHaveBeenCalled(); + expect(events.every(event => event.defaultPrevented)).toBe(true); + }); + + it('Escape takes the prompt down and stays in command mode', async () => { + const { ctx, wallActions, strayKeyRef } = await mount('ws'); + press('l'); + expect(press('Escape').defaultPrevented).toBe(true); + expect(strayKeyRef.current).toBeNull(); + expect(ctx.enterTerminalMode).not.toHaveBeenCalled(); + press('z'); + expect(wallActions.onZoom).toHaveBeenCalledWith('p1'); + }); + + it('Enter takes the prompt down and does the ordinary Enter', async () => { + const { ctx, strayKeyRef } = await mount('ws'); + press('l'); + press('Enter'); + expect(strayKeyRef.current).toBeNull(); + expect(ctx.enterTerminalMode).toHaveBeenCalledWith('p1'); + }); + + it('leaves host chords and bare modifiers to the host while the prompt shows', async () => { + const { strayKeyRef } = await mount('ws'); + press('l'); + expect(press('c', { metaKey: true }).defaultPrevented).toBe(false); + expect(press('Shift').defaultPrevented).toBe(false); + expect(strayKeyRef.current).toBe('l'); + }); + + it.each([' ', 'Backspace', 'L', 'q', '/'])('counts %j as a stray', async key => { + const { ctx } = await mount('ws'); + press(key, { shiftKey: key === 'L' }); + expect(ctx.raiseStrayKey).toHaveBeenCalledExactlyOnceWith(key); + }); + + it.each([{ key: 'l', metaKey: true }, { key: 'l', ctrlKey: true }, { key: 'l', altKey: true }, { key: 'Escape' }, { key: 'Tab' }, { key: 'F1' }])( + 'never counts %j as a stray', async init => { + const { ctx } = await mount('ws'); + press(init.key, init); + expect(ctx.raiseStrayKey).not.toHaveBeenCalled(); + }); + + it('counts digits as strays only on a bare Wall, which leaves them unbound', async () => { + const bare = await mount(undefined); + press('3'); + expect(bare.ctx.raiseStrayKey).toHaveBeenCalledExactlyOnceWith('3'); + }); + + it.each([...COMMAND_KEYS, WORKSPACE_KEYS].flatMap(([keys]) => listedKeys(keys)))( + 'dispatches the listed key %j instead of calling it a stray', async key => { + const { ctx } = await mount('ws'); + press(key); + expect(ctx.raiseStrayKey).not.toHaveBeenCalled(); + }); + + it.each(['t', 'a', '>', ','])('does not call a bound key a stray where its verb is inert (%j on a Door)', async key => { + const { ctx } = await mount('ws'); + ctx.selectedTypeRef.current = 'door'; + press(key); + expect(ctx.raiseStrayKey).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/src/components/wall/keyboard/handle-stray-key.ts b/lib/src/components/wall/keyboard/handle-stray-key.ts new file mode 100644 index 000000000..5e24c3ad3 --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-stray-key.ts @@ -0,0 +1,40 @@ +import { isKillAnswerKey } from './handle-kill-confirm'; +import { PANE_VERB_KEYS } from './handle-pane-shortcuts'; +import type { WallKeyboardCtx } from './types'; + +/** A key someone typing into a terminal would press: one printed character + * (Space included) or Backspace, with no modifier but Shift. A bound key is + * never a stray, even where its verb is inert (`t` on a Door). */ +export function isStrayKey(e: KeyboardEvent): boolean { + if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return false; + if (PANE_VERB_KEYS.has(e.key)) return false; + return e.key === 'Backspace' || [...e.key].length === 1; +} + +/** Last in the command-mode chain: a stray key the handlers above left + * unanswered raises the prompt instead of passing silently, so the rest of a + * word typed in the wrong mode cannot fire the verbs its letters spell. */ +export function handleStrayKey(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean { + if (!isStrayKey(e)) return false; + e.preventDefault(); + e.stopPropagation(); + ctx.raiseStrayKey(e.key); + return true; +} + +/** While the prompt shows it answers every key: Escape dismisses it, Enter + * dismisses it and falls through to the ordinary Enter, and every other answer + * key is swallowed. Modifiers and host chords pass as they do for the kill + * confirmation. */ +export function handleStrayKeyPrompt(e: KeyboardEvent, ctx: WallKeyboardCtx): boolean { + if (!ctx.strayKeyRef.current) return false; + if (!isKillAnswerKey(e)) return true; + if (e.key === 'Enter') { + ctx.dismissStrayKey(); + return false; + } + e.preventDefault(); + e.stopPropagation(); + if (e.key === 'Escape') ctx.dismissStrayKey(); + return true; +} diff --git a/lib/src/components/wall/keyboard/types.ts b/lib/src/components/wall/keyboard/types.ts index 942224dd9..9d6d84c71 100644 --- a/lib/src/components/wall/keyboard/types.ts +++ b/lib/src/components/wall/keyboard/types.ts @@ -56,6 +56,10 @@ export interface WallKeyboardCtx { reopenClosed: () => void; acceptKill: () => void; rejectKill: () => void; + /** The key that raised the stray-key prompt, while it shows. */ + strayKeyRef: RefObject; + raiseStrayKey: (key: string) => void; + dismissStrayKey: () => void; setRenamingPaneId: Dispatch>; fireEvent: (event: WallEvent) => void; } diff --git a/lib/src/components/wall/use-wall-keyboard.ts b/lib/src/components/wall/use-wall-keyboard.ts index 3d744ec34..85970a130 100644 --- a/lib/src/components/wall/use-wall-keyboard.ts +++ b/lib/src/components/wall/use-wall-keyboard.ts @@ -8,6 +8,7 @@ import { cancelUiSelectAll } from './keyboard/cancel-ui-select-all'; import { handlePaneShortcuts } from './keyboard/handle-pane-shortcuts'; import { handlePaneNavigation } from './keyboard/handle-pane-navigation'; import { handleWorkspaceShortcuts } from './keyboard/handle-workspace-shortcuts'; +import { handleStrayKey, handleStrayKeyPrompt } from './keyboard/handle-stray-key'; import { dualTap, newDualTapState } from '../../lib/dual-tap'; import { isProxyOrigin } from '../../lib/iframe-proxy-registry'; import { chromeKeyboardHeld } from './chrome-keyboard-lease'; @@ -78,10 +79,12 @@ export function useWallKeyboard(ctx: WallKeyboardCtx): void { // Workspace strip's rename editor / close confirmation). if (c.renamingRef.current || chromeKeyboardHeld()) return; if (handleKillConfirm(e, c)) return; + if (handleStrayKeyPrompt(e, c)) return; if (c.dialogKeyboardActiveRef.current) return; if (handleWorkspaceShortcuts(e, c)) return; if (handlePaneShortcuts(e, c, navHistory)) return; - handlePaneNavigation(e, c, navHistory); + if (handlePaneNavigation(e, c, navHistory)) return; + handleStrayKey(e, c); }; // A focused cross-origin iframe owns the keyboard, so its keystrokes never diff --git a/lib/src/stories/StrayKeyModal.stories.tsx b/lib/src/stories/StrayKeyModal.stories.tsx new file mode 100644 index 000000000..778131a36 --- /dev/null +++ b/lib/src/stories/StrayKeyModal.stories.tsx @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { StrayKeyModal } from '../components/StrayKeyPrompt'; + +function StrayKeyFrame({ strayKey = 'l', workspaces = true }: { strayKey?: string; workspaces?: boolean }) { + const [frameEl, setFrameEl] = useState(null); + return ( +
+ {/* Simulated terminal content behind the overlay */} +
+
user@dormouse:~$
+
+ +
+ ); +} + +const meta: Meta = { + title: 'Modals/StrayKeyModal', + component: StrayKeyFrame, + argTypes: { + strayKey: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { strayKey: 'l' }, +}; + +export const Space: Story = { + args: { strayKey: ' ' }, +}; + +/** VS Code's bare Wall binds no Workspace digits, so the list leaves them out. */ +export const BareWall: Story = { + args: { strayKey: '3', workspaces: false }, +}; diff --git a/lib/src/stories/Wall.stories.tsx b/lib/src/stories/Wall.stories.tsx index 591b828f7..363dda35f 100644 --- a/lib/src/stories/Wall.stories.tsx +++ b/lib/src/stories/Wall.stories.tsx @@ -127,6 +127,20 @@ export const WithDoors: Story = { }, }; +/** `ls -la` typed in command mode: `l` raises the stray-key prompt, which + * swallows the rest, so the `-` never splits. */ +export const StrayKeyPrompt: Story = { + parameters: { fakePty: { scenario: flattenScenario(SCENARIO_SHELL_PROMPT) } }, + play: async () => { + await settleTerminals(); + const panesBefore = paneCount(); + for (const key of 'ls -la') window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + await requireElement('[data-stray-key-prompt]', 'stray-key prompt'); + if (paneCount() !== panesBefore) throw new Error(`a stray word split the Wall: ${paneCount()} panes`); + await settleTerminals(); + }, +}; + export const AlertRingingDoor: Story = { args: { initialPaneIds: ['surface:1'], diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index adf6b82b5..c968df72c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -13,7 +13,7 @@ "docs/specs/dor-tools-lib.md": 350, "docs/specs/glossary.md": 2950, "docs/specs/hosted.md": 6500, - "docs/specs/layout.md": 7250, + "docs/specs/layout.md": 7400, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4200, "docs/specs/one-time.md": 2650, @@ -31,7 +31,7 @@ "docs/specs/security-remote.md": 7200, "docs/specs/security-supply-chain.md": 1650, "docs/specs/security.md": 2900, - "docs/specs/shortcuts.md": 1150, + "docs/specs/shortcuts.md": 1200, "docs/specs/standalone.md": 6700, "docs/specs/terminal-context.md": 1000, "docs/specs/terminal-escapes.md": 1850, From e2ce78f61eedf858270334e786e3f2c3161e2dea Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 16:25:58 -0700 Subject: [PATCH 2/3] Leave a focused control the keys it uses, not the stray-key prompt Tab is unbound in command mode, so it moves DOM focus onto pane-header controls, and Space is how those activate since Enter is a pane verb. The stray check claimed Space before they saw it: a focused header button stopped activating, and the browser URL span raised the prompt instead of opening its editor. A key the focused control uses (Space on a button-like control, any key in a text field or select) is no longer a stray. xterm's input proxy is excluded from that, so a key on it still raises the prompt and never reaches the PTY. Co-Authored-By: Claude Opus 5.5 --- docs/specs/layout.md | 2 +- .../wall/keyboard/handle-stray-key.test.ts | 38 ++++++++++++++++++- .../wall/keyboard/handle-stray-key.ts | 16 +++++++- 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index c4162afa1..a6b10194c 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -245,7 +245,7 @@ flowchart TD Every open dialog holds its own reference-counted lease on the dialog gate; command-mode dispatch resumes only once the last lease is released. -**Must raise the stray-key prompt on a key no command-mode binding claims**: a printed character (Space included) or Backspace, with no modifier but Shift. A bound key inert on the selection (`t` on a Door) is not one; a bare Wall's digit is. The prompt answers every key ahead of the dialog gate, so the rest of a word typed in the wrong mode reaches neither a verb nor the Session: +**Must raise the stray-key prompt on a key no command-mode binding claims**: a printed character (Space included) or Backspace, with no modifier but Shift. A bound key inert on the selection (`t` on a Door) is not one, nor is a key the focused control uses (Space on a button, any key in a text field); a bare Wall's digit is. The prompt answers every key ahead of the dialog gate, so the rest of a word typed in the wrong mode reaches neither a verb nor the Session: - `Escape` dismisses it. - `Enter` dismisses it, then acts as usual. diff --git a/lib/src/components/wall/keyboard/handle-stray-key.test.ts b/lib/src/components/wall/keyboard/handle-stray-key.test.ts index 95488a505..26e31b51c 100644 --- a/lib/src/components/wall/keyboard/handle-stray-key.test.ts +++ b/lib/src/components/wall/keyboard/handle-stray-key.test.ts @@ -48,6 +48,7 @@ let root: Root | null = null; afterEach(async () => { if (root) await act(async () => root!.unmount()); root = null; + document.body.replaceChildren(); }); async function mount(workspaceId: string | undefined) { @@ -61,12 +62,19 @@ async function mount(workspaceId: string | undefined) { return made; } -function press(key: string, init: KeyboardEventInit = {}): KeyboardEvent { +function press(key: string, init: KeyboardEventInit = {}, target: Element = document.body): KeyboardEvent { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); - document.body.dispatchEvent(event); + target.dispatchEvent(event); return event; } +function focusable(html: string): Element { + const host = document.createElement('div'); + host.innerHTML = html; + document.body.appendChild(host); + return host.firstElementChild!; +} + const ARROWS: Record = { '↑': 'ArrowUp', '↓': 'ArrowDown', '←': 'ArrowLeft', '→': 'ArrowRight' }; const DIGITS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const listedKeys = (keys: string) => keys === '1–9' ? DIGITS : keys.split(' ').map(key => ARROWS[key] ?? key); @@ -133,6 +141,32 @@ describe('stray keys in command mode', () => { expect(ctx.raiseStrayKey).not.toHaveBeenCalled(); }); + // Tab moves focus onto pane-header controls in command mode; Space is how + // they activate, since Enter is a pane verb. + it.each(['', 'https://example.com'])( + 'leaves Space to a focused control %s', async html => { + const { ctx } = await mount('ws'); + const event = press(' ', {}, focusable(html)); + expect(ctx.raiseStrayKey).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('leaves every key to a focused text field', async () => { + const { ctx } = await mount('ws'); + const input = focusable(''); + for (const key of ['l', ' ', 'Backspace']) expect(press(key, {}, input).defaultPrevented).toBe(false); + expect(ctx.raiseStrayKey).not.toHaveBeenCalled(); + }); + + it.each([ + { what: 'a letter on a focused button', html: '', key: 'l' }, + { what: "a key on xterm's input proxy", html: '', key: 'l' }, + ])('still counts $what as a stray', async ({ html, key }) => { + const { ctx } = await mount('ws'); + press(key, {}, focusable(html)); + expect(ctx.raiseStrayKey).toHaveBeenCalledExactlyOnceWith(key); + }); + it.each(['t', 'a', '>', ','])('does not call a bound key a stray where its verb is inert (%j on a Door)', async key => { const { ctx } = await mount('ws'); ctx.selectedTypeRef.current = 'door'; diff --git a/lib/src/components/wall/keyboard/handle-stray-key.ts b/lib/src/components/wall/keyboard/handle-stray-key.ts index 5e24c3ad3..94ee997ee 100644 --- a/lib/src/components/wall/keyboard/handle-stray-key.ts +++ b/lib/src/components/wall/keyboard/handle-stray-key.ts @@ -1,13 +1,25 @@ +import { isEditableTarget, isTerminalInputProxy } from '../../../lib/dom'; import { isKillAnswerKey } from './handle-kill-confirm'; import { PANE_VERB_KEYS } from './handle-pane-shortcuts'; import type { WallKeyboardCtx } from './types'; +/** Whether the focused element uses the key itself: a text field or select any + * key, a button-like control Space (Enter is a pane verb). xterm's input proxy + * is not one of ours, so a key on it stays a stray and never reaches the PTY. */ +function usedByFocusedControl(e: KeyboardEvent): boolean { + const target = e.target instanceof Element ? e.target : null; + if (!target) return false; + if ((isEditableTarget(target) && !isTerminalInputProxy(target)) || target.closest('select')) return true; + return e.key === ' ' && target.closest('button, [role="button"], [role="checkbox"], [role="switch"], summary') !== null; +} + /** A key someone typing into a terminal would press: one printed character * (Space included) or Backspace, with no modifier but Shift. A bound key is - * never a stray, even where its verb is inert (`t` on a Door). */ + * never a stray, even where its verb is inert (`t` on a Door), nor is a key the + * focused control uses. */ export function isStrayKey(e: KeyboardEvent): boolean { if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return false; - if (PANE_VERB_KEYS.has(e.key)) return false; + if (PANE_VERB_KEYS.has(e.key) || usedByFocusedControl(e)) return false; return e.key === 'Backspace' || [...e.key].length === 1; } From 29b20fad81f77f7e0a5129cf9a30056b33ccfb75 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 30 Oct 2026 16:40:21 -0700 Subject: [PATCH 3/3] Mock the registry verbs the stray-key test's listed keys reach The test drives the real command-mode chain, so `Enter`, `a`, and `t` reach `acknowledgeSession`, `dismissSessionAlert`, and `toggleSessionTodo`, which read the session activity store and threw "Platform not initialized" inside the keydown listener. Vitest counted those as unhandled errors and failed the Library Tests run though every assertion passed. Mock them as handle-pane-shortcuts.test.ts does. Co-Authored-By: Claude Opus 5.5 --- lib/src/components/wall/keyboard/handle-stray-key.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/src/components/wall/keyboard/handle-stray-key.test.ts b/lib/src/components/wall/keyboard/handle-stray-key.test.ts index 26e31b51c..3ff6e0320 100644 --- a/lib/src/components/wall/keyboard/handle-stray-key.test.ts +++ b/lib/src/components/wall/keyboard/handle-stray-key.test.ts @@ -6,6 +6,13 @@ import { useWallKeyboard } from '../use-wall-keyboard'; import { COMMAND_KEYS, WORKSPACE_KEYS } from '../../StrayKeyPrompt'; import type { WallKeyboardCtx } from './types'; +// The verbs the listed keys reach would otherwise need an initialized platform. +vi.mock('../../../lib/terminal-registry', () => ({ + acknowledgeSession: vi.fn(), + dismissSessionAlert: vi.fn(), + toggleSessionTodo: vi.fn(), +})); + function makeCtx(workspaceId: string | undefined) { const strayKeyRef = { current: null as string | null }; const wallActions = { onSplitH: vi.fn(), onSplitV: vi.fn(), onZoom: vi.fn() };