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 (