diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx
index 43bb6eaad..a69df9d4f 100644
--- a/src/ui/tui/playground/PlaygroundApp.tsx
+++ b/src/ui/tui/playground/PlaygroundApp.tsx
@@ -23,6 +23,7 @@ import { AuditChecksDemo } from './demos/AuditChecksDemo.js';
import { LearnDeckDemo } from './demos/LearnDeckDemo.js';
import { EndScreensDemo } from './demos/EndScreensDemo.js';
import { AiOptInDemo } from './demos/AiOptInDemo.js';
+import { AskModalDemo } from './demos/AskModalDemo.js';
interface PlaygroundAppProps {
store: WizardStore;
@@ -32,6 +33,7 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => {
const tabs = [
{ id: 'layout', label: 'Layout', component: },
{ id: 'input', label: 'Input', component: },
+ { id: 'ask-modal', label: 'Ask modal', component: },
{ id: 'progress', label: 'Progress', component: },
{ id: 'logs', label: 'Logs', component: },
{
diff --git a/src/ui/tui/playground/demos/AskModalDemo.tsx b/src/ui/tui/playground/demos/AskModalDemo.tsx
new file mode 100644
index 000000000..6f62268f6
--- /dev/null
+++ b/src/ui/tui/playground/demos/AskModalDemo.tsx
@@ -0,0 +1,187 @@
+/**
+ * AskModalDemo — Pickers rendered inside an ask-style ModalOverlay, the way
+ * WizardAskScreen composes them: prompt paragraph in the modal body, then a
+ * label-less PickerMenu. Exercises the viewport height cap (MAX_LIST_ROWS)
+ * and the no-label spacing (no stray blank lines under the paragraph).
+ */
+
+import { Box, Text } from 'ink';
+import { useState, type ReactNode } from 'react';
+import {
+ ModalOverlay,
+ PickerMenu,
+ GroupedPickerMenu,
+} from '@ui/tui/primitives/index';
+import { Colors, Icons } from '@ui/tui/styles';
+import { LONG_OPTIONS } from './InputDemo.js';
+
+enum DemoStep {
+ MultiLong = 'multi-long',
+ MultiDescriptions = 'multi-descriptions',
+ Single = 'single',
+ Grouped = 'grouped',
+ Done = 'done',
+}
+
+const DESCRIBED_OPTIONS = [
+ {
+ label: 'Session replay',
+ value: 'replay',
+ description:
+ 'Record and replay real user sessions to see exactly what happened before an error or a rage click.',
+ },
+ {
+ label: 'Feature flags',
+ value: 'flags',
+ description:
+ 'Roll features out gradually, target by cohort, and kill misbehaving code paths without a deploy.',
+ },
+ {
+ label: 'Error tracking',
+ value: 'errors',
+ description: 'Capture exceptions with stack traces and source maps.',
+ },
+];
+
+const GROUPED = {
+ 'Issue trackers': [
+ { label: 'GitHub Issues', value: 'github' },
+ { label: 'Linear', value: 'linear' },
+ { label: 'Jira', value: 'jira' },
+ { label: 'Shortcut', value: 'shortcut' },
+ ],
+ 'Error monitoring': [
+ { label: 'Sentry', value: 'sentry' },
+ { label: 'Rollbar', value: 'rollbar' },
+ { label: 'Bugsnag', value: 'bugsnag' },
+ { label: 'Honeybadger', value: 'honeybadger' },
+ ],
+ 'Support desks': [
+ { label: 'Zendesk', value: 'zendesk' },
+ { label: 'Freshdesk', value: 'freshdesk' },
+ { label: 'Front', value: 'front' },
+ { label: 'Intercom', value: 'intercom' },
+ ],
+ Observability: [
+ { label: 'Datadog', value: 'datadog' },
+ { label: 'New Relic', value: 'newrelic' },
+ { label: 'Grafana', value: 'grafana' },
+ { label: 'Splunk', value: 'splunk' },
+ ],
+};
+
+/** Mirrors WizardAskScreen's shape: paragraph body, then a label-less input. */
+const AskModal = ({
+ prompt,
+ children,
+}: {
+ prompt: string;
+ children: ReactNode;
+}) => (
+
+
+ {prompt}
+
+ {children}
+
+);
+
+export const AskModalDemo = () => {
+ const [step, setStep] = useState(DemoStep.MultiLong);
+ const [results, setResults] = useState([]);
+
+ const advance = (result: string, next: DemoStep) => {
+ setResults((prev) => [...prev, result]);
+ setStep(next);
+ };
+
+ if (step === DemoStep.MultiLong) {
+ return (
+
+ {
+ const arr = Array.isArray(values) ? values : [values];
+ advance(
+ `Multi (long): ${arr.length} picked`,
+ DemoStep.MultiDescriptions,
+ );
+ }}
+ />
+
+ );
+ }
+
+ if (step === DemoStep.MultiDescriptions) {
+ return (
+
+ {
+ const arr = Array.isArray(values) ? values : [values];
+ advance(
+ `Multi (descriptions): ${arr.join(', ') || 'none'}`,
+ DemoStep.Single,
+ );
+ }}
+ />
+
+ );
+ }
+
+ if (step === DemoStep.Single) {
+ return (
+
+
+ options={[
+ { label: 'The app', value: 'app', hint: 'src/app' },
+ { label: 'The marketing site', value: 'marketing', hint: 'www/' },
+ { label: 'Both, app first', value: 'both' },
+ ]}
+ onSelect={(value) => {
+ advance(`Single: ${String(value)}`, DemoStep.Grouped);
+ }}
+ />
+
+ );
+ }
+
+ if (step === DemoStep.Grouped) {
+ return (
+
+ {
+ advance(`Grouped: ${values.length} kept`, DemoStep.Done);
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+
+ Ask Modal Demo — Results
+
+
+ {results.map((r, i) => (
+
+ {'✔'} {r}
+
+ ))}
+
+ Switch away from this tab and back to restart.
+
+ );
+};
diff --git a/src/ui/tui/playground/demos/InputDemo.tsx b/src/ui/tui/playground/demos/InputDemo.tsx
index 442a80087..3e941aea2 100644
--- a/src/ui/tui/playground/demos/InputDemo.tsx
+++ b/src/ui/tui/playground/demos/InputDemo.tsx
@@ -10,10 +10,55 @@ import { Colors } from '@ui/tui/styles';
enum DemoStep {
Single = 'single',
Multi = 'multi',
+ MultiLong = 'multi-long',
Confirm = 'confirm',
Done = 'done',
}
+// A long single-column multi-select — more options than a normal terminal can
+// show at once — so the playground exercises PickerMenu's viewport paging
+// (the "↑/↓ N more" indicators and the n/p page keys). Shared with
+// AskModalDemo.
+export const LONG_OPTIONS = [
+ 'None of these',
+ 'GitHub Issues',
+ 'Linear',
+ 'Jira',
+ 'GitLab',
+ 'Gitea',
+ 'Shortcut',
+ 'Sentry',
+ 'Rollbar',
+ 'Bugsnag',
+ 'Honeybadger',
+ 'Raygun',
+ 'Zendesk',
+ 'Freshdesk',
+ 'Freshservice',
+ 'Front',
+ 'Gorgias',
+ 'Kustomer',
+ 'Dixa',
+ 'Plain',
+ 'pganalyze',
+ 'Snyk',
+ 'SonarQube',
+ 'Semgrep',
+ 'Rapid7 InsightVM',
+ 'Featurebase',
+ 'Frill',
+ 'Aha',
+ 'UserVoice',
+ 'Productboard',
+ 'Canny',
+ 'AskNicely',
+ 'Retently',
+ 'Appfigures',
+ 'AppFollow',
+ 'Judge.me',
+ 'Google Search Console',
+].map((label) => ({ label, value: label.toLowerCase().replace(/\s+/g, '-') }));
+
export const InputDemo = () => {
const [step, setStep] = useState(DemoStep.Single);
const [results, setResults] = useState([]);
@@ -60,6 +105,30 @@ export const InputDemo = () => {
onSelect={(values) => {
const arr = Array.isArray(values) ? values : [values];
setResults((prev) => [...prev, `Multi: ${arr.join(', ')}`]);
+ setStep(DemoStep.MultiLong);
+ }}
+ />
+
+ );
+ }
+
+ if (step === DemoStep.MultiLong) {
+ return (
+
+
+ Input Demo — Multi Select (long, scrolls)
+
+
+ {
+ const arr = Array.isArray(values) ? values : [values];
+ setResults((prev) => [
+ ...prev,
+ `Multi (long): ${arr.length} picked`,
+ ]);
setStep(DemoStep.Confirm);
}}
/>
diff --git a/src/ui/tui/primitives/GroupedPickerMenu.tsx b/src/ui/tui/primitives/GroupedPickerMenu.tsx
index 5418d9a11..533e10f8e 100644
--- a/src/ui/tui/primitives/GroupedPickerMenu.tsx
+++ b/src/ui/tui/primitives/GroupedPickerMenu.tsx
@@ -54,6 +54,12 @@ const CHROME_OVERHEAD = 10;
* single border top/bottom (2) + button text (1).
*/
const MENU_CHROME = 6;
+/**
+ * Max visual rows the list renders regardless of terminal height, so a tall
+ * terminal doesn't turn the menu into a full-screen wall. Mirrors
+ * PickerMenu's MAX_LIST_ROWS.
+ */
+const MAX_LIST_ROWS = 12;
/** Count the visual rows occupied by rows[start..end), accounting for header margins. */
function countVisualRows(rows: Row[], start: number, end: number): number {
@@ -166,7 +172,10 @@ export const GroupedPickerMenu = ({
const focusedRowIdx = selectableIndices[focusedSelectable] ?? 0;
// Viewport budget: how many visual rows can be shown
- const viewportBudget = Math.max(5, termRows - CHROME_OVERHEAD - MENU_CHROME);
+ const viewportBudget = Math.max(
+ 5,
+ Math.min(termRows - CHROME_OVERHEAD - MENU_CHROME, MAX_LIST_ROWS),
+ );
const totalVisual = countVisualRows(rows, 0, rows.length);
const needsScroll = totalVisual > viewportBudget;
const effectiveBudget = needsScroll ? viewportBudget - 2 : viewportBudget;
@@ -267,7 +276,7 @@ export const GroupedPickerMenu = ({
return (
-
+
{needsScroll && (
{hiddenAbove > 0 ? `\u2191 ${hiddenAbove} more` : ' '}
diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx
index b7a4b541e..de3a617c0 100644
--- a/src/ui/tui/primitives/PickerMenu.tsx
+++ b/src/ui/tui/primitives/PickerMenu.tsx
@@ -14,10 +14,13 @@ import { useEffect, useState } from 'react';
import { Icons, Colors } from '@ui/tui/styles';
import { PromptLabel } from './PromptLabel.js';
import { ConfirmButton } from './ConfirmButton.js';
+import { wordWrap } from './layout-helpers.js';
+import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions';
import {
useKeyBindings,
KeyMatch,
type KeyBinding,
+ type KeyMatchOrChar,
} from '@ui/tui/hooks/useKeyBindings';
interface PickerOption {
@@ -75,19 +78,101 @@ function stepEnabled(
return from;
}
-/** Index of the first enabled option, for the initial focus. */
-function firstEnabled(options: PickerOption[]): number {
- const idx = options.findIndex((o) => !o.disabled);
- return idx === -1 ? 0 : idx;
+/** First enabled option in [start, end), for initial focus and for entering
+ * a page from the Confirm button. */
+function firstEnabled(
+ options: PickerOption[],
+ start = 0,
+ end = options.length,
+): number {
+ for (let i = start; i < end; i++) {
+ if (!options[i]?.disabled) return i;
+ }
+ return start;
}
-/** Index of the last enabled option, for wrapping from the button onto
- * the bottom of the grid. */
-function lastEnabled(options: PickerOption[]): number {
- for (let i = options.length - 1; i >= 0; i--) {
+/** Last enabled option in [start, end), for wrapping from the button onto
+ * the bottom of the page. */
+function lastEnabled(
+ options: PickerOption[],
+ start = 0,
+ end = options.length,
+): number {
+ for (let i = end - 1; i >= start; i--) {
if (!options[i]?.disabled) return i;
}
- return options.length - 1;
+ return end - 1;
+}
+
+/**
+ * Rows the surrounding screen or overlay consumes above and below a picker —
+ * border + padding, title, prompt text, and the keyboard-hints bar. A
+ * deliberately generous estimate: overshooting just shows a few fewer rows,
+ * whereas undershooting lets a long list overflow the viewport (the bug this
+ * windowing guards against). Mirrors GroupedPickerMenu's budgeting.
+ */
+const CHROME_OVERHEAD = 13;
+/**
+ * Max visual rows a picker renders regardless of terminal height. Without a
+ * ceiling a tall terminal lets a long list fill the whole viewport, which
+ * reads as a wall of options; ~12 rows keeps the menu scannable and leaves
+ * known breathing room above and below.
+ */
+const MAX_LIST_ROWS = 12;
+/** Extra rows a multi-select adds below its options: marginTop + Confirm button. */
+const CONFIRM_CHROME = 3;
+/** Width the multi-select wraps option descriptions to (matches the render). */
+const DESCRIPTION_WIDTH = 56;
+
+interface PickerViewport {
+ needsScroll: boolean;
+ /** First option index on the current page. */
+ start: number;
+ /** One past the last option index on the current page. */
+ end: number;
+ hiddenAbove: number;
+ hiddenBelow: number;
+ /** Focus target one page over (wrapping), for the n/p keys. */
+ pageStep: (focused: number, dir: 1 | -1) => number;
+}
+
+/**
+ * Pages a single-column option list to the terminal height. The visible page
+ * is derived from the focused index — no scroll state — so ↑/↓ flip pages as
+ * focus crosses a page edge and n/p jump a whole page. Pages hold a fixed
+ * option count sized to the tallest row (`rowCost`), trading a sparser page
+ * on mixed-height lists for arithmetic-only paging. Engages only for
+ * single-column pickers — multi-column grids already compress vertically.
+ */
+function usePickerViewport(
+ count: number,
+ rowCost: number,
+ chromeBelow: number,
+ enabled: boolean,
+ focused: number,
+): PickerViewport {
+ const [, termRows] = useStdoutDimensions();
+ const budget = Math.max(
+ 5,
+ Math.min(termRows - CHROME_OVERHEAD - chromeBelow, MAX_LIST_ROWS),
+ );
+ const needsScroll = enabled && count * rowCost > budget;
+ // Reserve two rows for the "↑/↓ N more" indicators.
+ const perPage = needsScroll
+ ? Math.max(1, Math.floor((budget - 2) / rowCost))
+ : count;
+ const pageCount = Math.max(1, Math.ceil(count / perPage));
+ const start = Math.floor(focused / perPage) * perPage;
+ const end = Math.min(start + perPage, count);
+ return {
+ needsScroll,
+ start,
+ end,
+ hiddenAbove: start,
+ hiddenBelow: count - end,
+ pageStep: (f, dir) =>
+ ((Math.floor(f / perPage) + dir + pageCount) % pageCount) * perPage,
+ };
}
interface PickerMenuProps {
@@ -157,6 +242,14 @@ const SinglePickerMenu = ({
}) => {
const [focused, setFocused] = useState(() => firstEnabled(options));
const rows = Math.ceil(options.length / columns);
+ // Single-select rows are label-only (no descriptions): one line plus margin.
+ const viewport = usePickerViewport(
+ options.length,
+ 1 + optionMarginBottom,
+ 0,
+ columns === 1,
+ focused,
+ );
// Re-validate focus when the options change while mounted \u2014 a list
// that shrinks or disables entries can leave `focused` pointing at a
@@ -173,14 +266,39 @@ const SinglePickerMenu = ({
label: '\u2191\u2193',
action: 'navigate',
handler: (_input, key) => {
- if (key.upArrow) {
- setFocused(stepEnabled(options, rows, focused, -1));
- }
- if (key.downArrow) {
- setFocused(stepEnabled(options, rows, focused, 1));
+ const dir = key.upArrow ? -1 : 1;
+ if (columns === 1) {
+ // Wrap within the current page; pages change only via n/p.
+ const { start, end } = viewport;
+ const span = end - start;
+ let r = focused;
+ for (let i = 0; i < span; i++) {
+ r = start + ((r - start + dir + span) % span);
+ if (!options[r]?.disabled) break;
+ }
+ setFocused(r);
+ return;
}
+ setFocused(stepEnabled(options, rows, focused, dir));
},
},
+ ...(viewport.needsScroll
+ ? [
+ {
+ match: ['n', 'p'] as KeyMatchOrChar[],
+ label: 'n/p',
+ action: 'page',
+ handler: (input: string) => {
+ const target = viewport.pageStep(focused, input === 'n' ? 1 : -1);
+ setFocused(
+ options[target]?.disabled
+ ? stepEnabled(options, rows, target, 1)
+ : target,
+ );
+ },
+ },
+ ]
+ : []),
{
match: KeyMatch.Return,
label: 'enter',
@@ -232,49 +350,68 @@ const SinglePickerMenu = ({
const align = centered ? 'center' : undefined;
+ const renderOption = (opt: PickerOption, flatIdx: number) => {
+ const isFocused = flatIdx === focused;
+ const base = opt.hint ? `${opt.label} (${opt.hint})` : opt.label;
+ const label = opt.indent ? ` ${base}` : base;
+ return (
+
+
+ {isFocused && !opt.header ? Icons.triangleSmallRight : ' '}
+
+ {opt.icon && {opt.icon.glyph}}
+
+ {label}
+
+
+ );
+ };
+
return (
-
- {columnArrays.map((colOpts, colIdx) => (
-
- {colOpts.map((opt, rowIdx) => {
- const flatIdx = colIdx * rows + rowIdx;
- const isFocused = flatIdx === focused;
- const base = opt.hint ? `${opt.label} (${opt.hint})` : opt.label;
- const label = opt.indent ? ` ${base}` : base;
- return (
-
-
- {isFocused && !opt.header ? Icons.triangleSmallRight : ' '}
-
- {opt.icon && (
- {opt.icon.glyph}
- )}
-
- {label}
-
-
- );
- })}
-
- ))}
-
+ {viewport.needsScroll ? (
+
+
+ {viewport.hiddenAbove > 0
+ ? `↑ ${viewport.hiddenAbove} more [P] for previous page`
+ : ' '}
+
+ {options
+ .slice(viewport.start, viewport.end)
+ .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))}
+
+ {viewport.hiddenBelow > 0
+ ? `↓ ${viewport.hiddenBelow} more [N] for next page`
+ : ' '}
+
+
+ ) : (
+
+ {columnArrays.map((colOpts, colIdx) => (
+
+ {colOpts.map((opt, rowIdx) =>
+ renderOption(opt, colIdx * rows + rowIdx),
+ )}
+
+ ))}
+
+ )}
);
};
@@ -311,6 +448,27 @@ const MultiPickerMenu = ({
const [onButton, setOnButton] = useState(false);
const [selected, setSelected] = useState>(new Set());
const rows = Math.ceil(options.length / columns);
+ // A row is its label line plus any margin; a description adds one line per
+ // wrapped line beneath the label. Pages size to the tallest row.
+ const rowCost = options.reduce(
+ (max, opt) =>
+ Math.max(
+ max,
+ 1 +
+ optionMarginBottom +
+ (opt.description
+ ? wordWrap(opt.description, DESCRIPTION_WIDTH).length
+ : 0),
+ ),
+ 1,
+ );
+ const viewport = usePickerViewport(
+ options.length,
+ rowCost,
+ CONFIRM_CHROME,
+ columns === 1,
+ focused,
+ );
// Re-validate focus when the options change while mounted — a list
// that shrinks or disables entries can leave `focused` pointing at a
@@ -334,47 +492,69 @@ const MultiPickerMenu = ({
label: '\u2191\u2193',
action: 'navigate',
handler: (_input, key) => {
- if (key.upArrow) {
- if (onButton) {
- // Button \u2192 bottom of the grid (last enabled option).
- setOnButton(false);
- setFocused(lastEnabled(options));
- return;
- }
- const col = Math.floor(focused / rows);
- const row = focused % rows;
- // Nearest enabled option above in this column.
- let r = row - 1;
- while (r >= 0 && options[col * rows + r]?.disabled) r--;
- if (r >= 0) {
- setFocused(col * rows + r);
- } else {
- // Top of the column \u2192 wrap up onto the button.
- setOnButton(true);
- }
+ const dir = key.upArrow ? -1 : 1;
+ if (onButton) {
+ // Button \u2192 back onto the current page (bottom for \u2191, top for \u2193).
+ setOnButton(false);
+ setFocused(
+ dir === -1
+ ? lastEnabled(options, viewport.start, viewport.end)
+ : firstEnabled(options, viewport.start, viewport.end),
+ );
+ return;
}
- if (key.downArrow) {
- if (onButton) {
- // Button \u2192 top of the grid (first enabled option).
- setOnButton(false);
- setFocused(firstEnabled(options));
- return;
+ if (columns === 1) {
+ // Walk within the current page; stepping past either edge lands
+ // on the Confirm button. Pages change only via n/p.
+ let r = focused + dir;
+ while (
+ r >= viewport.start &&
+ r < viewport.end &&
+ options[r]?.disabled
+ ) {
+ r += dir;
}
- const col = Math.floor(focused / rows);
- const row = focused % rows;
- const colLen = Math.min(rows, options.length - col * rows);
- // Nearest enabled option below in this column.
- let r = row + 1;
- while (r < colLen && options[col * rows + r]?.disabled) r++;
- if (r < colLen) {
- setFocused(col * rows + r);
+ if (r >= viewport.start && r < viewport.end) {
+ setFocused(r);
} else {
- // Bottom of the column \u2192 down onto the button.
setOnButton(true);
}
+ return;
+ }
+ const col = Math.floor(focused / rows);
+ const row = focused % rows;
+ const colLen = Math.min(rows, options.length - col * rows);
+ // Nearest enabled option above/below in this column; leaving the
+ // column lands on the button.
+ let r = row + dir;
+ while (r >= 0 && r < colLen && options[col * rows + r]?.disabled) {
+ r += dir;
+ }
+ if (r >= 0 && r < colLen) {
+ setFocused(col * rows + r);
+ } else {
+ setOnButton(true);
}
},
},
+ ...(viewport.needsScroll
+ ? [
+ {
+ match: ['n', 'p'] as KeyMatchOrChar[],
+ label: 'n/p',
+ action: 'page',
+ handler: (input: string) => {
+ const target = viewport.pageStep(focused, input === 'n' ? 1 : -1);
+ setOnButton(false);
+ setFocused(
+ options[target]?.disabled
+ ? stepEnabled(options, rows, target, 1)
+ : target,
+ );
+ },
+ },
+ ]
+ : []),
{
match: [KeyMatch.Space, KeyMatch.Return],
label: 'enter',
@@ -444,72 +624,93 @@ const MultiPickerMenu = ({
columnArrays.push(options.slice(c * rows, c * rows + rows));
}
- return (
-
-
+ const renderOption = (opt: PickerOption, flatIdx: number) => {
+ const isFocused = !onButton && flatIdx === focused;
+ const isSelected = selected.has(flatIdx);
+ const label = opt.hint ? `${opt.label} (${opt.hint})` : opt.label;
+ const checkbox = isSelected ? Icons.squareFilled : Icons.squareOpen;
+ return (
- {columnArrays.map((colOpts, colIdx) => (
-
- {colOpts.map((opt, rowIdx) => {
- const flatIdx = colIdx * rows + rowIdx;
- const isFocused = !onButton && flatIdx === focused;
- const isSelected = selected.has(flatIdx);
- const label = opt.hint ? `${opt.label} (${opt.hint})` : opt.label;
- const checkbox = isSelected
- ? Icons.squareFilled
- : Icons.squareOpen;
- return (
-
-
-
- {checkbox}
-
- {opt.icon && (
- {opt.icon.glyph}
- )}
-
- {label}
-
-
- {/* Optional dimmed, wrapped explanation under the label. The
- explicit width forces Ink to wrap (an unconstrained Box
- shrinks to its content and never wraps). Renders only when
- set, so label-only rows are byte-for-byte unchanged. */}
- {opt.description && (
-
-
- {opt.description}
-
-
- )}
-
- );
- })}
+
+
+ {checkbox}
+
+ {opt.icon && {opt.icon.glyph}}
+
+ {label}
+
+
+ {/* Optional dimmed, wrapped explanation under the label. The explicit
+ width forces Ink to wrap (an unconstrained Box shrinks to its
+ content and never wraps). Renders only when set, so label-only rows
+ are byte-for-byte unchanged. */}
+ {opt.description && (
+
+
+ {opt.description}
+
- ))}
+ )}
+ );
+ };
+
+ return (
+
+
+ {viewport.needsScroll ? (
+
+
+ {viewport.hiddenAbove > 0
+ ? `↑ ${viewport.hiddenAbove} more [P] for previous page`
+ : ' '}
+
+ {options
+ .slice(viewport.start, viewport.end)
+ .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))}
+
+ {viewport.hiddenBelow > 0
+ ? `↓ ${viewport.hiddenBelow} more [N] for next page`
+ : ' '}
+
+
+ ) : (
+
+ {columnArrays.map((colOpts, colIdx) => (
+
+ {colOpts.map((opt, rowIdx) =>
+ renderOption(opt, colIdx * rows + rowIdx),
+ )}
+
+ ))}
+
+ )}
diff --git a/src/ui/tui/primitives/PromptLabel.tsx b/src/ui/tui/primitives/PromptLabel.tsx
index 973e081a0..84603c893 100644
--- a/src/ui/tui/primitives/PromptLabel.tsx
+++ b/src/ui/tui/primitives/PromptLabel.tsx
@@ -13,6 +13,8 @@ interface PromptLabelProps {
}
export const PromptLabel = ({ message }: PromptLabelProps) => {
+ // No message → no row, so label-less pickers don't get a stray blank line.
+ if (!message) return null;
return (