Skip to content
Draft
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
15 changes: 12 additions & 3 deletions docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
```
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/specs/shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions lib/src/components/StrayKeyPrompt.tsx
Original file line number Diff line number Diff line change
@@ -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<readonly [keys: string, label: string]> = [
['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<HTMLButtonElement>(null);
const keys = workspaces ? [...COMMAND_KEYS, WORKSPACE_KEYS] : COMMAND_KEYS;
return (
<ModalFrame
titleId="stray-key-title"
targetElement={targetElement}
padding="spacious"
align="center"
initialFocusRef={cancelButtonRef}
onEscape={onCancel}
onOutsideClick={onCancel}
data-stray-key-prompt=""
>
<h2 id="stray-key-title" className="text-base font-bold mb-3 text-foreground">
That&apos;s not a shortcut
</h2>
<p className="text-sm text-muted mb-3 max-w-xs">
<span className="font-mono text-foreground">{strayKeyLabel(strayKey)}</span> does nothing in command mode.
</p>
<div className="text-sm text-muted leading-relaxed grid grid-cols-[auto_auto] gap-x-2 justify-center mb-3">
<Shortcut className="justify-self-end">Enter</Shortcut>
<span className="justify-self-start">to type into the terminal</span>
<button
ref={cancelButtonRef}
type="button"
onClick={onCancel}
className="contents group cursor-pointer"
>
<Shortcut className="justify-self-end group-hover:text-foreground transition-colors">Esc</Shortcut>
<span className="justify-self-start group-hover:text-foreground transition-colors">to stay in command mode</span>
</button>
</div>
<div className="text-xs text-muted grid grid-cols-[auto_auto_auto_auto] gap-x-2 justify-center">
{keys.map(([key, label]) => (
<span key={key} className="contents">
<span className="justify-self-end font-mono text-foreground">{key}</span>
<span className="justify-self-start">{label}</span>
</span>
))}
</div>
</ModalFrame>
);
}
32 changes: 32 additions & 0 deletions lib/src/components/Wall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -372,6 +374,18 @@ export function Wall({

// UI state
const [confirmKill, setConfirmKill] = useState<ConfirmKill | null>(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<string | null>(null);
const strayKeyRef = useRef<string | null>(null);
const raiseStrayKey = useCallback((key: string) => {
strayKeyRef.current = key;
setStrayKey(key);
}, []);
const dismissStrayKey = useCallback(() => {
strayKeyRef.current = null;
setStrayKey(null);
}, []);
const [renamingPaneId, setRenamingPaneId] = useState<string | null>(null);
// Runtime Doors carry id + token only; the restored rows' metadata goes into the
// store via the seed effect below.
Expand Down Expand Up @@ -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 ---

Expand Down Expand Up @@ -2432,6 +2449,9 @@ export function Wall({
reopenClosed: () => { void reopenClosed({ gesture: true }); },
acceptKill,
rejectKill,
strayKeyRef,
raiseStrayKey,
dismissStrayKey,
setRenamingPaneId,
fireEvent,
});
Expand Down Expand Up @@ -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 && (
<StrayKeyModal
strayKey={strayKey}
workspaces={workspaceId !== undefined}
targetElement={selectedType === 'pane' && selectedId ? resolvePaneElement(paneElements.get(selectedId)) : null}
onCancel={dismissStrayKey}
/>
)}

<ShellSpawnNotice
notice={shellSpawnNotice}
paneElements={paneElements}
Expand Down
16 changes: 13 additions & 3 deletions lib/src/components/WorkspaceWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ describe('WorkspaceWindow', () => {
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();
Expand Down Expand Up @@ -855,16 +859,22 @@ 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(); });
await flush();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ function makeCtx(overrides: Partial<WallKeyboardCtx> = {}): 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(),
Expand Down
5 changes: 3 additions & 2 deletions lib/src/components/wall/keyboard/handle-pane-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set(['Enter', '|', '%', '-', '"', 'k', 'x', ',', 'm', 'd', 't', 'a', 'z', '>']);
export const PANE_VERB_KEYS: ReadonlySet<string> = 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`. */
Expand Down
Loading
Loading