[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: wrap messages in select prompts (#410)

authored by

James Garbutt and committed by
GitHub
(Nov 7, 2025, 12:13 PM UTC) 7530af07 38019c78

+230 -10
+5
.changeset/plenty-snakes-ring.md
··· 1 + --- 2 + "@clack/prompts": patch 3 + --- 4 + 5 + Fixes wrapping of cancelled and success messages of select prompt
+1 -1
packages/core/src/index.ts
··· 8 8 export { default as SelectKeyPrompt } from './prompts/select-key.js'; 9 9 export { default as TextPrompt } from './prompts/text.js'; 10 10 export type { ClackState as State } from './types.js'; 11 - export { block, getColumns, getRows, isCancel } from './utils/index.js'; 11 + export { block, getColumns, getRows, isCancel, wrapTextWithPrefix } from './utils/index.js'; 12 12 export type { ClackSettings } from './utils/settings.js'; 13 13 export { settings, updateSettings } from './utils/settings.js';
+21
packages/core/src/utils/index.ts
··· 3 3 import * as readline from 'node:readline'; 4 4 import type { Readable, Writable } from 'node:stream'; 5 5 import { ReadStream } from 'node:tty'; 6 + import { wrapAnsi } from 'fast-wrap-ansi'; 6 7 import { cursor } from 'sisteransi'; 7 8 import { isActionKey } from './settings.js'; 8 9 ··· 96 97 } 97 98 return 20; 98 99 }; 100 + 101 + export function wrapTextWithPrefix( 102 + output: Writable | undefined, 103 + text: string, 104 + prefix: string, 105 + startPrefix: string = prefix 106 + ): string { 107 + const columns = getColumns(output ?? stdout); 108 + const wrapped = wrapAnsi(text, columns - prefix.length, { 109 + hard: true, 110 + trim: false, 111 + }); 112 + const lines = wrapped 113 + .split('\n') 114 + .map((line, index) => { 115 + return `${index === 0 ? startPrefix : prefix}${line}`; 116 + }) 117 + .join('\n'); 118 + return lines; 119 + }
+14
packages/prompts/src/common.ts
··· 53 53 } 54 54 }; 55 55 56 + export const symbolBar = (state: State) => { 57 + switch (state) { 58 + case 'initial': 59 + case 'active': 60 + return color.cyan(S_BAR); 61 + case 'cancel': 62 + return color.red(S_BAR); 63 + case 'error': 64 + return color.yellow(S_BAR); 65 + case 'submit': 66 + return color.green(S_BAR); 67 + } 68 + }; 69 + 56 70 export interface CommonOptions { 57 71 input?: Readable; 58 72 output?: Writable;
+29 -9
packages/prompts/src/select.ts
··· 1 - import { SelectPrompt } from '@clack/core'; 1 + import { SelectPrompt, wrapTextWithPrefix } from '@clack/core'; 2 2 import color from 'picocolors'; 3 3 import { 4 4 type CommonOptions, ··· 7 7 S_RADIO_ACTIVE, 8 8 S_RADIO_INACTIVE, 9 9 symbol, 10 + symbolBar, 10 11 } from './common.js'; 11 12 import { limitOptions } from './limit-options.js'; 12 13 ··· 102 103 output: opts.output, 103 104 initialValue: opts.initialValue, 104 105 render() { 105 - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; 106 + const titlePrefix = `${symbol(this.state)} `; 107 + const titlePrefixBar = `${symbolBar(this.state)} `; 108 + const messageLines = wrapTextWithPrefix( 109 + opts.output, 110 + opts.message, 111 + titlePrefixBar, 112 + titlePrefix 113 + ); 114 + const title = `${color.gray(S_BAR)}\n${messageLines}\n`; 106 115 107 116 switch (this.state) { 108 - case 'submit': 109 - return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor], 'selected')}`; 110 - case 'cancel': 111 - return `${title}${color.gray(S_BAR)} ${opt( 112 - this.options[this.cursor], 113 - 'cancelled' 114 - )}\n${color.gray(S_BAR)}`; 117 + case 'submit': { 118 + const submitPrefix = `${color.gray(S_BAR)} `; 119 + const wrappedLines = wrapTextWithPrefix( 120 + opts.output, 121 + opt(this.options[this.cursor], 'selected'), 122 + submitPrefix 123 + ); 124 + return `${title}${wrappedLines}`; 125 + } 126 + case 'cancel': { 127 + const cancelPrefix = `${color.gray(S_BAR)} `; 128 + const wrappedLines = wrapTextWithPrefix( 129 + opts.output, 130 + opt(this.options[this.cursor], 'cancelled'), 131 + cancelPrefix 132 + ); 133 + return `${title}${wrappedLines}\n${color.gray(S_BAR)}`; 134 + } 115 135 default: { 116 136 const prefix = `${color.cyan(S_BAR)} `; 117 137 return `${title}${prefix}${limitOptions({
+114
packages/prompts/test/__snapshots__/select.test.ts.snap
··· 178 178 ] 179 179 `; 180 180 181 + exports[`select (isCI = false) > wraps long cancelled message 1`] = ` 182 + [ 183 + "<cursor.hide>", 184 + "│ 185 + ◆ foo 186 + │ ● foo foo foo foo foo foo 187 + │ foo foo foo foo foo foo foo 188 + │ foo foo foo foo foo foo 189 + │ foo foo foo foo foo foo foo 190 + │ foo foo foo foo 191 + │ ○ Option 1 192 + └ 193 + ", 194 + "<cursor.backward count=999><cursor.up count=9>", 195 + "<cursor.down count=1>", 196 + "<erase.down>", 197 + "■ foo 198 + │ foo foo foo foo foo foo foo 199 + │  foo foo foo foo foo foo  200 + │ foo foo foo foo foo foo foo 201 + │  foo foo foo foo foo foo  202 + │ foo foo foo foo 203 + │", 204 + " 205 + ", 206 + "<cursor.show>", 207 + ] 208 + `; 209 + 210 + exports[`select (isCI = false) > wraps long results 1`] = ` 211 + [ 212 + "<cursor.hide>", 213 + "│ 214 + ◆ foo 215 + │ ● foo foo foo foo foo foo 216 + │ foo foo foo foo foo foo foo 217 + │ foo foo foo foo foo foo 218 + │ foo foo foo foo foo foo foo 219 + │ foo foo foo foo 220 + │ ○ Option 1 221 + └ 222 + ", 223 + "<cursor.backward count=999><cursor.up count=9>", 224 + "<cursor.down count=1>", 225 + "<erase.down>", 226 + "◇ foo 227 + │ foo foo foo foo foo foo foo 228 + │  foo foo foo foo foo foo  229 + │ foo foo foo foo foo foo foo 230 + │  foo foo foo foo foo foo  231 + │ foo foo foo foo", 232 + " 233 + ", 234 + "<cursor.show>", 235 + ] 236 + `; 237 + 181 238 exports[`select (isCI = true) > can be aborted by a signal 1`] = ` 182 239 [ 183 240 "<cursor.hide>", ··· 355 412 "<cursor.show>", 356 413 ] 357 414 `; 415 + 416 + exports[`select (isCI = true) > wraps long cancelled message 1`] = ` 417 + [ 418 + "<cursor.hide>", 419 + "│ 420 + ◆ foo 421 + │ ● foo foo foo foo foo foo 422 + │ foo foo foo foo foo foo foo 423 + │ foo foo foo foo foo foo 424 + │ foo foo foo foo foo foo foo 425 + │ foo foo foo foo 426 + │ ○ Option 1 427 + └ 428 + ", 429 + "<cursor.backward count=999><cursor.up count=9>", 430 + "<cursor.down count=1>", 431 + "<erase.down>", 432 + "■ foo 433 + │ foo foo foo foo foo foo foo 434 + │  foo foo foo foo foo foo  435 + │ foo foo foo foo foo foo foo 436 + │  foo foo foo foo foo foo  437 + │ foo foo foo foo 438 + │", 439 + " 440 + ", 441 + "<cursor.show>", 442 + ] 443 + `; 444 + 445 + exports[`select (isCI = true) > wraps long results 1`] = ` 446 + [ 447 + "<cursor.hide>", 448 + "│ 449 + ◆ foo 450 + │ ● foo foo foo foo foo foo 451 + │ foo foo foo foo foo foo foo 452 + │ foo foo foo foo foo foo 453 + │ foo foo foo foo foo foo foo 454 + │ foo foo foo foo 455 + │ ○ Option 1 456 + └ 457 + ", 458 + "<cursor.backward count=999><cursor.up count=9>", 459 + "<cursor.down count=1>", 460 + "<erase.down>", 461 + "◇ foo 462 + │ foo foo foo foo foo foo foo 463 + │  foo foo foo foo foo foo  464 + │ foo foo foo foo foo foo foo 465 + │  foo foo foo foo foo foo  466 + │ foo foo foo foo", 467 + " 468 + ", 469 + "<cursor.show>", 470 + ] 471 + `;
+46
packages/prompts/test/select.test.ts
··· 165 165 expect(value).toBe('opt1'); 166 166 expect(output.buffer).toMatchSnapshot(); 167 167 }); 168 + 169 + test('wraps long results', async () => { 170 + output.columns = 40; 171 + 172 + const result = prompts.select({ 173 + message: 'foo', 174 + options: [ 175 + { 176 + value: 'opt0', 177 + label: 'foo '.repeat(30).trim(), 178 + }, 179 + { value: 'opt1', label: 'Option 1' }, 180 + ], 181 + input, 182 + output, 183 + }); 184 + 185 + input.emit('keypress', '', { name: 'return' }); 186 + 187 + await result; 188 + 189 + expect(output.buffer).toMatchSnapshot(); 190 + }); 191 + 192 + test('wraps long cancelled message', async () => { 193 + output.columns = 40; 194 + 195 + const result = prompts.select({ 196 + message: 'foo', 197 + options: [ 198 + { 199 + value: 'opt0', 200 + label: 'foo '.repeat(30).trim(), 201 + }, 202 + { value: 'opt1', label: 'Option 1' }, 203 + ], 204 + input, 205 + output, 206 + }); 207 + 208 + input.emit('keypress', 'escape', { name: 'escape' }); 209 + 210 + await result; 211 + 212 + expect(output.buffer).toMatchSnapshot(); 213 + }); 168 214 });