diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 5806122b1..cc498ad0a 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, 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. +- 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..3ff6e0320 --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-stray-key.test.ts @@ -0,0 +1,183 @@ +// @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'; + +// 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() }; + 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; + document.body.replaceChildren(); +}); + +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 = {}, target: Element = document.body): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); + 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); + +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(); + }); + + // 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'; + 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..94ee997ee --- /dev/null +++ b/lib/src/components/wall/keyboard/handle-stray-key.ts @@ -0,0 +1,52 @@ +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), 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) || usedByFocusedControl(e)) 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 41f278f16..e9afd106b 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": 6550, - "docs/specs/layout.md": 7250, + "docs/specs/layout.md": 7400, "docs/specs/mobile-terminal-ui.md": 1250, "docs/specs/mouse-and-clipboard.md": 4250, "docs/specs/one-time.md": 2650, @@ -31,7 +31,7 @@ "docs/specs/security-remote.md": 7600, "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": 6750, "docs/specs/terminal-context.md": 1000, "docs/specs/terminal-escapes.md": 1850,