Skip to content

Commit b7bb4fc

Browse files
theoephraimCI
andauthored
fix: viewport scrolling in bumpy add prompt (#99)
## Summary Fix [#96](#96) — `bumpy add` snapped focus to the bottom when navigating a package list taller than the terminal. The interactive bump-select prompt rendered every package at once and used `\x1B[NA` (cursor up) to clear the previous frame before each re-render. Once the rendered output exceeded terminal height, earlier lines had scrolled off-screen and the cursor-up no longer reached the original anchor — so subsequent frames drew at the wrong position. **Approach** — windowed viewport rendering: - Build a flat row structure (`Changed` header → changed items → separator → `Unchanged` header → unchanged items) with a `displayIdx → rowIdx` map - Compute window size from `stdout.rows` minus fixed chrome (header + footer) - Track a `scroll` offset that adjusts to keep the focused row inside the window - Show `▲ N more` / `▼ N more` indicators when content is hidden above/below - Pin a sticky section header to the top of the window once the natural `Changed` or `Unchanged` header scrolls out of view, so the user always knows which section they're in - Re-render on `SIGWINCH` so resizing reflows the viewport Total rendered output is now bounded by terminal height, so the cursor-up redraw always works. ## Test plan - [x] `bun run check` (lint + format + typecheck) - [x] `bun run test` (258 tests passing) - [x] Manual: tested locally in a 30-package pnpm workspace with 5 packages marked changed - Cursor stays visible when navigating past viewport edges - Changing levels with `←/→` from a scrolled position does not snap focus to terminal bottom - `▲ N more` / `▼ N more` indicators appear correctly - Sticky `Unchanged` header pins once the natural header scrolls off - Resizing the terminal mid-prompt reflows the viewport Co-authored-by: CI <ci@example.com>
1 parent eb0f9da commit b7bb4fc

2 files changed

Lines changed: 142 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Fix scrolling in `bumpy add` when there are many packages. The interactive bump-select prompt now renders a viewport that fits within the terminal, scrolling the package list (with `▲ N more` / `▼ N more` indicators) as the cursor moves. Previously, when the list exceeded terminal height, navigating up would snap the cursor back to the bottom because the redraw cursor-up lost its anchor once content scrolled off-screen. Closes #96.

packages/bumpy/src/prompts/bump-select.ts

Lines changed: 137 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,22 +20,58 @@ export interface BumpSelectResult {
2020
type: BumpTypeWithNone;
2121
}
2222

23+
type Row =
24+
| { kind: 'header'; text: string }
25+
| { kind: 'separator' }
26+
| { kind: 'item'; itemIdx: number; displayIdx: number };
27+
2328
/**
2429
* Custom interactive prompt for selecting bump levels for multiple packages.
2530
* - Up/Down arrows to navigate between packages
2631
* - Left/Right arrows to change the bump level
2732
* - Changed packages default to "patch", unchanged to "none"
2833
* - Enter to confirm
2934
* - Ctrl+C / Escape to cancel
35+
*
36+
* Renders a viewport that fits within the terminal so the list scrolls instead of
37+
* overflowing — otherwise large package counts cause the redraw cursor-up to lose
38+
* its anchor once content scrolls off-screen.
3039
*/
3140
export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSelectResult[] | symbol> {
3241
// Build display order: changed first, then unchanged
3342
const changedEntries = items.map((item, idx) => ({ item, idx })).filter(({ item }) => item.changed);
3443
const unchangedEntries = items.map((item, idx) => ({ item, idx })).filter(({ item }) => !item.changed);
3544
const displayOrder = [...changedEntries, ...unchangedEntries];
3645

46+
// Build a flat list of rows (headers, separators, items) — static structure used for windowing.
47+
const rows: Row[] = [];
48+
const itemRowIndex: number[] = []; // displayIdx -> index into rows
49+
{
50+
let displayIdx = 0;
51+
if (changedEntries.length > 0) {
52+
rows.push({ kind: 'header', text: 'Changed' });
53+
for (const { idx } of changedEntries) {
54+
itemRowIndex.push(rows.length);
55+
rows.push({ kind: 'item', itemIdx: idx, displayIdx });
56+
displayIdx++;
57+
}
58+
if (unchangedEntries.length > 0) {
59+
rows.push({ kind: 'separator' });
60+
}
61+
}
62+
if (unchangedEntries.length > 0) {
63+
rows.push({ kind: 'header', text: 'Unchanged' });
64+
for (const { idx } of unchangedEntries) {
65+
itemRowIndex.push(rows.length);
66+
rows.push({ kind: 'item', itemIdx: idx, displayIdx });
67+
displayIdx++;
68+
}
69+
}
70+
}
71+
3772
// State
3873
let cursor = 0;
74+
let scroll = 0;
3975
const levels: BumpLevel[] = items.map((item) =>
4076
item.initialLevel !== undefined ? item.initialLevel : item.changed ? 'patch' : 'skip',
4177
);
@@ -65,50 +101,108 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
65101
lines.push(`${pc.dim('│')} ${pc.dim('(none selected)')}`);
66102
} else {
67103
for (const { item, idx } of selected) {
68-
lines.push(`${pc.dim('│')} ${pc.cyan(item.name)} ${pc.dim('→')} ${pc.bold(levels[idx])}`);
104+
lines.push(`${pc.dim('│')} ${pc.cyan(item.name)} ${pc.dim('→')} ${pc.bold(levels[idx]!)}`);
69105
}
70106
}
71107
lines.push(pc.dim('│'));
72-
} else {
73-
lines.push(`${pc.cyan('◆')} Select bump levels`);
74-
lines.push(`${pc.dim('│')} ${pc.dim('↑/↓ navigate · ←/→ change level · enter to confirm')}`);
75-
lines.push(`${pc.dim('│')} ${pc.dim('0 skip current · x skip all · r reset all to defaults')}`);
76-
lines.push(pc.dim('│'));
77108

78-
let displayIdx = 0;
109+
const output = lines.join('\n') + '\n';
110+
stdout.write(output);
111+
renderedLines = lines.length;
112+
return;
113+
}
79114

80-
if (changedEntries.length > 0) {
81-
lines.push(`${pc.dim('│')} ${pc.underline('Changed')}`);
82-
for (const { item, idx } of changedEntries) {
83-
lines.push(formatRow(item, levels[idx]!, cursor === displayIdx));
84-
displayIdx++;
85-
}
86-
if (unchangedEntries.length > 0) {
87-
lines.push(pc.dim('│'));
115+
const headerChrome = [
116+
`${pc.cyan('◆')} Select bump levels`,
117+
`${pc.dim('│')} ${pc.dim('↑/↓ navigate · ←/→ change level · enter to confirm')}`,
118+
`${pc.dim('│')} ${pc.dim('0 skip current · x skip all · r reset all to defaults')}`,
119+
pc.dim('│'),
120+
];
121+
122+
const selectedCount = levels.filter((l) => l !== 'skip').length;
123+
const footerChrome = [
124+
pc.dim('│'),
125+
`${pc.dim('│')} ${pc.dim(`${selectedCount} package${selectedCount !== 1 ? 's' : ''} selected`)}`,
126+
pc.dim('└'),
127+
];
128+
129+
// Determine viewport size: how many body lines fit in the terminal.
130+
const termRows = stdout.rows || 24;
131+
const chromeLines = headerChrome.length + footerChrome.length;
132+
const MIN_BODY = 3;
133+
const availableBody = Math.max(MIN_BODY, termRows - chromeLines - 1);
134+
135+
let visibleRows: Row[];
136+
let topIndicator: string | null = null;
137+
let bottomIndicator: string | null = null;
138+
let stickyHeader: string | null = null;
139+
140+
if (rows.length <= availableBody) {
141+
visibleRows = rows;
142+
scroll = 0;
143+
} else {
144+
// Reserve up to 2 lines for scroll indicators (one above, one below).
145+
let windowSize = Math.max(MIN_BODY, availableBody - 2);
146+
const focusedRowIdx = itemRowIndex[cursor]!;
147+
148+
const adjustScroll = () => {
149+
if (focusedRowIdx < scroll) {
150+
scroll = focusedRowIdx;
151+
} else if (focusedRowIdx >= scroll + windowSize) {
152+
scroll = focusedRowIdx - windowSize + 1;
88153
}
154+
scroll = Math.max(0, Math.min(scroll, rows.length - windowSize));
155+
};
156+
157+
adjustScroll();
158+
159+
// Sticky section header — if the focused item's section header has scrolled
160+
// out of view above the window, pin it just below the ▲ indicator so the
161+
// user always sees which section they're in.
162+
const section = getCurrentSection(cursor, changedEntries.length, unchangedEntries.length);
163+
if (section !== null && section.headerRowIdx < scroll) {
164+
// Reserve one more line for the sticky header and re-adjust scroll
165+
windowSize = Math.max(MIN_BODY, windowSize - 1);
166+
adjustScroll();
167+
stickyHeader = `${pc.dim('│')} ${pc.underline(section.name)}`;
89168
}
90169

91-
if (unchangedEntries.length > 0) {
92-
lines.push(`${pc.dim('│')} ${pc.underline('Unchanged')}`);
93-
for (const { item, idx } of unchangedEntries) {
94-
lines.push(formatRow(item, levels[idx]!, cursor === displayIdx));
95-
displayIdx++;
96-
}
97-
}
170+
visibleRows = rows.slice(scroll, scroll + windowSize);
171+
const above = scroll;
172+
const below = rows.length - (scroll + windowSize);
173+
if (above > 0) topIndicator = `${pc.dim('│')} ${pc.dim(`▲ ${above} more`)}`;
174+
if (below > 0) bottomIndicator = `${pc.dim('│')} ${pc.dim(`▼ ${below} more`)}`;
175+
}
98176

99-
lines.push(pc.dim('│'));
100-
const selectedCount = levels.filter((l) => l !== 'skip').length;
101-
lines.push(`${pc.dim('│')} ${pc.dim(`${selectedCount} package${selectedCount !== 1 ? 's' : ''} selected`)}`);
102-
lines.push(`${pc.dim('└')}`);
177+
lines.push(...headerChrome);
178+
if (topIndicator !== null) lines.push(topIndicator);
179+
if (stickyHeader !== null) lines.push(stickyHeader);
180+
for (const row of visibleRows) {
181+
if (row.kind === 'separator') {
182+
lines.push(pc.dim('│'));
183+
} else if (row.kind === 'header') {
184+
lines.push(`${pc.dim('│')} ${pc.underline(row.text)}`);
185+
} else {
186+
const item = items[row.itemIdx]!;
187+
const isFocused = row.displayIdx === cursor;
188+
lines.push(formatRow(item, levels[row.itemIdx]!, isFocused));
189+
}
103190
}
191+
if (bottomIndicator !== null) lines.push(bottomIndicator);
192+
lines.push(...footerChrome);
104193

105194
const output = lines.join('\n') + '\n';
106195
stdout.write(output);
107196
renderedLines = lines.length;
108197
}
109198

199+
function onResize() {
200+
render();
201+
}
202+
110203
function cleanup() {
111204
stdin.removeListener('keypress', onKeypress);
205+
stdout.removeListener('resize', onResize);
112206
rl.close();
113207
stdout.write('\x1B[?25h'); // Show cursor
114208
if (stdin.isTTY) stdin.setRawMode(false);
@@ -192,9 +286,26 @@ export async function bumpSelectPrompt(items: BumpSelectItem[]): Promise<BumpSel
192286
}
193287

194288
stdin.on('keypress', onKeypress);
289+
stdout.on('resize', onResize);
195290
});
196291
}
197292

293+
/** Returns the section the focused item is in, plus the row index of its header. */
294+
function getCurrentSection(
295+
cursor: number,
296+
changedCount: number,
297+
unchangedCount: number,
298+
): { headerRowIdx: number; name: string } | null {
299+
if (cursor < changedCount) {
300+
if (changedCount === 0) return null;
301+
return { headerRowIdx: 0, name: 'Changed' };
302+
}
303+
if (unchangedCount === 0) return null;
304+
// Unchanged header is at row 0 if there's no Changed section, otherwise
305+
// it follows: [Changed header (1)] + [changed items (N)] + [separator (1)]
306+
return { headerRowIdx: changedCount > 0 ? changedCount + 2 : 0, name: 'Unchanged' };
307+
}
308+
198309
function formatRow(item: BumpSelectItem, level: BumpLevel, focused: boolean): string {
199310
const prefix = pc.dim('│');
200311
const pointer = focused ? pc.cyan('›') : ' ';

0 commit comments

Comments
 (0)