diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 44024e80b..971fed69e 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -459,6 +459,24 @@ function OverlayPanel({ // ──────────────────────────── Components ────────────────────────────── +/** + * Delay activating keyboard shortcuts for a brief window after a prompt + * mounts. Terminal input buffered during a spinner (arrow keys, enter) + * arrives as a burst when the prompt appears — without this grace period + * those stale keystrokes can accidentally select an option or navigate + * the list before the user even sees the prompt. + */ +const INPUT_GRACE_MS = 150; + +function useInputGracePeriod(): boolean { + const [ready, setReady] = useState(false); + useEffect(() => { + const timer = setTimeout(() => setReady(true), INPUT_GRACE_MS); + return () => clearTimeout(timer); + }, []); + return ready; +} + type ChoiceRow = { value: T; label: string; @@ -478,6 +496,7 @@ function useChoiceNavigation({ }): number { const [highlighted, setHighlighted] = useState(0); const totalCount = choices.length; + const ready = useInputGracePeriod(); const shortcuts = useMemo( () => [ @@ -516,7 +535,7 @@ function useChoiceNavigation({ ], [choices, highlighted, onCancel, onChoose, totalCount] ); - useInkShortcuts(scope, shortcuts); + useInkShortcuts(scope, shortcuts, { isActive: ready }); return highlighted; } @@ -1266,6 +1285,7 @@ function SelectPrompt({ const isCentered = alignment === "center"; const promptWidth = isCentered ? "100%" : undefined; const totalCount = prompt.options.length; + const ready = useInputGracePeriod(); const [highlighted, setHighlighted] = useState(() => Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1)) ); @@ -1307,7 +1327,7 @@ function SelectPrompt({ ], [highlighted, prompt, totalCount] ); - useInkShortcuts("select-prompt", shortcuts); + useInkShortcuts("select-prompt", shortcuts, { isActive: ready }); return ( ( () => [ { @@ -1423,7 +1444,7 @@ function ConfirmPrompt({ ], [prompt] ); - useInkShortcuts("confirm-prompt", shortcuts); + useInkShortcuts("confirm-prompt", shortcuts, { isActive: ready }); const yLabel = prompt.initialValue ? "Y" : "y"; const nLabel = prompt.initialValue ? "n" : "N"; @@ -1471,6 +1492,7 @@ function MultiSelectPrompt({ ); const [highlighted, setHighlighted] = useState(0); const totalCount = prompt.options.length; + const ready = useInputGracePeriod(); const toggleAt = useCallback( (idx: number) => { @@ -1554,7 +1576,7 @@ function MultiSelectPrompt({ ], [commit, highlighted, prompt, toggleAt, totalCount] ); - useInkShortcuts("multiselect-prompt", shortcuts); + useInkShortcuts("multiselect-prompt", shortcuts, { isActive: ready }); const shortcutText = `space toggle ${ICONS.bullet} a all ${ICONS.bullet} enter confirm ${ICONS.bullet} esc cancel`; const selectedCount = `${selected.size}/${totalCount}`; diff --git a/test/lib/init/ui/ink-app.snapshot.test.tsx b/test/lib/init/ui/ink-app.snapshot.test.tsx index 1547bd1f9..8512f548c 100644 --- a/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -89,7 +89,7 @@ function makeStdin(): Readable { async function renderApp( store: WizardStore, columns: number, - options: { rows?: number; input?: string[] } = {} + options: { rows?: number; input?: string[]; settleMs?: number } = {} ): Promise { const out = new CaptureStream(columns, options.rows ?? 40); const stdin = makeStdin(); @@ -104,7 +104,7 @@ async function renderApp( stdin.push(input); await Bun.sleep(20); } - await Bun.sleep(FRAME_SETTLE_MS); + await Bun.sleep(options.settleMs ?? FRAME_SETTLE_MS); instance.unmount(); // waitUntilExit() hangs in CI — race with a short unref'd timeout. await Promise.race([ @@ -389,7 +389,9 @@ describe("Ink App snapshot", () => { resolve: ignorePromptResolution, }); - const frame = (await renderApp(store, 120)).allOutput(); + // Prompts use a 150ms input grace period before activating shortcuts, + // so we need to wait longer than the default settle time. + const frame = (await renderApp(store, 120, { settleMs: 200 })).allOutput(); const plainFrame = stripAnsi(frame); expect(frame).toContain("Session Replay"); expect(frame).toContain("Tracing"); @@ -455,7 +457,9 @@ describe("Ink App snapshot", () => { resolve: ignorePromptResolution, }); - const frame = (await renderApp(store, 120)).allOutput(); + // Prompts use a 150ms input grace period before activating shortcuts, + // so we need to wait longer than the default settle time. + const frame = (await renderApp(store, 120, { settleMs: 200 })).allOutput(); expect(frame).toContain("navigate"); expect(frame).toContain("confirm"); expect(frame).toContain("cancel");