[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.

feat(@clack/prompts): add theme support for text prompt customization

Paul Valladares (Dec 4, 2025, 1:22 AM -0600) a43ce247 9b921616

+448 -10
+34
.changeset/dark-roses-report.md
··· 1 + --- 2 + "@clack/prompts": minor 3 + --- 4 + 5 + Add theme support for the text prompt. Users can now customize the colors of symbols, guide lines, and error messages by passing a `theme` option. 6 + 7 + Example usage: 8 + ```typescript 9 + import { text } from '@clack/prompts'; 10 + import color from 'picocolors'; 11 + 12 + const result = await text({ 13 + message: 'Enter your name', 14 + theme: { 15 + formatSymbolActive: (str) => color.magenta(str), 16 + formatGuide: (str) => color.blue(str), 17 + formatErrorMessage: (str) => color.bgRed(color.white(str)), 18 + } 19 + }); 20 + ``` 21 + 22 + Available theme options for text prompt: 23 + - `formatSymbolActive` - Format the prompt symbol in active/initial state 24 + - `formatSymbolSubmit` - Format the prompt symbol on submit 25 + - `formatSymbolCancel` - Format the prompt symbol on cancel 26 + - `formatSymbolError` - Format the prompt symbol on error 27 + - `formatErrorMessage` - Format error messages 28 + - `formatGuide` - Format the left guide line in active state 29 + - `formatGuideSubmit` - Format the guide line on submit 30 + - `formatGuideCancel` - Format the guide line on cancel 31 + - `formatGuideError` - Format the guide line on error 32 + 33 + This establishes the foundation for theming support that will be extended to other prompts. 34 +
+35
packages/prompts/src/common.ts
··· 73 73 signal?: AbortSignal; 74 74 withGuide?: boolean; 75 75 } 76 + 77 + export type ColorFormatter = (str: string) => string; 78 + 79 + /** 80 + * Global theme options shared across all prompts. 81 + * These control the common visual elements like the guide line. 82 + */ 83 + export interface GlobalTheme { 84 + /** Format the left guide/border line (default: cyan) */ 85 + formatGuide?: ColorFormatter; 86 + /** Format the guide line on submit (default: gray) */ 87 + formatGuideSubmit?: ColorFormatter; 88 + /** Format the guide line on cancel (default: gray) */ 89 + formatGuideCancel?: ColorFormatter; 90 + /** Format the guide line on error (default: yellow) */ 91 + formatGuideError?: ColorFormatter; 92 + } 93 + 94 + export interface ThemeOptions<T> { 95 + theme?: T & GlobalTheme; 96 + } 97 + 98 + export const defaultGlobalTheme: Required<GlobalTheme> = { 99 + formatGuide: color.cyan, 100 + formatGuideSubmit: color.gray, 101 + formatGuideCancel: color.gray, 102 + formatGuideError: color.yellow, 103 + }; 104 + 105 + export function resolveTheme<T>( 106 + theme: Partial<T> | undefined, 107 + defaults: T 108 + ): T { 109 + return { ...defaults, ...theme }; 110 + }
+68 -10
packages/prompts/src/text.ts
··· 1 1 import { settings, TextPrompt } from '@clack/core'; 2 2 import color from 'picocolors'; 3 - import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; 3 + import { 4 + type ColorFormatter, 5 + type CommonOptions, 6 + defaultGlobalTheme, 7 + type GlobalTheme, 8 + resolveTheme, 9 + S_BAR, 10 + S_BAR_END, 11 + S_STEP_ACTIVE, 12 + S_STEP_CANCEL, 13 + S_STEP_ERROR, 14 + S_STEP_SUBMIT, 15 + type ThemeOptions, 16 + } from './common.js'; 17 + 18 + /** 19 + * Theme options specific to the text prompt. 20 + * All formatters are optional - defaults will be used if not provided. 21 + */ 22 + export interface TextTheme { 23 + /** Format the prompt symbol in active/initial state (default: cyan) */ 24 + formatSymbolActive?: ColorFormatter; 25 + /** Format the prompt symbol on submit (default: green) */ 26 + formatSymbolSubmit?: ColorFormatter; 27 + /** Format the prompt symbol on cancel (default: red) */ 28 + formatSymbolCancel?: ColorFormatter; 29 + /** Format the prompt symbol on error (default: yellow) */ 30 + formatSymbolError?: ColorFormatter; 31 + /** Format error messages (default: yellow) */ 32 + formatErrorMessage?: ColorFormatter; 33 + } 4 34 5 - export interface TextOptions extends CommonOptions { 35 + /** Default theme values for the text prompt */ 36 + const defaultTextTheme: Required<TextTheme & GlobalTheme> = { 37 + ...defaultGlobalTheme, 38 + formatSymbolActive: color.cyan, 39 + formatSymbolSubmit: color.green, 40 + formatSymbolCancel: color.red, 41 + formatSymbolError: color.yellow, 42 + formatErrorMessage: color.yellow, 43 + }; 44 + 45 + export interface TextOptions extends CommonOptions, ThemeOptions<TextTheme> { 6 46 message: string; 7 47 placeholder?: string; 8 48 defaultValue?: string; ··· 11 51 } 12 52 13 53 export const text = (opts: TextOptions) => { 54 + const theme = resolveTheme<Required<TextTheme & GlobalTheme>>(opts.theme, defaultTextTheme); 55 + 14 56 return new TextPrompt({ 15 57 validate: opts.validate, 16 58 placeholder: opts.placeholder, ··· 21 63 input: opts.input, 22 64 render() { 23 65 const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false; 24 - const titlePrefix = `${hasGuide ? `${color.gray(S_BAR)}\n` : ''}${symbol(this.state)} `; 66 + 67 + // Resolve symbol based on state 68 + const symbolText = (() => { 69 + switch (this.state) { 70 + case 'initial': 71 + case 'active': 72 + return theme.formatSymbolActive(S_STEP_ACTIVE); 73 + case 'cancel': 74 + return theme.formatSymbolCancel(S_STEP_CANCEL); 75 + case 'error': 76 + return theme.formatSymbolError(S_STEP_ERROR); 77 + case 'submit': 78 + return theme.formatSymbolSubmit(S_STEP_SUBMIT); 79 + } 80 + })(); 81 + 82 + const titlePrefix = `${hasGuide ? `${color.gray(S_BAR)}\n` : ''}${symbolText} `; 25 83 const title = `${titlePrefix}${opts.message}\n`; 26 84 const placeholder = opts.placeholder 27 85 ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) ··· 31 89 32 90 switch (this.state) { 33 91 case 'error': { 34 - const errorText = this.error ? ` ${color.yellow(this.error)}` : ''; 35 - const errorPrefix = hasGuide ? `${color.yellow(S_BAR)} ` : ''; 36 - const errorPrefixEnd = hasGuide ? color.yellow(S_BAR_END) : ''; 92 + const errorText = this.error ? ` ${theme.formatErrorMessage(this.error)}` : ''; 93 + const errorPrefix = hasGuide ? `${theme.formatGuideError(S_BAR)} ` : ''; 94 + const errorPrefixEnd = hasGuide ? theme.formatGuideError(S_BAR_END) : ''; 37 95 return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`; 38 96 } 39 97 case 'submit': { 40 98 const valueText = value ? ` ${color.dim(value)}` : ''; 41 - const submitPrefix = hasGuide ? color.gray(S_BAR) : ''; 99 + const submitPrefix = hasGuide ? theme.formatGuideSubmit(S_BAR) : ''; 42 100 return `${title}${submitPrefix}${valueText}`; 43 101 } 44 102 case 'cancel': { 45 103 const valueText = value ? ` ${color.strikethrough(color.dim(value))}` : ''; 46 - const cancelPrefix = hasGuide ? color.gray(S_BAR) : ''; 104 + const cancelPrefix = hasGuide ? theme.formatGuideCancel(S_BAR) : ''; 47 105 return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`; 48 106 } 49 107 default: { 50 - const defaultPrefix = hasGuide ? `${color.cyan(S_BAR)} ` : ''; 51 - const defaultPrefixEnd = hasGuide ? color.cyan(S_BAR_END) : ''; 108 + const defaultPrefix = hasGuide ? `${theme.formatGuide(S_BAR)} ` : ''; 109 + const defaultPrefixEnd = hasGuide ? theme.formatGuide(S_BAR_END) : ''; 52 110 return `${title}${defaultPrefix}${userInput}\n${defaultPrefixEnd}\n`; 53 111 } 54 112 }
+216
packages/prompts/test/__snapshots__/text.test.ts.snap
··· 33 33 ] 34 34 `; 35 35 36 + exports[`text (isCI = false) > custom theme changes active symbol color 1`] = ` 37 + [ 38 + "<cursor.hide>", 39 + "│ 40 + [MAGENTA]◆[/MAGENTA] foo 41 + [BLUE]│[/BLUE] _ 42 + [BLUE]└[/BLUE] 43 + ", 44 + "<cursor.backward count=999><cursor.up count=4>", 45 + "<cursor.down count=1>", 46 + "<erase.down>", 47 + "◇ foo 48 + │", 49 + " 50 + ", 51 + "<cursor.show>", 52 + ] 53 + `; 54 + 55 + exports[`text (isCI = false) > custom theme changes cancel symbol color 1`] = ` 56 + [ 57 + "<cursor.hide>", 58 + "│ 59 + ◆ foo 60 + │ _ 61 + └ 62 + ", 63 + "<cursor.backward count=999><cursor.up count=4>", 64 + "<cursor.down count=1>", 65 + "<erase.down>", 66 + "[ORANGE]■[/ORANGE] foo 67 + [BROWN]│[/BROWN]", 68 + " 69 + ", 70 + "<cursor.show>", 71 + ] 72 + `; 73 + 74 + exports[`text (isCI = false) > custom theme changes error colors 1`] = ` 75 + [ 76 + "<cursor.hide>", 77 + "│ 78 + ◆ foo 79 + │ _ 80 + └ 81 + ", 82 + "<cursor.backward count=999><cursor.up count=4>", 83 + "<cursor.down count=2>", 84 + "<erase.line><cursor.left count=1>", 85 + "│ x█", 86 + "<cursor.down count=2>", 87 + "<cursor.backward count=999><cursor.up count=4>", 88 + "<cursor.down count=1>", 89 + "<erase.down>", 90 + "[RED_BG]▲[/RED_BG] foo 91 + [RED]│[/RED] x█ 92 + [RED]└[/RED] [BOLD_RED]custom error[/BOLD_RED] 93 + ", 94 + "<cursor.backward count=999><cursor.up count=4>", 95 + "<cursor.down count=1>", 96 + "<erase.down>", 97 + "■ foo 98 + │ x 99 + │", 100 + " 101 + ", 102 + "<cursor.show>", 103 + ] 104 + `; 105 + 106 + exports[`text (isCI = false) > custom theme changes submit symbol color 1`] = ` 107 + [ 108 + "<cursor.hide>", 109 + "│ 110 + ◆ foo 111 + │ _ 112 + └ 113 + ", 114 + "<cursor.backward count=999><cursor.up count=4>", 115 + "<cursor.down count=1>", 116 + "<erase.down>", 117 + "[PURPLE]◇[/PURPLE] foo 118 + [PINK]│[/PINK]", 119 + " 120 + ", 121 + "<cursor.show>", 122 + ] 123 + `; 124 + 36 125 exports[`text (isCI = false) > defaultValue sets the value but does not render 1`] = ` 37 126 [ 38 127 "<cursor.hide>", ··· 82 171 "<erase.down>", 83 172 "◇ foo 84 173 ", 174 + " 175 + ", 176 + "<cursor.show>", 177 + ] 178 + `; 179 + 180 + exports[`text (isCI = false) > partial theme only overrides specified options 1`] = ` 181 + [ 182 + "<cursor.hide>", 183 + "│ 184 + [CUSTOM]◆[/CUSTOM] foo 185 + │ _ 186 + └ 187 + ", 188 + "<cursor.backward count=999><cursor.up count=4>", 189 + "<cursor.down count=1>", 190 + "<erase.down>", 191 + "◇ foo 192 + │", 85 193 " 86 194 ", 87 195 "<cursor.show>", ··· 330 438 ] 331 439 `; 332 440 441 + exports[`text (isCI = true) > custom theme changes active symbol color 1`] = ` 442 + [ 443 + "<cursor.hide>", 444 + "│ 445 + [MAGENTA]◆[/MAGENTA] foo 446 + [BLUE]│[/BLUE] _ 447 + [BLUE]└[/BLUE] 448 + ", 449 + "<cursor.backward count=999><cursor.up count=4>", 450 + "<cursor.down count=1>", 451 + "<erase.down>", 452 + "◇ foo 453 + │", 454 + " 455 + ", 456 + "<cursor.show>", 457 + ] 458 + `; 459 + 460 + exports[`text (isCI = true) > custom theme changes cancel symbol color 1`] = ` 461 + [ 462 + "<cursor.hide>", 463 + "│ 464 + ◆ foo 465 + │ _ 466 + └ 467 + ", 468 + "<cursor.backward count=999><cursor.up count=4>", 469 + "<cursor.down count=1>", 470 + "<erase.down>", 471 + "[ORANGE]■[/ORANGE] foo 472 + [BROWN]│[/BROWN]", 473 + " 474 + ", 475 + "<cursor.show>", 476 + ] 477 + `; 478 + 479 + exports[`text (isCI = true) > custom theme changes error colors 1`] = ` 480 + [ 481 + "<cursor.hide>", 482 + "│ 483 + ◆ foo 484 + │ _ 485 + └ 486 + ", 487 + "<cursor.backward count=999><cursor.up count=4>", 488 + "<cursor.down count=2>", 489 + "<erase.line><cursor.left count=1>", 490 + "│ x█", 491 + "<cursor.down count=2>", 492 + "<cursor.backward count=999><cursor.up count=4>", 493 + "<cursor.down count=1>", 494 + "<erase.down>", 495 + "[RED_BG]▲[/RED_BG] foo 496 + [RED]│[/RED] x█ 497 + [RED]└[/RED] [BOLD_RED]custom error[/BOLD_RED] 498 + ", 499 + "<cursor.backward count=999><cursor.up count=4>", 500 + "<cursor.down count=1>", 501 + "<erase.down>", 502 + "■ foo 503 + │ x 504 + │", 505 + " 506 + ", 507 + "<cursor.show>", 508 + ] 509 + `; 510 + 511 + exports[`text (isCI = true) > custom theme changes submit symbol color 1`] = ` 512 + [ 513 + "<cursor.hide>", 514 + "│ 515 + ◆ foo 516 + │ _ 517 + └ 518 + ", 519 + "<cursor.backward count=999><cursor.up count=4>", 520 + "<cursor.down count=1>", 521 + "<erase.down>", 522 + "[PURPLE]◇[/PURPLE] foo 523 + [PINK]│[/PINK]", 524 + " 525 + ", 526 + "<cursor.show>", 527 + ] 528 + `; 529 + 333 530 exports[`text (isCI = true) > defaultValue sets the value but does not render 1`] = ` 334 531 [ 335 532 "<cursor.hide>", ··· 379 576 "<erase.down>", 380 577 "◇ foo 381 578 ", 579 + " 580 + ", 581 + "<cursor.show>", 582 + ] 583 + `; 584 + 585 + exports[`text (isCI = true) > partial theme only overrides specified options 1`] = ` 586 + [ 587 + "<cursor.hide>", 588 + "│ 589 + [CUSTOM]◆[/CUSTOM] foo 590 + │ _ 591 + └ 592 + ", 593 + "<cursor.backward count=999><cursor.up count=4>", 594 + "<cursor.down count=1>", 595 + "<erase.down>", 596 + "◇ foo 597 + │", 382 598 " 383 599 ", 384 600 "<cursor.show>",
+95
packages/prompts/test/text.test.ts
··· 238 238 239 239 expect(output.buffer).toMatchSnapshot(); 240 240 }); 241 + 242 + test('custom theme changes active symbol color', async () => { 243 + const result = prompts.text({ 244 + message: 'foo', 245 + input, 246 + output, 247 + theme: { 248 + formatSymbolActive: (str) => `[MAGENTA]${str}[/MAGENTA]`, 249 + formatGuide: (str) => `[BLUE]${str}[/BLUE]`, 250 + }, 251 + }); 252 + 253 + input.emit('keypress', '', { name: 'return' }); 254 + 255 + await result; 256 + 257 + expect(output.buffer).toMatchSnapshot(); 258 + }); 259 + 260 + test('custom theme changes submit symbol color', async () => { 261 + const result = prompts.text({ 262 + message: 'foo', 263 + input, 264 + output, 265 + theme: { 266 + formatSymbolSubmit: (str) => `[PURPLE]${str}[/PURPLE]`, 267 + formatGuideSubmit: (str) => `[PINK]${str}[/PINK]`, 268 + }, 269 + }); 270 + 271 + input.emit('keypress', '', { name: 'return' }); 272 + 273 + await result; 274 + 275 + expect(output.buffer).toMatchSnapshot(); 276 + }); 277 + 278 + test('custom theme changes cancel symbol color', async () => { 279 + const result = prompts.text({ 280 + message: 'foo', 281 + input, 282 + output, 283 + theme: { 284 + formatSymbolCancel: (str) => `[ORANGE]${str}[/ORANGE]`, 285 + formatGuideCancel: (str) => `[BROWN]${str}[/BROWN]`, 286 + }, 287 + }); 288 + 289 + input.emit('keypress', 'escape', { name: 'escape' }); 290 + 291 + await result; 292 + 293 + expect(output.buffer).toMatchSnapshot(); 294 + }); 295 + 296 + test('custom theme changes error colors', async () => { 297 + const result = prompts.text({ 298 + message: 'foo', 299 + validate: () => 'custom error', 300 + input, 301 + output, 302 + theme: { 303 + formatSymbolError: (str) => `[RED_BG]${str}[/RED_BG]`, 304 + formatGuideError: (str) => `[RED]${str}[/RED]`, 305 + formatErrorMessage: (str) => `[BOLD_RED]${str}[/BOLD_RED]`, 306 + }, 307 + }); 308 + 309 + input.emit('keypress', 'x', { name: 'x' }); 310 + input.emit('keypress', '', { name: 'return' }); 311 + // Cancel to exit after seeing the error 312 + input.emit('keypress', 'escape', { name: 'escape' }); 313 + 314 + await result; 315 + 316 + expect(output.buffer).toMatchSnapshot(); 317 + }); 318 + 319 + test('partial theme only overrides specified options', async () => { 320 + const result = prompts.text({ 321 + message: 'foo', 322 + input, 323 + output, 324 + theme: { 325 + // Only override the symbol, let guide use default 326 + formatSymbolActive: (str) => `[CUSTOM]${str}[/CUSTOM]`, 327 + }, 328 + }); 329 + 330 + input.emit('keypress', '', { name: 'return' }); 331 + 332 + await result; 333 + 334 + expect(output.buffer).toMatchSnapshot(); 335 + }); 241 336 });