[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

fix: prevent duplicated logs when scrolling with multiline messages (#423)

Co-authored-by: James Garbutt <43081j@users.noreply.github.com>

authored by

paul valladares
James Garbutt
and committed by
GitHub
(Nov 29, 2025, 3:32 PM -0600) 2feaebb5 43aed553

+373 -6
+5
.changeset/early-maps-carry.md
··· 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.
+15 -6
packages/prompts/src/autocomplete.ts
··· 299 299 const errorMessage = 300 300 this.state === 'error' ? [`${color.cyan(S_BAR)} ${color.yellow(this.error)}`] : []; 301 301 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 + 302 314 // Get limited options for display 303 315 const displayOptions = limitOptions({ 304 316 cursor: this.cursor, ··· 307 319 formatOption(option, active, this.selectedValues, this.focusedValue), 308 320 maxItems: opts.maxItems, 309 321 output: opts.output, 322 + rowPadding: headerLines.length + footerLines.length, 310 323 }); 311 324 312 325 // Build the prompt display 313 326 return [ 314 - title, 315 - `${color.cyan(S_BAR)} ${color.dim('Search:')} ${searchText}${matches}`, 316 - ...noResults, 317 - ...errorMessage, 327 + ...headerLines, 318 328 ...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, 321 330 ].join('\n'); 322 331 } 323 332 }
+8
packages/prompts/src/multi-select.ts
··· 146 146 i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` 147 147 ) 148 148 .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 149 152 return `${title}${prefix}${limitOptions({ 150 153 output: opts.output, 151 154 options: this.options, 152 155 cursor: this.cursor, 153 156 maxItems: opts.maxItems, 154 157 columnPadding: prefix.length, 158 + rowPadding: titleLineCount + footerLineCount, 155 159 style: styleOption, 156 160 }).join(`\n${prefix}`)}\n${footer}\n`; 157 161 } 158 162 default: { 159 163 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 160 167 return `${title}${prefix}${limitOptions({ 161 168 output: opts.output, 162 169 options: this.options, 163 170 cursor: this.cursor, 164 171 maxItems: opts.maxItems, 165 172 columnPadding: prefix.length, 173 + rowPadding: titleLineCount + footerLineCount, 166 174 style: styleOption, 167 175 }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`; 168 176 }
+4
packages/prompts/src/select.ts
··· 144 144 } 145 145 default: { 146 146 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 147 150 return `${title}${prefix}${limitOptions({ 148 151 output: opts.output, 149 152 cursor: this.cursor, 150 153 options: this.options, 151 154 maxItems: opts.maxItems, 152 155 columnPadding: prefix.length, 156 + rowPadding: titleLineCount + footerLineCount, 153 157 style: (item, active) => 154 158 opt(item, item.disabled ? 'disabled' : active ? 'active' : 'inactive'), 155 159 }).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`;
+236
packages/prompts/test/__snapshots__/select.test.ts.snap
··· 36 36 ] 37 37 `; 38 38 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 + 39 157 exports[`select (isCI = false) > down arrow selects next option 1`] = ` 40 158 [ 41 159 "<cursor.hide>", ··· 356 474 "■ foo 357 475 │ opt0 358 476 │", 477 + " 478 + ", 479 + "<cursor.show>", 480 + ] 481 + `; 482 + 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", 359 595 " 360 596 ", 361 597 "<cursor.show>",
+50
packages/prompts/test/limit-options.test.ts
··· 241 241 const result = limitOptions(options); 242 242 expect(result).toEqual(['Item 1', '-- Item 2 --', 'Item 3']); 243 243 }); 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 + }); 244 294 });
+55
packages/prompts/test/select.test.ts
··· 275 275 276 276 expect(output.buffer).toMatchSnapshot(); 277 277 }); 278 + 279 + test('correctly limits options when message wraps to multiple lines', async () => { 280 + // Simulate a narrow terminal that forces the message to wrap 281 + output.columns = 30; 282 + output.rows = 12; 283 + 284 + const result = prompts.select({ 285 + // Long message that will wrap to multiple lines in a 30-column terminal 286 + message: 'This is a very long message that will wrap to multiple lines', 287 + options: Array.from({ length: 10 }, (_, i) => ({ 288 + value: `opt${i}`, 289 + label: `Option ${i}`, 290 + })), 291 + input, 292 + output, 293 + }); 294 + 295 + // Scroll down through options to trigger the bug scenario 296 + input.emit('keypress', '', { name: 'down' }); 297 + input.emit('keypress', '', { name: 'down' }); 298 + input.emit('keypress', '', { name: 'down' }); 299 + input.emit('keypress', '', { name: 'down' }); 300 + input.emit('keypress', '', { name: 'return' }); 301 + 302 + const value = await result; 303 + 304 + expect(value).toBe('opt4'); 305 + expect(output.buffer).toMatchSnapshot(); 306 + }); 307 + 308 + test('correctly limits options with explicit multiline message', async () => { 309 + output.rows = 12; 310 + 311 + const result = prompts.select({ 312 + // Explicit multiline message 313 + message: 'Choose an option:\nLine 2 of the message\nLine 3 of the message', 314 + options: Array.from({ length: 10 }, (_, i) => ({ 315 + value: `opt${i}`, 316 + label: `Option ${i}`, 317 + })), 318 + input, 319 + output, 320 + }); 321 + 322 + // Scroll down to test that options don't overflow 323 + input.emit('keypress', '', { name: 'down' }); 324 + input.emit('keypress', '', { name: 'down' }); 325 + input.emit('keypress', '', { name: 'down' }); 326 + input.emit('keypress', '', { name: 'return' }); 327 + 328 + const value = await result; 329 + 330 + expect(value).toBe('opt3'); 331 + expect(output.buffer).toMatchSnapshot(); 332 + }); 278 333 });