Skip to content
Open
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
30 changes: 26 additions & 4 deletions src/lib/init/ui/ink-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends string> = {
value: T;
label: string;
Expand All @@ -478,6 +496,7 @@ function useChoiceNavigation<T extends string>({
}): number {
const [highlighted, setHighlighted] = useState(0);
const totalCount = choices.length;
const ready = useInputGracePeriod();

const shortcuts = useMemo<ShortcutBinding[]>(
() => [
Expand Down Expand Up @@ -516,7 +535,7 @@ function useChoiceNavigation<T extends string>({
],
[choices, highlighted, onCancel, onChoose, totalCount]
);
useInkShortcuts(scope, shortcuts);
useInkShortcuts(scope, shortcuts, { isActive: ready });

return highlighted;
}
Expand Down Expand Up @@ -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<number>(() =>
Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1))
);
Expand Down Expand Up @@ -1307,7 +1327,7 @@ function SelectPrompt({
],
[highlighted, prompt, totalCount]
);
useInkShortcuts("select-prompt", shortcuts);
useInkShortcuts("select-prompt", shortcuts, { isActive: ready });

return (
<Box
Expand Down Expand Up @@ -1394,6 +1414,7 @@ function ConfirmPrompt({
}): React.ReactNode {
const isCentered = alignment === "center";
const promptWidth = isCentered ? "100%" : undefined;
const ready = useInputGracePeriod();
const shortcuts = useMemo<ShortcutBinding[]>(
() => [
{
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -1471,6 +1492,7 @@ function MultiSelectPrompt({
);
const [highlighted, setHighlighted] = useState<number>(0);
const totalCount = prompt.options.length;
const ready = useInputGracePeriod();

const toggleAt = useCallback(
(idx: number) => {
Expand Down Expand Up @@ -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}`;

Expand Down
12 changes: 8 additions & 4 deletions test/lib/init/ui/ink-app.snapshot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CaptureStream> {
const out = new CaptureStream(columns, options.rows ?? 40);
const stdin = makeStdin();
Expand All @@ -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([
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
Loading