From e3f5de6b6535b909e39d9277478ad9a96924a5d8 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" <29069505+gewenyu99@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:45:04 -0400 Subject: [PATCH 1/9] fix(tui): scroll long picker lists so they don't overflow the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-column PickerMenu (single- and multi-select) rendered every option unconditionally, so a long list — e.g. the self-driving "connected tools" multi-select with 30+ options — overflowed the terminal and pushed the Confirm button off screen. Add terminal-height-aware windowing to PickerMenu, mirroring the existing GroupedPickerMenu pattern: a usePickerViewport hook derives a row budget from the terminal height, scrolls the visible window to keep the focused row in view, and renders "↑/↓ N more" indicators. Windowing engages only for single-column pickers that don't fit; short lists and multi-column grids render exactly as before. Generated-By: PostHog Code Task-Id: f40c1784-7368-4811-83d0-2d69a844b6c5 --- src/ui/tui/primitives/PickerMenu.tsx | 376 +++++++++++++++++++-------- 1 file changed, 268 insertions(+), 108 deletions(-) diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index b7a4b541e..2553af251 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -14,6 +14,8 @@ 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, @@ -90,6 +92,109 @@ function lastEnabled(options: PickerOption[]): number { return options.length - 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; +/** 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; + +/** + * From `start`, how many options fit within `budget` visual rows, where each + * option's height is given by `costs`. Always yields at least the start row so + * the focused option is never windowed out entirely. + */ +function windowEnd(costs: number[], start: number, budget: number): number { + let used = 0; + let i = start; + while (i < costs.length) { + if (used + costs[i] > budget && i > start) break; + used += costs[i]; + i++; + } + return Math.max(i, start + 1); +} + +/** Shift the scroll offset the minimum needed to keep `focused` in view. */ +function keepFocusedVisible( + offset: number, + focused: number, + costs: number[], + budget: number, +): number { + const end = windowEnd(costs, offset, budget); + if (focused >= offset && focused < end) return offset; // already visible + if (focused < offset) return focused; // scrolled off the top → align to focus + // Scrolled off the bottom → advance the offset until focus fits again. + let next = offset + 1; + while (next < costs.length && focused >= windowEnd(costs, next, budget)) { + next++; + } + return Math.min(next, Math.max(0, costs.length - 1)); +} + +interface PickerViewport { + needsScroll: boolean; + /** First option index in the visible window. */ + start: number; + /** One past the last visible option index. */ + end: number; + hiddenAbove: number; + hiddenBelow: number; + /** Call from a navigation handler with the new focused index to scroll it + * into view. A no-op when the whole list already fits. */ + scrollTo: (focused: number) => void; +} + +/** + * Windows a single-column option list to the terminal height, scrolling to + * follow the focused row so long lists (e.g. a 30-tool multi-select) don't + * overflow the viewport. Windowing engages only for single-column pickers + * (`enabled`) — multi-column grids already compress vertically and render + * whole — and only when the list is taller than the available space. + */ +function usePickerViewport( + costs: number[], + chromeBelow: number, + enabled: boolean, +): PickerViewport { + const [, termRows] = useStdoutDimensions(); + const [offset, setOffset] = useState(0); + + const budget = Math.max(5, termRows - CHROME_OVERHEAD - chromeBelow); + const total = costs.reduce((sum, c) => sum + c, 0); + const needsScroll = enabled && total > budget; + // Reserve two rows for the "↑/↓ N more" indicators. + const effectiveBudget = needsScroll ? budget - 2 : budget; + + const start = needsScroll + ? Math.min(offset, Math.max(0, costs.length - 1)) + : 0; + const end = needsScroll + ? windowEnd(costs, start, effectiveBudget) + : costs.length; + + return { + needsScroll, + start, + end, + hiddenAbove: start, + hiddenBelow: costs.length - end, + scrollTo: (focused) => { + if (!needsScroll) return; + setOffset((prev) => + keepFocusedVisible(prev, focused, costs, effectiveBudget), + ); + }, + }; +} + interface PickerMenuProps { message?: string; options: PickerOption[]; @@ -157,6 +262,10 @@ const SinglePickerMenu = ({ }) => { const [focused, setFocused] = useState(() => firstEnabled(options)); const rows = Math.ceil(options.length / columns); + // Single-select rows are label-only (no descriptions), so each option is + // one line plus its margin. + const costs = options.map(() => 1 + optionMarginBottom); + const viewport = usePickerViewport(costs, 0, columns === 1); // Re-validate focus when the options change while mounted \u2014 a list // that shrinks or disables entries can leave `focused` pointing at a @@ -174,10 +283,14 @@ const SinglePickerMenu = ({ action: 'navigate', handler: (_input, key) => { if (key.upArrow) { - setFocused(stepEnabled(options, rows, focused, -1)); + const next = stepEnabled(options, rows, focused, -1); + setFocused(next); + viewport.scrollTo(next); } if (key.downArrow) { - setFocused(stepEnabled(options, rows, focused, 1)); + const next = stepEnabled(options, rows, focused, 1); + setFocused(next); + viewport.scrollTo(next); } }, }, @@ -232,49 +345,64 @@ 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` : ' '} + + {options + .slice(viewport.start, viewport.end) + .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))} + + {viewport.hiddenBelow > 0 ? `↓ ${viewport.hiddenBelow} more` : ' '} + + + ) : ( + + {columnArrays.map((colOpts, colIdx) => ( + + {colOpts.map((opt, rowIdx) => + renderOption(opt, colIdx * rows + rowIdx), + )} + + ))} + + )} ); }; @@ -311,6 +439,17 @@ 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. + const costs = options.map( + (opt) => + 1 + + optionMarginBottom + + (opt.description + ? wordWrap(opt.description, DESCRIPTION_WIDTH).length + : 0), + ); + const viewport = usePickerViewport(costs, CONFIRM_CHROME, columns === 1); // Re-validate focus when the options change while mounted — a list // that shrinks or disables entries can leave `focused` pointing at a @@ -337,8 +476,10 @@ const MultiPickerMenu = ({ if (key.upArrow) { if (onButton) { // Button \u2192 bottom of the grid (last enabled option). + const next = lastEnabled(options); setOnButton(false); - setFocused(lastEnabled(options)); + setFocused(next); + viewport.scrollTo(next); return; } const col = Math.floor(focused / rows); @@ -347,7 +488,9 @@ const MultiPickerMenu = ({ let r = row - 1; while (r >= 0 && options[col * rows + r]?.disabled) r--; if (r >= 0) { - setFocused(col * rows + r); + const next = col * rows + r; + setFocused(next); + viewport.scrollTo(next); } else { // Top of the column \u2192 wrap up onto the button. setOnButton(true); @@ -356,8 +499,10 @@ const MultiPickerMenu = ({ if (key.downArrow) { if (onButton) { // Button \u2192 top of the grid (first enabled option). + const next = firstEnabled(options); setOnButton(false); - setFocused(firstEnabled(options)); + setFocused(next); + viewport.scrollTo(next); return; } const col = Math.floor(focused / rows); @@ -367,7 +512,9 @@ const MultiPickerMenu = ({ let r = row + 1; while (r < colLen && options[col * rows + r]?.disabled) r++; if (r < colLen) { - setFocused(col * rows + r); + const next = col * rows + r; + setFocused(next); + viewport.scrollTo(next); } else { // Bottom of the column \u2192 down onto the button. setOnButton(true); @@ -444,72 +591,85 @@ 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` : ' '} + + {options + .slice(viewport.start, viewport.end) + .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))} + + {viewport.hiddenBelow > 0 ? `↓ ${viewport.hiddenBelow} more` : ' '} + + + ) : ( + + {columnArrays.map((colOpts, colIdx) => ( + + {colOpts.map((opt, rowIdx) => + renderOption(opt, colIdx * rows + rowIdx), + )} + + ))} + + )} From 17b9b309812a05087e125692ebdbd63499dd9154 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" <29069505+gewenyu99@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:54:22 -0400 Subject: [PATCH 2/9] test(playground): add a long multi-select to exercise picker scrolling The Input demo's multi-select had only 4 options, so it never triggered PickerMenu's viewport windowing. Add a MultiLong step with 30+ options so the playground demonstrates the scroll indicators and cursor-following window introduced in this PR. Generated-By: PostHog Code Task-Id: f40c1784-7368-4811-83d0-2d69a844b6c5 --- src/ui/tui/playground/demos/InputDemo.tsx | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/ui/tui/playground/demos/InputDemo.tsx b/src/ui/tui/playground/demos/InputDemo.tsx index 442a80087..c79300d92 100644 --- a/src/ui/tui/playground/demos/InputDemo.tsx +++ b/src/ui/tui/playground/demos/InputDemo.tsx @@ -10,10 +10,54 @@ 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 scrolling +// (the "↑/↓ N more" indicators and cursor-following window). +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 +104,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); }} /> From 0c8bf287b6fb7f8cccb216f9445a04f99ebf755d Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 19:59:45 -0400 Subject: [PATCH 3/9] fix(tui): cap picker list height so tall terminals don't render a wall The scroll window sized itself to the full terminal height, so on a tall terminal a long picker filled the whole viewport. Clamp the visual budget to 12 rows in PickerMenu and GroupedPickerMenu, keeping known breathing room above and below regardless of terminal size. Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/primitives/GroupedPickerMenu.tsx | 11 ++++++++++- src/ui/tui/primitives/PickerMenu.tsx | 12 +++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/ui/tui/primitives/GroupedPickerMenu.tsx b/src/ui/tui/primitives/GroupedPickerMenu.tsx index 5418d9a11..4616218b9 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; diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index 2553af251..0e11a1f7d 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -100,6 +100,13 @@ function lastEnabled(options: PickerOption[]): number { * 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). */ @@ -167,7 +174,10 @@ function usePickerViewport( const [, termRows] = useStdoutDimensions(); const [offset, setOffset] = useState(0); - const budget = Math.max(5, termRows - CHROME_OVERHEAD - chromeBelow); + const budget = Math.max( + 5, + Math.min(termRows - CHROME_OVERHEAD - chromeBelow, MAX_LIST_ROWS), + ); const total = costs.reduce((sum, c) => sum + c, 0); const needsScroll = enabled && total > budget; // Reserve two rows for the "↑/↓ N more" indicators. From 62a36db4f7f7abfdb7276e095c3da6cb5ac10333 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 20:11:14 -0400 Subject: [PATCH 4/9] fix(tui): drop dead blank lines above label-less pickers PromptLabel rendered an empty row when no message was set, and the multi picker always added marginTop below it, so ask-modal pickers (which render the prompt in the modal body) opened with four blank lines before the first option. Render no label row without a message and gate the marginTop on it. Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/primitives/PickerMenu.tsx | 8 ++++++-- src/ui/tui/primitives/PromptLabel.tsx | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index 0e11a1f7d..bb5dbeefb 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -653,7 +653,11 @@ const MultiPickerMenu = ({ {viewport.needsScroll ? ( - + {viewport.hiddenAbove > 0 ? `↑ ${viewport.hiddenAbove} more` : ' '} @@ -669,7 +673,7 @@ const MultiPickerMenu = ({ flexDirection="row" gap={4} marginLeft={centered ? 0 : 2} - marginTop={1} + marginTop={message ? 1 : 0} > {columnArrays.map((colOpts, colIdx) => ( 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 ( From 95fa52bc10ca5d93929aca5c462697c538d8e378 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 20:16:53 -0400 Subject: [PATCH 5/9] test(playground): add an Ask modal tab rendering ask-style pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the WizardAskScreen composition (ModalOverlay + paragraph + label-less picker) across four variants — long scrolling multi-select, multi-select with descriptions, single-select, and GroupedPickerMenu — so height capping and spacing are eyeballable in the playground. Also gate GroupedPickerMenu's label marginTop on an actual message, matching PickerMenu. Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/playground/PlaygroundApp.tsx | 2 + src/ui/tui/playground/demos/AskModalDemo.tsx | 224 +++++++++++++++++++ src/ui/tui/primitives/GroupedPickerMenu.tsx | 2 +- 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 src/ui/tui/playground/demos/AskModalDemo.tsx 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..67be11b1c --- /dev/null +++ b/src/ui/tui/playground/demos/AskModalDemo.tsx @@ -0,0 +1,224 @@ +/** + * 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'; + +enum DemoStep { + MultiLong = 'multi-long', + MultiDescriptions = 'multi-descriptions', + Single = 'single', + Grouped = 'grouped', + Done = 'done', +} + +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, '-') })); + +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/primitives/GroupedPickerMenu.tsx b/src/ui/tui/primitives/GroupedPickerMenu.tsx index 4616218b9..533e10f8e 100644 --- a/src/ui/tui/primitives/GroupedPickerMenu.tsx +++ b/src/ui/tui/primitives/GroupedPickerMenu.tsx @@ -276,7 +276,7 @@ export const GroupedPickerMenu = ({ return ( - + {needsScroll && ( {hiddenAbove > 0 ? `\u2191 ${hiddenAbove} more` : ' '} From ea622cde58d068780fc206526b3ff3b6b427fb45 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 20:31:16 -0400 Subject: [PATCH 6/9] refactor(tui): replace picker scroll-follow with derived paging + n/p keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll window kept offset state and threaded scrollTo through every navigation handler. Derive the visible page from the focused index instead: no state, ↑/↓ flip pages as focus crosses an edge, and n/p jump a whole page (advertised in the hints bar and the "more" indicators). Also dedupe LONG_OPTIONS into a shared export. Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/playground/demos/AskModalDemo.tsx | 41 +--- src/ui/tui/playground/demos/InputDemo.tsx | 7 +- src/ui/tui/primitives/PickerMenu.tsx | 195 +++++++++++-------- 3 files changed, 121 insertions(+), 122 deletions(-) diff --git a/src/ui/tui/playground/demos/AskModalDemo.tsx b/src/ui/tui/playground/demos/AskModalDemo.tsx index 67be11b1c..7b4132963 100644 --- a/src/ui/tui/playground/demos/AskModalDemo.tsx +++ b/src/ui/tui/playground/demos/AskModalDemo.tsx @@ -13,6 +13,7 @@ import { GroupedPickerMenu, } from '@ui/tui/primitives/index'; import { Colors, Icons } from '@ui/tui/styles'; +import { LONG_OPTIONS } from './InputDemo.js'; enum DemoStep { MultiLong = 'multi-long', @@ -22,46 +23,6 @@ enum DemoStep { Done = 'done', } -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, '-') })); - const DESCRIBED_OPTIONS = [ { label: 'Session replay', diff --git a/src/ui/tui/playground/demos/InputDemo.tsx b/src/ui/tui/playground/demos/InputDemo.tsx index c79300d92..3e941aea2 100644 --- a/src/ui/tui/playground/demos/InputDemo.tsx +++ b/src/ui/tui/playground/demos/InputDemo.tsx @@ -16,9 +16,10 @@ enum DemoStep { } // A long single-column multi-select — more options than a normal terminal can -// show at once — so the playground exercises PickerMenu's viewport scrolling -// (the "↑/↓ N more" indicators and cursor-following window). -const LONG_OPTIONS = [ +// 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', diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index bb5dbeefb..6b565737c 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -20,6 +20,7 @@ import { useKeyBindings, KeyMatch, type KeyBinding, + type KeyMatchOrChar, } from '@ui/tui/hooks/useKeyBindings'; interface PickerOption { @@ -113,66 +114,58 @@ const CONFIRM_CHROME = 3; const DESCRIPTION_WIDTH = 56; /** - * From `start`, how many options fit within `budget` visual rows, where each - * option's height is given by `costs`. Always yields at least the start row so - * the focused option is never windowed out entirely. + * Chunk options into pages, each holding as many options as fit in `budget` + * visual rows (each option's height given by `costs`). Every page holds at + * least one option so an oversized row can't stall the chunking. */ -function windowEnd(costs: number[], start: number, budget: number): number { - let used = 0; - let i = start; - while (i < costs.length) { - if (used + costs[i] > budget && i > start) break; - used += costs[i]; - i++; - } - return Math.max(i, start + 1); -} - -/** Shift the scroll offset the minimum needed to keep `focused` in view. */ -function keepFocusedVisible( - offset: number, - focused: number, +function computePages( costs: number[], budget: number, -): number { - const end = windowEnd(costs, offset, budget); - if (focused >= offset && focused < end) return offset; // already visible - if (focused < offset) return focused; // scrolled off the top → align to focus - // Scrolled off the bottom → advance the offset until focus fits again. - let next = offset + 1; - while (next < costs.length && focused >= windowEnd(costs, next, budget)) { - next++; +): { start: number; end: number }[] { + const pages: { start: number; end: number }[] = []; + let start = 0; + while (start < costs.length) { + let used = 0; + let end = start; + while (end < costs.length) { + if (used + costs[end] > budget && end > start) break; + used += costs[end]; + end++; + } + pages.push({ start, end }); + start = end; } - return Math.min(next, Math.max(0, costs.length - 1)); + return pages.length ? pages : [{ start: 0, end: 0 }]; } interface PickerViewport { needsScroll: boolean; - /** First option index in the visible window. */ + /** First option index on the current page. */ start: number; - /** One past the last visible option index. */ + /** One past the last option index on the current page. */ end: number; hiddenAbove: number; hiddenBelow: number; - /** Call from a navigation handler with the new focused index to scroll it - * into view. A no-op when the whole list already fits. */ - scrollTo: (focused: number) => void; + /** First enabled option on the next/previous page (wrapping), for the + * n/p page-jump keys. Meaningless when !needsScroll. */ + pageStep: (focused: number, dir: 1 | -1) => number; } /** - * Windows a single-column option list to the terminal height, scrolling to - * follow the focused row so long lists (e.g. a 30-tool multi-select) don't - * overflow the viewport. Windowing engages only for single-column pickers - * (`enabled`) — multi-column grids already compress vertically and render - * whole — and only when the list is taller than the available space. + * 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 + * naturally as focus crosses a page edge, and n/p jump a whole page. Paging + * engages only for single-column pickers (`enabled`) — multi-column grids + * already compress vertically — and only when the list is taller than the + * available space. */ function usePickerViewport( costs: number[], chromeBelow: number, enabled: boolean, + focused: number, ): PickerViewport { const [, termRows] = useStdoutDimensions(); - const [offset, setOffset] = useState(0); const budget = Math.max( 5, @@ -180,27 +173,35 @@ function usePickerViewport( ); const total = costs.reduce((sum, c) => sum + c, 0); const needsScroll = enabled && total > budget; - // Reserve two rows for the "↑/↓ N more" indicators. - const effectiveBudget = needsScroll ? budget - 2 : budget; + if (!needsScroll) { + return { + needsScroll, + start: 0, + end: costs.length, + hiddenAbove: 0, + hiddenBelow: 0, + pageStep: (f) => f, + }; + } - const start = needsScroll - ? Math.min(offset, Math.max(0, costs.length - 1)) - : 0; - const end = needsScroll - ? windowEnd(costs, start, effectiveBudget) - : costs.length; + // Reserve two rows for the "↑/↓ N more" indicators. + const pages = computePages(costs, budget - 2); + const pageOf = (idx: number) => + Math.max( + 0, + pages.findIndex((p) => idx >= p.start && idx < p.end), + ); + const page = pages[pageOf(focused)]; return { needsScroll, - start, - end, - hiddenAbove: start, - hiddenBelow: costs.length - end, - scrollTo: (focused) => { - if (!needsScroll) return; - setOffset((prev) => - keepFocusedVisible(prev, focused, costs, effectiveBudget), - ); + start: page.start, + end: page.end, + hiddenAbove: page.start, + hiddenBelow: costs.length - page.end, + pageStep: (f, dir) => { + const target = pages[(pageOf(f) + dir + pages.length) % pages.length]; + return target.start; }, }; } @@ -275,7 +276,7 @@ const SinglePickerMenu = ({ // Single-select rows are label-only (no descriptions), so each option is // one line plus its margin. const costs = options.map(() => 1 + optionMarginBottom); - const viewport = usePickerViewport(costs, 0, columns === 1); + const viewport = usePickerViewport(costs, 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 @@ -293,17 +294,30 @@ const SinglePickerMenu = ({ action: 'navigate', handler: (_input, key) => { if (key.upArrow) { - const next = stepEnabled(options, rows, focused, -1); - setFocused(next); - viewport.scrollTo(next); + setFocused(stepEnabled(options, rows, focused, -1)); } if (key.downArrow) { - const next = stepEnabled(options, rows, focused, 1); - setFocused(next); - viewport.scrollTo(next); + setFocused(stepEnabled(options, rows, focused, 1)); } }, }, + ...(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', @@ -393,13 +407,17 @@ const SinglePickerMenu = ({ {viewport.needsScroll ? ( - {viewport.hiddenAbove > 0 ? `↑ ${viewport.hiddenAbove} more` : ' '} + {viewport.hiddenAbove > 0 + ? `↑ ${viewport.hiddenAbove} more (p)` + : ' '} {options .slice(viewport.start, viewport.end) .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))} - {viewport.hiddenBelow > 0 ? `↓ ${viewport.hiddenBelow} more` : ' '} + {viewport.hiddenBelow > 0 + ? `↓ ${viewport.hiddenBelow} more (n)` + : ' '} ) : ( @@ -459,7 +477,12 @@ const MultiPickerMenu = ({ ? wordWrap(opt.description, DESCRIPTION_WIDTH).length : 0), ); - const viewport = usePickerViewport(costs, CONFIRM_CHROME, columns === 1); + const viewport = usePickerViewport( + costs, + 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 @@ -486,10 +509,8 @@ const MultiPickerMenu = ({ if (key.upArrow) { if (onButton) { // Button \u2192 bottom of the grid (last enabled option). - const next = lastEnabled(options); setOnButton(false); - setFocused(next); - viewport.scrollTo(next); + setFocused(lastEnabled(options)); return; } const col = Math.floor(focused / rows); @@ -498,9 +519,7 @@ const MultiPickerMenu = ({ let r = row - 1; while (r >= 0 && options[col * rows + r]?.disabled) r--; if (r >= 0) { - const next = col * rows + r; - setFocused(next); - viewport.scrollTo(next); + setFocused(col * rows + r); } else { // Top of the column \u2192 wrap up onto the button. setOnButton(true); @@ -509,10 +528,8 @@ const MultiPickerMenu = ({ if (key.downArrow) { if (onButton) { // Button \u2192 top of the grid (first enabled option). - const next = firstEnabled(options); setOnButton(false); - setFocused(next); - viewport.scrollTo(next); + setFocused(firstEnabled(options)); return; } const col = Math.floor(focused / rows); @@ -522,9 +539,7 @@ const MultiPickerMenu = ({ let r = row + 1; while (r < colLen && options[col * rows + r]?.disabled) r++; if (r < colLen) { - const next = col * rows + r; - setFocused(next); - viewport.scrollTo(next); + setFocused(col * rows + r); } else { // Bottom of the column \u2192 down onto the button. setOnButton(true); @@ -532,6 +547,24 @@ const MultiPickerMenu = ({ } }, }, + ...(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', @@ -659,13 +692,17 @@ const MultiPickerMenu = ({ marginTop={message ? 1 : 0} > - {viewport.hiddenAbove > 0 ? `↑ ${viewport.hiddenAbove} more` : ' '} + {viewport.hiddenAbove > 0 + ? `↑ ${viewport.hiddenAbove} more (p)` + : ' '} {options .slice(viewport.start, viewport.end) .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))} - {viewport.hiddenBelow > 0 ? `↓ ${viewport.hiddenBelow} more` : ' '} + {viewport.hiddenBelow > 0 + ? `↓ ${viewport.hiddenBelow} more (n)` + : ' '} ) : ( From db7ebd1d26b5ab228c202a46d8d3b57ead929cb8 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 20:38:34 -0400 Subject: [PATCH 7/9] feat(tui): s submits multi-selects directly; paging is plain arithmetic Reaching Confirm in a long multi-select meant walking the whole list (or knowing the hidden up-wrap). Bind s to submit the current selection from anywhere, in both MultiPickerMenu and GroupedPickerMenu, advertised in the hints bar. Pages now hold a fixed option count sized to the tallest row, replacing per-option cost chunking with arithmetic. Key the demo pickers per step so selection state doesn't bleed between steps. Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/playground/demos/AskModalDemo.tsx | 2 + src/ui/tui/primitives/GroupedPickerMenu.tsx | 6 + src/ui/tui/primitives/PickerMenu.tsx | 125 ++++++++----------- 3 files changed, 57 insertions(+), 76 deletions(-) diff --git a/src/ui/tui/playground/demos/AskModalDemo.tsx b/src/ui/tui/playground/demos/AskModalDemo.tsx index 7b4132963..6f62268f6 100644 --- a/src/ui/tui/playground/demos/AskModalDemo.tsx +++ b/src/ui/tui/playground/demos/AskModalDemo.tsx @@ -104,6 +104,7 @@ export const AskModalDemo = () => { return ( { @@ -122,6 +123,7 @@ export const AskModalDemo = () => { return ( onSelect([...selected]), + }, { match: 'a', label: 'a', diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index 6b565737c..d221b333d 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -113,31 +113,6 @@ const CONFIRM_CHROME = 3; /** Width the multi-select wraps option descriptions to (matches the render). */ const DESCRIPTION_WIDTH = 56; -/** - * Chunk options into pages, each holding as many options as fit in `budget` - * visual rows (each option's height given by `costs`). Every page holds at - * least one option so an oversized row can't stall the chunking. - */ -function computePages( - costs: number[], - budget: number, -): { start: number; end: number }[] { - const pages: { start: number; end: number }[] = []; - let start = 0; - while (start < costs.length) { - let used = 0; - let end = start; - while (end < costs.length) { - if (used + costs[end] > budget && end > start) break; - used += costs[end]; - end++; - } - pages.push({ start, end }); - start = end; - } - return pages.length ? pages : [{ start: 0, end: 0 }]; -} - interface PickerViewport { needsScroll: boolean; /** First option index on the current page. */ @@ -146,63 +121,46 @@ interface PickerViewport { end: number; hiddenAbove: number; hiddenBelow: number; - /** First enabled option on the next/previous page (wrapping), for the - * n/p page-jump keys. Meaningless when !needsScroll. */ + /** 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 - * naturally as focus crosses a page edge, and n/p jump a whole page. Paging - * engages only for single-column pickers (`enabled`) — multi-column grids - * already compress vertically — and only when the list is taller than the - * available space. + * 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( - costs: number[], + 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 total = costs.reduce((sum, c) => sum + c, 0); - const needsScroll = enabled && total > budget; - if (!needsScroll) { - return { - needsScroll, - start: 0, - end: costs.length, - hiddenAbove: 0, - hiddenBelow: 0, - pageStep: (f) => f, - }; - } - + const needsScroll = enabled && count * rowCost > budget; // Reserve two rows for the "↑/↓ N more" indicators. - const pages = computePages(costs, budget - 2); - const pageOf = (idx: number) => - Math.max( - 0, - pages.findIndex((p) => idx >= p.start && idx < p.end), - ); - const page = pages[pageOf(focused)]; - + 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: page.start, - end: page.end, - hiddenAbove: page.start, - hiddenBelow: costs.length - page.end, - pageStep: (f, dir) => { - const target = pages[(pageOf(f) + dir + pages.length) % pages.length]; - return target.start; - }, + start, + end, + hiddenAbove: start, + hiddenBelow: count - end, + pageStep: (f, dir) => + ((Math.floor(f / perPage) + dir + pageCount) % pageCount) * perPage, }; } @@ -273,10 +231,14 @@ const SinglePickerMenu = ({ }) => { const [focused, setFocused] = useState(() => firstEnabled(options)); const rows = Math.ceil(options.length / columns); - // Single-select rows are label-only (no descriptions), so each option is - // one line plus its margin. - const costs = options.map(() => 1 + optionMarginBottom); - const viewport = usePickerViewport(costs, 0, columns === 1, focused); + // 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 @@ -468,17 +430,22 @@ const MultiPickerMenu = ({ 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. - const costs = options.map( - (opt) => - 1 + - optionMarginBottom + - (opt.description - ? wordWrap(opt.description, DESCRIPTION_WIDTH).length - : 0), + // 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( - costs, + options.length, + rowCost, CONFIRM_CHROME, columns === 1, focused, @@ -596,6 +563,12 @@ const MultiPickerMenu = ({ }); }, }, + { + match: 's', + label: 's', + action: 'submit', + handler: confirm, + }, ]; if (columns > 1) { From bec49337678175dd9c599bd88d52137ee6ae0bb9 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 20:43:15 -0400 Subject: [PATCH 8/9] fix(tui): arrows stay on the page; Confirm is one step past either edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the s-submit shortcut. In paged pickers ↑/↓ no longer walk the whole list: they move within the visible page, and stepping past either edge lands on the Confirm button. Pages change only via n/p. Single-select wraps within the page. The up/down handlers collapse to one direction- parameterized path. Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/primitives/GroupedPickerMenu.tsx | 6 - src/ui/tui/primitives/PickerMenu.tsx | 123 +++++++++++--------- 2 files changed, 70 insertions(+), 59 deletions(-) diff --git a/src/ui/tui/primitives/GroupedPickerMenu.tsx b/src/ui/tui/primitives/GroupedPickerMenu.tsx index b21ae7d3b..533e10f8e 100644 --- a/src/ui/tui/primitives/GroupedPickerMenu.tsx +++ b/src/ui/tui/primitives/GroupedPickerMenu.tsx @@ -244,12 +244,6 @@ export const GroupedPickerMenu = ({ } }, }, - { - match: 's', - label: 's', - action: 'submit', - handler: () => onSelect([...selected]), - }, { match: 'a', label: 'a', diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index d221b333d..df92f0218 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -78,19 +78,30 @@ 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; } /** @@ -255,12 +266,20 @@ 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 @@ -473,44 +492,48 @@ 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); } }, }, @@ -563,12 +586,6 @@ const MultiPickerMenu = ({ }); }, }, - { - match: 's', - label: 's', - action: 'submit', - handler: confirm, - }, ]; if (columns > 1) { From 4d56d464efe70237cf05796b9dfbdb80dce01036 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 24 Jul 2026 20:45:35 -0400 Subject: [PATCH 9/9] style(tui): spell out the page keys in the picker indicators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "↑ 10 more [P] for previous page" / "↓ 27 more [N] for next page". Generated-By: PostHog Code Task-Id: 64dfb953-a7c2-44d2-a241-89f06bf7e4f7 --- src/ui/tui/primitives/PickerMenu.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ui/tui/primitives/PickerMenu.tsx b/src/ui/tui/primitives/PickerMenu.tsx index df92f0218..de3a617c0 100644 --- a/src/ui/tui/primitives/PickerMenu.tsx +++ b/src/ui/tui/primitives/PickerMenu.tsx @@ -389,7 +389,7 @@ const SinglePickerMenu = ({ {viewport.hiddenAbove > 0 - ? `↑ ${viewport.hiddenAbove} more (p)` + ? `↑ ${viewport.hiddenAbove} more [P] for previous page` : ' '} {options @@ -397,7 +397,7 @@ const SinglePickerMenu = ({ .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))} {viewport.hiddenBelow > 0 - ? `↓ ${viewport.hiddenBelow} more (n)` + ? `↓ ${viewport.hiddenBelow} more [N] for next page` : ' '} @@ -683,7 +683,7 @@ const MultiPickerMenu = ({ > {viewport.hiddenAbove > 0 - ? `↑ ${viewport.hiddenAbove} more (p)` + ? `↑ ${viewport.hiddenAbove} more [P] for previous page` : ' '} {options @@ -691,7 +691,7 @@ const MultiPickerMenu = ({ .map((opt, relIdx) => renderOption(opt, viewport.start + relIdx))} {viewport.hiddenBelow > 0 - ? `↓ ${viewport.hiddenBelow} more (n)` + ? `↓ ${viewport.hiddenBelow} more [N] for next page` : ' '}