From 4c67495969da678d0b09a57656d75619c71afa09 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 8 May 2026 06:16:17 +0000 Subject: [PATCH] fix(init): add input grace period to prompts to discard stale keystrokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a prompt mounts after an async operation (tool execution, network call), terminal input buffered during the spinner arrives as a burst. Without a grace period, those stale keystrokes can accidentally select an option or navigate the list before the user sees the prompt. Each prompt component now ignores input for 150ms after mounting via useInputGracePeriod(). The rest of the UI (tab switching, file panel scrolling, Ctrl+C) stays fully responsive during spinners — only the prompt-level shortcuts are delayed. Fixes #478 --- src/lib/init/ui/ink-app.tsx | 30 +++++++++++++++++++--- test/lib/init/ui/ink-app.snapshot.test.tsx | 12 ++++++--- 2 files changed, 34 insertions(+), 8 deletions(-) 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");