diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index cadf330fe..6134536c1 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -35,6 +35,8 @@ import { useState, useSyncExternalStore, } from "react"; +import stringWidth from "string-width"; +import wrapAnsi from "wrap-ansi"; import { type BannerLine, bannerLinesForWidth, @@ -393,7 +395,7 @@ function ActivityPane({ )} {visibleLogs.length > 0 ? ( - + {visibleLogs.map((log) => ( ))} @@ -401,7 +403,12 @@ function ActivityPane({ ) : null} {spinner.active ? : null} {summary ? : null} - {prompt ? : null} + {prompt ? ( + + ) : null} ); } @@ -719,7 +726,11 @@ function IntroPreflightContent({ const promptContent = prompt ? ( - + ) : null; @@ -1277,30 +1288,184 @@ type MultiSelectPromptOptionData = Extract< { kind: "multiselect" } >["options"][number]; +/** + * Rows unavailable to option lists: workflow chrome reserves the tab/shortcut + * footers, while centered prompts also reserve intro padding, the full banner, + * and the extra controls shown by multiselect prompts. + */ +const WORKFLOW_PROMPT_RESERVED_ROWS = 10; +const CENTERED_SELECT_RESERVED_ROWS = 20; +const CENTERED_MULTISELECT_RESERVED_ROWS = 23; + +/** + * Returns the half-open option range that keeps the highlighted item visible. + * The range never exceeds the requested viewport size or the option count. + */ +export function getOptionWindow( + totalCount: number, + highlighted: number, + maxVisible: number +): readonly [number, number] { + const normalizedTotal = Math.max(0, Math.floor(totalCount)); + if (normalizedTotal === 0) { + return [0, 0]; + } + + const viewportSize = Math.min( + normalizedTotal, + Math.max(1, Math.floor(maxVisible)) + ); + const normalizedHighlight = Math.min( + normalizedTotal - 1, + Math.max(0, Math.floor(highlighted)) + ); + const centeredStart = normalizedHighlight - Math.floor(viewportSize / 2); + const start = Math.min( + normalizedTotal - viewportSize, + Math.max(0, centeredStart) + ); + return [start, start + viewportSize]; +} + +function getPromptOptionLimit({ + terminalRows, + alignment, + kind, + occupiedRows, + messageRows, +}: { + terminalRows: number; + alignment: PromptAlignment; + kind: "select" | "multiselect"; + occupiedRows: number; + messageRows: number; +}): number { + let reservedRows = WORKFLOW_PROMPT_RESERVED_ROWS; + if (alignment === "center") { + reservedRows = + kind === "multiselect" + ? CENTERED_MULTISELECT_RESERVED_ROWS + : CENTERED_SELECT_RESERVED_ROWS; + } + const extraMessageRows = Math.max(0, messageRows - 1); + return Math.max( + 1, + terminalRows - reservedRows - occupiedRows - extraMessageRows + ); +} + +function getPromptContentWidth( + terminalColumns: number, + alignment: PromptAlignment +): number { + const frameWidth = getInkFrameWidth(terminalColumns); + if (alignment === "center") { + return Math.min(frameWidth, 84); + } + return frameWidth >= 80 ? Math.floor((frameWidth - 1) * 0.6) : frameWidth; +} + +function getPromptMessageRows({ + message, + terminalColumns, + alignment, + kind, + totalCount, +}: { + message: string; + terminalColumns: number; + alignment: PromptAlignment; + kind: "select" | "multiselect"; + totalCount: number; +}): number { + let availableWidth = getPromptContentWidth(terminalColumns, alignment); + const countDigits = String(Math.max(1, totalCount)).length; + + if (kind === "select") { + const positionWidth = stringWidth( + `(${"9".repeat(countDigits)}/${"9".repeat(countDigits)})` + ); + availableWidth -= positionWidth + (alignment === "center" ? 1 : 3); + } else if (alignment === "start") { + const count = "9".repeat(countDigits); + availableWidth -= + stringWidth(`${count}/${count} selected • ${count}/${count}`) + 4; + } + + return wrapAnsi(message, Math.max(1, availableWidth), { + hard: true, + trim: false, + wordWrap: true, + }).split("\n").length; +} + function PromptArea({ alignment = "start", + occupiedRows = 0, prompt, }: { alignment?: PromptAlignment; + occupiedRows?: number; prompt: ActivePrompt; }): React.ReactNode { + const { columns, rows } = useInkFrameSize(); if (prompt.kind === "select") { - return ; + const messageRows = getPromptMessageRows({ + message: prompt.message, + terminalColumns: columns, + alignment, + kind: prompt.kind, + totalCount: prompt.options.length, + }); + return ( + + ); } if (prompt.kind === "confirm") { return ; } if (prompt.kind === "multiselect") { - return ; + const messageRows = getPromptMessageRows({ + message: prompt.message, + terminalColumns: columns, + alignment, + kind: prompt.kind, + totalCount: prompt.options.length, + }); + return ( + + ); } return null; } function SelectPrompt({ alignment, + maxVisibleOptions, prompt, }: { alignment: PromptAlignment; + maxVisibleOptions: number; prompt: Extract; }): React.ReactNode { const isCentered = alignment === "center"; @@ -1309,6 +1474,13 @@ function SelectPrompt({ const [highlighted, setHighlighted] = useState(() => Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1)) ); + const [windowStart, windowEnd] = getOptionWindow( + totalCount, + highlighted, + maxVisibleOptions + ); + const visibleOptions = prompt.options.slice(windowStart, windowEnd); + const isWindowed = visibleOptions.length < totalCount; const shortcuts = useMemo( () => [ @@ -1357,8 +1529,13 @@ function SelectPrompt({ width={promptWidth} > {isCentered ? ( - + {prompt.message} + {isWindowed ? ( + + ({highlighted + 1}/{totalCount}) + + ) : null} ) : ( @@ -1366,10 +1543,16 @@ function SelectPrompt({ {ICONS.diamondOpen} {prompt.message} + {isWindowed ? ( + + ({highlighted + 1}/{totalCount}) + + ) : null} )} - {prompt.options.map((option, idx) => { + {visibleOptions.map((option, visibleIndex) => { + const idx = windowStart + visibleIndex; const isCursor = idx === highlighted; return ( + {isCursor ? `${ICONS.triangleSmallRight} ` : " "} @@ -1411,7 +1600,7 @@ function SelectPromptOptionRow({ ); } return ( - + {isCursor ? ICONS.triangleSmallRight : " "} @@ -1499,9 +1688,11 @@ function ConfirmPrompt({ function MultiSelectPrompt({ alignment, + maxVisibleOptions, prompt, }: { alignment: PromptAlignment; + maxVisibleOptions: number; prompt: Extract; }): React.ReactNode { const isCentered = alignment === "center"; @@ -1511,6 +1702,13 @@ function MultiSelectPrompt({ ); const [highlighted, setHighlighted] = useState(0); const totalCount = prompt.options.length; + const [windowStart, windowEnd] = getOptionWindow( + totalCount, + highlighted, + maxVisibleOptions + ); + const visibleOptions = prompt.options.slice(windowStart, windowEnd); + const isWindowed = visibleOptions.length < totalCount; const toggleAt = useCallback( (idx: number) => { @@ -1596,7 +1794,9 @@ function MultiSelectPrompt({ ); useInkShortcuts("multiselect-prompt", shortcuts); const shortcutText = `space toggle ${ICONS.bullet} a all ${ICONS.bullet} enter confirm ${ICONS.bullet} esc cancel`; - const selectedCount = `${selected.size}/${totalCount}`; + const selectedCount = isWindowed + ? `${selected.size}/${totalCount} selected ${ICONS.bullet} ${highlighted + 1}/${totalCount}` + : `${selected.size}/${totalCount}`; return ( ) : null} - {prompt.options.map((option, idx) => { + {visibleOptions.map((option, visibleIndex) => { + const idx = windowStart + visibleIndex; const isSelected = selected.has(option.value); const isCursor = idx === highlighted; return ( @@ -1665,7 +1866,13 @@ function MultiSelectPromptOptionRow({ const markerColor = isSelected ? COLOR_SUCCESS : MUTED_DIM; if (centered) { return ( - + {isCursor ? `${ICONS.triangleSmallRight} ` : " "} @@ -1678,7 +1885,7 @@ function MultiSelectPromptOptionRow({ ); } return ( - + {isCursor ? ICONS.triangleSmallRight : " "} diff --git a/packages/cli/test/lib/init/ui/ink-app.property.test.ts b/packages/cli/test/lib/init/ui/ink-app.property.test.ts new file mode 100644 index 000000000..5f6d5d47b --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-app.property.test.ts @@ -0,0 +1,42 @@ +/** + * Property tests for terminal-height option windowing in the Ink prompt UI. + */ + +import { assert as fcAssert, integer, property } from "fast-check"; +import { describe, expect, test } from "vitest"; +import { getOptionWindow } from "../../../../src/lib/init/ui/ink-app.js"; +import { DEFAULT_NUM_RUNS } from "../../../model-based/helpers.js"; + +describe("property: Ink prompt option window", () => { + test("stays bounded and always contains the highlighted option", () => { + fcAssert( + property( + integer({ min: 0, max: 500 }), + integer({ min: -1000, max: 1000 }), + integer({ min: -50, max: 200 }), + (totalCount, highlighted, maxVisible) => { + const [start, end] = getOptionWindow( + totalCount, + highlighted, + maxVisible + ); + const expectedSize = Math.min(totalCount, Math.max(1, maxVisible)); + + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeLessThanOrEqual(totalCount); + expect(end - start).toBe(expectedSize); + + if (totalCount > 0) { + const normalizedHighlight = Math.min( + totalCount - 1, + Math.max(0, highlighted) + ); + expect(start).toBeLessThanOrEqual(normalizedHighlight); + expect(end).toBeGreaterThan(normalizedHighlight); + } + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 916277630..a063e3c1b 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -37,11 +37,13 @@ const ENTER_CONFIRM_HINT_RE = /enter\s+confirm/; const ESC_CANCEL_HINT_RE = /esc\s+cancel/; const COMPLETED_SELECTING_FEATURES_RE = /✔\s+Selecting features/; const ANSI_ESCAPE_PREFIX = "\u001B["; +const CURSOR_TO_LINE_START = "\u001B[G"; // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in captured Ink output const ANSI_CSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in captured Ink output const ANSI_OSC_RE = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g; const LINE_SPLIT_RE = /\r?\n/; +const DOWN_ARROW = "\u001B[B"; const RIGHT_ARROW = "\u001B[C"; const FEEDBACK_BANNER_TEXT = '$ sentry cli feedback "what worked or broke"'; @@ -53,6 +55,7 @@ const TEST_BANNER_ROWS = [ class CaptureStream extends Writable { frames: string[] = []; + settledOutput = ""; columns: number; rows: number; isTTY = true; @@ -68,6 +71,13 @@ class CaptureStream extends Writable { allOutput(): string { return this.frames.join(""); } + latestFrame(): string { + const output = this.settledOutput || this.allOutput(); + const redrawStart = output.lastIndexOf(CURSOR_TO_LINE_START); + return redrawStart === -1 + ? output + : output.slice(redrawStart + CURSOR_TO_LINE_START.length); + } } function makeStdin(): Readable { @@ -113,6 +123,7 @@ async function renderApp( await sleep(20); } await sleep(FRAME_SETTLE_MS); + out.settledOutput = out.allOutput(); instance.unmount(); // waitUntilExit() hangs in CI — race with a short unref'd timeout. await Promise.race([ @@ -144,6 +155,10 @@ function withoutFeedbackBanner(output: string): string { .join("\n"); } +function stripFinalLineBreak(output: string): string { + return output.endsWith("\n") ? output.slice(0, -1) : output; +} + function stripAnsi(output: string): string { return output.replace(ANSI_CSI_RE, "").replace(ANSI_OSC_RE, ""); } @@ -535,6 +550,142 @@ describe("Ink App snapshot", () => { expect(frame).not.toContain("switch tab"); }); + test("long select prompts only render options that fit the terminal", async () => { + const store = new WizardStore({ + bannerRows: FULL_BANNER_LINES, + layout: "intro", + }); + store.setPrompt({ + kind: "select", + message: "Which team should own this project?", + options: Array.from({ length: 20 }, (_value, index) => ({ + value: `team-${index + 1}`, + label: `Team ${index + 1}`, + })), + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 120, { rows: 24 }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Which team should own this project?"); + expect(frame).toContain("(1/20)"); + expect(frame).toContain("Team 4"); + expect(frame).not.toContain("Team 5"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(24); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + + const scrolledFrame = stripAnsi( + ( + await renderApp(store, 120, { + input: Array.from({ length: 7 }, () => DOWN_ARROW), + rows: 24, + }) + ).allOutput() + ); + expect(scrolledFrame).toContain("(8/20)"); + expect(scrolledFrame).toContain("Team 8"); + }); + + test("long multiselect prompts only render options that fit the terminal", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog( + "warn", + "A warning remains visible while choosing features" + ); + store.appendLog( + "error", + "An error remains visible while choosing features" + ); + store.appendLog("warn", "A second warning also remains visible"); + store.setPrompt({ + kind: "multiselect", + message: "Select features\nReview the monitoring choices", + options: Array.from({ length: 20 }, (_value, index) => ({ + value: `feature-${index + 1}`, + label: `Feature ${index + 1}`, + })), + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 120, { rows: 16 }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("0/20 selected • 1/20"); + expect(frame).toContain("Review the monitoring choices"); + expect(frame).toContain("A second warning also remains visible"); + expect(frame).toContain("Feature 2"); + expect(frame).not.toContain("Feature 3"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + + const scrolledFrame = stripFinalLineBreak( + stripAnsi( + ( + await renderApp(store, 120, { + input: Array.from({ length: 19 }, () => DOWN_ARROW), + rows: 16, + }) + ).latestFrame() + ) + ); + expect(scrolledFrame).toContain("0/20 selected • 20/20"); + expect(scrolledFrame).toContain("Feature 20"); + expect(scrolledFrame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + expect(scrolledFrame).toContain(FEEDBACK_BANNER_TEXT); + }); + + test("centered multiselect prompts fit with the full banner", async () => { + const store = new WizardStore({ + bannerRows: FULL_BANNER_LINES, + layout: "intro", + }); + store.setPrompt({ + kind: "multiselect", + message: "Select features\nReview the monitoring choices", + options: Array.from({ length: 20 }, (_value, index) => ({ + value: `feature-${index + 1}`, + label: `Feature ${index + 1}`, + })), + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 120, { rows: 30 }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Review the monitoring choices"); + expect(frame).toContain("Feature 6"); + expect(frame).not.toContain("Feature 7"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(30); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + }); + + test("long option hints stay on one terminal row", async () => { + const store = new WizardStore({ bannerRows: [], layout: "intro" }); + store.setPrompt({ + kind: "select", + message: "Choose a team", + options: [ + { + value: "team-1", + label: "A", + hint: "This deliberately long team name would wrap onto another row UNIQUE_TAIL", + }, + { value: "team-2", label: "Team 2" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = stripAnsi( + (await renderApp(store, 40, { rows: 24 })).allOutput() + ); + expect(frame).toContain("A"); + expect(frame).not.toContain("UNIQUE_TAIL"); + }); + test("file scroll shortcut appears only when the file tree overflows", async () => { const shortTree = new WizardStore({ bannerRows: [] }); shortTree.recordFilesReading(["src/app.ts"]);