Skip to content

Commit 2feaebb

Browse files
dreyfus9243081j
andauthored
fix: prevent duplicated logs when scrolling with multiline messages (#423)
Co-authored-by: James Garbutt <[email protected]>
1 parent 43aed55 commit 2feaebb

File tree

7 files changed

+373
-6
lines changed

7 files changed

+373
-6
lines changed

.changeset/early-maps-carry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": patch
3+
---
4+
5+
Fix duplicated logs when scrolling through options with multiline messages by calculating `rowPadding` dynamically based on actual rendered lines instead of using a hardcoded value.

packages/prompts/src/autocomplete.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,18 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
299299
const errorMessage =
300300
this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : [];
301301

302+
// Calculate header and footer line counts for rowPadding
303+
const headerLines = [
304+
...title.split('\n'),
305+
`${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
306+
...noResults,
307+
...errorMessage,
308+
];
309+
const footerLines = [
310+
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
311+
`${color.cyan(S_BAR_END)}`,
312+
];
313+
302314
// Get limited options for display
303315
const displayOptions = limitOptions({
304316
cursor: this.cursor,
@@ -307,17 +319,14 @@ export const autocompleteMultiselect = <Value>(opts: AutocompleteMultiSelectOpti
307319
formatOption(option, active, this.selectedValues, this.focusedValue),
308320
maxItems: opts.maxItems,
309321
output: opts.output,
322+
rowPadding: headerLines.length + footerLines.length,
310323
});
311324

312325
// Build the prompt display
313326
return [
314-
title,
315-
`${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`,
316-
...noResults,
317-
...errorMessage,
327+
...headerLines,
318328
...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`),
319-
`${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`,
320-
`${color.cyan(S_BAR_END)}`,
329+
...footerLines,
321330
].join('\n');
322331
}
323332
}

packages/prompts/src/multi-select.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,23 +146,31 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
146146
i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`
147147
)
148148
.join('\n');
149+
// Calculate rowPadding: title lines + footer lines (error message + trailing newline)
150+
const titleLineCount = title.split('\n').length;
151+
const footerLineCount = footer.split('\n').length + 1; // footer + trailing newline
149152
return `${title}${prefix}${limitOptions({
150153
output: opts.output,
151154
options: this.options,
152155
cursor: this.cursor,
153156
maxItems: opts.maxItems,
154157
columnPadding: prefix.length,
158+
rowPadding: titleLineCount + footerLineCount,
155159
style: styleOption,
156160
}).join(`\n${prefix}`)}\n${footer}\n`;
157161
}
158162
default: {
159163
const prefix = `${color.cyan(S_BAR)} `;
164+
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
165+
const titleLineCount = title.split('\n').length;
166+
const footerLineCount = 2; // S_BAR_END + trailing newline
160167
return `${title}${prefix}${limitOptions({
161168
output: opts.output,
162169
options: this.options,
163170
cursor: this.cursor,
164171
maxItems: opts.maxItems,
165172
columnPadding: prefix.length,
173+
rowPadding: titleLineCount + footerLineCount,
166174
style: styleOption,
167175
}).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`;
168176
}

packages/prompts/src/select.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,12 +144,16 @@ export const select = <Value>(opts: SelectOptions<Value>) => {
144144
}
145145
default: {
146146
const prefix = `${color.cyan(S_BAR)} `;
147+
// Calculate rowPadding: title lines + footer lines (S_BAR_END + trailing newline)
148+
const titleLineCount = title.split('\n').length;
149+
const footerLineCount = 2; // S_BAR_END + trailing newline
147150
return `${title}${prefix}${limitOptions({
148151
output: opts.output,
149152
cursor: this.cursor,
150153
options: this.options,
151154
maxItems: opts.maxItems,
152155
columnPadding: prefix.length,
156+
rowPadding: titleLineCount + footerLineCount,
153157
style: (item, active) =>
154158
opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'),
155159
}).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`;

packages/prompts/test/__snapshots__/select.test.ts.snap

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,124 @@ exports[`select (isCI = false) > can cancel 1`] = `
3636
]
3737
`;
3838
39+
exports[`select (isCI = false) > correctly limits options when message wraps to multiple lines 1`] = `
40+
[
41+
"<cursor.hide>",
42+
"│
43+
◆ This is a very
44+
│ long message that
45+
│ will wrap to
46+
│ multiple lines
47+
│ ● Option 0
48+
│ ○ Option 1
49+
│ ○ Option 2
50+
│ ...
51+
└
52+
",
53+
"<cursor.backward count=999><cursor.up count=10>",
54+
"<cursor.down count=5>",
55+
"<erase.down>",
56+
"│ ○ Option 0
57+
│ ● Option 1
58+
│ ○ Option 2
59+
│ ...
60+
└
61+
",
62+
"<cursor.backward count=999><cursor.up count=10>",
63+
"<cursor.down count=6>",
64+
"<erase.down>",
65+
"│ ○ Option 1
66+
│ ● Option 2
67+
│ ...
68+
└
69+
",
70+
"<cursor.backward count=999><cursor.up count=10>",
71+
"<cursor.down count=5>",
72+
"<erase.down>",
73+
"│ ...
74+
│ ● Option 3
75+
│ ○ Option 4
76+
│ ...
77+
└
78+
",
79+
"<cursor.backward count=999><cursor.up count=10>",
80+
"<cursor.down count=6>",
81+
"<erase.down>",
82+
"│ ● Option 4
83+
│ ○ Option 5
84+
│ ...
85+
└
86+
",
87+
"<cursor.backward count=999><cursor.up count=10>",
88+
"<cursor.down count=1>",
89+
"<erase.down>",
90+
"◇ This is a very
91+
│ long message that
92+
│ will wrap to
93+
│ multiple lines
94+
│ Option 4",
95+
"
96+
",
97+
"<cursor.show>",
98+
]
99+
`;
100+
101+
exports[`select (isCI = false) > correctly limits options with explicit multiline message 1`] = `
102+
[
103+
"<cursor.hide>",
104+
"│
105+
◆ Choose an option:
106+
│ Line 2 of the message
107+
│ Line 3 of the message
108+
│ ● Option 0
109+
│ ○ Option 1
110+
│ ○ Option 2
111+
│ ○ Option 3
112+
│ ...
113+
└
114+
",
115+
"<cursor.backward count=999><cursor.up count=10>",
116+
"<cursor.down count=4>",
117+
"<erase.down>",
118+
"│ ○ Option 0
119+
│ ● Option 1
120+
│ ○ Option 2
121+
│ ○ Option 3
122+
│ ...
123+
└
124+
",
125+
"<cursor.backward count=999><cursor.up count=10>",
126+
"<cursor.down count=5>",
127+
"<erase.down>",
128+
"│ ○ Option 1
129+
│ ● Option 2
130+
│ ○ Option 3
131+
│ ...
132+
└
133+
",
134+
"<cursor.backward count=999><cursor.up count=10>",
135+
"<cursor.down count=4>",
136+
"<erase.down>",
137+
"│ ...
138+
│ ○ Option 2
139+
│ ● Option 3
140+
│ ○ Option 4
141+
│ ...
142+
└
143+
",
144+
"<cursor.backward count=999><cursor.up count=10>",
145+
"<cursor.down count=1>",
146+
"<erase.down>",
147+
"◇ Choose an option:
148+
│ Line 2 of the message
149+
│ Line 3 of the message
150+
│ Option 3",
151+
"
152+
",
153+
"<cursor.show>",
154+
]
155+
`;
156+
39157
exports[`select (isCI = false) > down arrow selects next option 1`] = `
40158
[
41159
"<cursor.hide>",
@@ -362,6 +480,124 @@ exports[`select (isCI = true) > can cancel 1`] = `
362480
]
363481
`;
364482
483+
exports[`select (isCI = true) > correctly limits options when message wraps to multiple lines 1`] = `
484+
[
485+
"<cursor.hide>",
486+
"│
487+
◆ This is a very
488+
│ long message that
489+
│ will wrap to
490+
│ multiple lines
491+
│ ● Option 0
492+
│ ○ Option 1
493+
│ ○ Option 2
494+
│ ...
495+
└
496+
",
497+
"<cursor.backward count=999><cursor.up count=10>",
498+
"<cursor.down count=5>",
499+
"<erase.down>",
500+
"│ ○ Option 0
501+
│ ● Option 1
502+
│ ○ Option 2
503+
│ ...
504+
└
505+
",
506+
"<cursor.backward count=999><cursor.up count=10>",
507+
"<cursor.down count=6>",
508+
"<erase.down>",
509+
"│ ○ Option 1
510+
│ ● Option 2
511+
│ ...
512+
└
513+
",
514+
"<cursor.backward count=999><cursor.up count=10>",
515+
"<cursor.down count=5>",
516+
"<erase.down>",
517+
"│ ...
518+
│ ● Option 3
519+
│ ○ Option 4
520+
│ ...
521+
└
522+
",
523+
"<cursor.backward count=999><cursor.up count=10>",
524+
"<cursor.down count=6>",
525+
"<erase.down>",
526+
"│ ● Option 4
527+
│ ○ Option 5
528+
│ ...
529+
└
530+
",
531+
"<cursor.backward count=999><cursor.up count=10>",
532+
"<cursor.down count=1>",
533+
"<erase.down>",
534+
"◇ This is a very
535+
│ long message that
536+
│ will wrap to
537+
│ multiple lines
538+
│ Option 4",
539+
"
540+
",
541+
"<cursor.show>",
542+
]
543+
`;
544+
545+
exports[`select (isCI = true) > correctly limits options with explicit multiline message 1`] = `
546+
[
547+
"<cursor.hide>",
548+
"│
549+
◆ Choose an option:
550+
│ Line 2 of the message
551+
│ Line 3 of the message
552+
│ ● Option 0
553+
│ ○ Option 1
554+
│ ○ Option 2
555+
│ ○ Option 3
556+
│ ...
557+
└
558+
",
559+
"<cursor.backward count=999><cursor.up count=10>",
560+
"<cursor.down count=4>",
561+
"<erase.down>",
562+
"│ ○ Option 0
563+
│ ● Option 1
564+
│ ○ Option 2
565+
│ ○ Option 3
566+
│ ...
567+
└
568+
",
569+
"<cursor.backward count=999><cursor.up count=10>",
570+
"<cursor.down count=5>",
571+
"<erase.down>",
572+
"│ ○ Option 1
573+
│ ● Option 2
574+
│ ○ Option 3
575+
│ ...
576+
└
577+
",
578+
"<cursor.backward count=999><cursor.up count=10>",
579+
"<cursor.down count=4>",
580+
"<erase.down>",
581+
"│ ...
582+
│ ○ Option 2
583+
│ ● Option 3
584+
│ ○ Option 4
585+
│ ...
586+
└
587+
",
588+
"<cursor.backward count=999><cursor.up count=10>",
589+
"<cursor.down count=1>",
590+
"<erase.down>",
591+
"◇ Choose an option:
592+
│ Line 2 of the message
593+
│ Line 3 of the message
594+
│ Option 3",
595+
"
596+
",
597+
"<cursor.show>",
598+
]
599+
`;
600+
365601
exports[`select (isCI = true) > down arrow selects next option 1`] = `
366602
[
367603
"<cursor.hide>",

packages/prompts/test/limit-options.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,4 +241,54 @@ describe('limitOptions', () => {
241241
const result = limitOptions(options);
242242
expect(result).toEqual(['Item 1', '-- Item 2 --', 'Item 3']);
243243
});
244+
245+
test('respects custom rowPadding', async () => {
246+
options.options = [
247+
{ value: 'Item 1' },
248+
{ value: 'Item 2' },
249+
{ value: 'Item 3' },
250+
{ value: 'Item 4' },
251+
{ value: 'Item 5' },
252+
{ value: 'Item 6' },
253+
{ value: 'Item 7' },
254+
{ value: 'Item 8' },
255+
{ value: 'Item 9' },
256+
{ value: 'Item 10' },
257+
];
258+
output.rows = 12;
259+
options.rowPadding = 6;
260+
// Available rows for options = 12 - 6 = 6
261+
const result = limitOptions(options);
262+
expect(result).toEqual(['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', color.dim('...')]);
263+
});
264+
265+
test('respects custom rowPadding when scrolling', async () => {
266+
options.options = [
267+
{ value: 'Item 1' },
268+
{ value: 'Item 2' },
269+
{ value: 'Item 3' },
270+
{ value: 'Item 4' },
271+
{ value: 'Item 5' },
272+
{ value: 'Item 6' },
273+
{ value: 'Item 7' },
274+
{ value: 'Item 8' },
275+
{ value: 'Item 9' },
276+
{ value: 'Item 10' },
277+
];
278+
output.rows = 12;
279+
// Simulate a multiline message that takes 6 lines
280+
options.rowPadding = 6;
281+
// Move cursor to middle of list
282+
options.cursor = 5;
283+
// Available rows for options = 12 - 6 = 6
284+
const result = limitOptions(options);
285+
expect(result).toEqual([
286+
color.dim('...'),
287+
'Item 4',
288+
'Item 5',
289+
'Item 6',
290+
'Item 7',
291+
color.dim('...'),
292+
]);
293+
});
244294
});

0 commit comments

Comments
 (0)