[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: trim lines from correct end (#532)

authored by

James Garbutt and committed by
GitHub
(May 9, 2026, 5:14 AM -0500) 54be8d7a 266eaf9c

+109 -55
+6
.changeset/moody-lies-play.md
··· 1 + --- 2 + "@clack/prompts": patch 3 + "@clack/core": patch 4 + --- 5 + 6 + Fix line wrapping and overflow computation in group multi-select and other list-like prompts.
+8 -2
packages/core/src/utils/index.ts
··· 103 103 text: string, 104 104 prefix: string, 105 105 startPrefix: string = prefix, 106 + endPrefix: string = prefix, 106 107 lineFormatter?: (line: string, index: number) => string 107 108 ): string { 108 109 const columns = getColumns(output ?? stdout); ··· 112 113 }); 113 114 const lines = wrapped 114 115 .split('\n') 115 - .map((line, index) => { 116 + .map((line, index, arr) => { 116 117 const lineString = lineFormatter ? lineFormatter(line, index) : line; 117 - return `${index === 0 ? startPrefix : prefix}${lineString}`; 118 + if (index === 0) { 119 + return `${startPrefix}${lineString}`; 120 + } else if (index === arr.length - 1) { 121 + return `${endPrefix}${lineString}`; 122 + } 123 + return `${prefix}${lineString}`; 118 124 }) 119 125 .join('\n'); 120 126 return lines;
+57 -14
packages/prompts/src/group-multi-select.ts
··· 1 1 import { styleText } from 'node:util'; 2 - import { GroupMultiSelectPrompt, settings } from '@clack/core'; 2 + import { GroupMultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; 3 3 import { 4 4 type CommonOptions, 5 5 S_BAR, ··· 41 41 const isItem = typeof option.group === 'string'; 42 42 const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); 43 43 const isLast = isItem && next && next.group === true; 44 - const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : ''; 44 + let prefix = ''; 45 + let prefixEnd = ''; 46 + if (isItem) { 47 + if (selectableGroups) { 48 + prefix = isLast ? `${S_BAR_END} ` : `${S_BAR} `; 49 + prefixEnd = isLast ? ` ` : `${S_BAR} `; 50 + } else { 51 + prefix = ' '; 52 + } 53 + } 45 54 let spacingPrefix = ''; 46 55 if (groupSpacing > 0 && !isItem) { 47 56 spacingPrefix = '\n'.repeat(groupSpacing); 48 57 } 49 58 50 59 if (state === 'active') { 51 - return `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${label}${ 52 - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' 53 - }`; 60 + return wrapTextWithPrefix( 61 + opts.output, 62 + `${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`, 63 + `${spacingPrefix}${styleText('dim', prefix)} `, 64 + `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} `, 65 + `${spacingPrefix}${styleText('dim', prefixEnd)} ` 66 + ); 54 67 } 55 68 if (state === 'group-active') { 56 - return `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${styleText('dim', label)}`; 69 + return wrapTextWithPrefix( 70 + opts.output, 71 + label, 72 + `${spacingPrefix}${prefix} `, 73 + `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} `, 74 + `${spacingPrefix}${prefixEnd} `, 75 + (str) => styleText('dim', str) 76 + ); 57 77 } 58 78 if (state === 'group-active-selected') { 59 - return `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} ${styleText('dim', label)}`; 79 + return wrapTextWithPrefix( 80 + opts.output, 81 + label, 82 + `${spacingPrefix}${prefix} `, 83 + `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} `, 84 + `${spacingPrefix}${prefixEnd} `, 85 + (str) => styleText('dim', str) 86 + ); 60 87 } 61 88 if (state === 'selected') { 62 89 const selectedCheckbox = 63 90 isItem || selectableGroups ? styleText('green', S_CHECKBOX_SELECTED) : ''; 64 - return `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} ${styleText('dim', label)}${ 65 - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' 66 - }`; 91 + return wrapTextWithPrefix( 92 + opts.output, 93 + `${label}${option.hint ? ` (${option.hint})` : ''}`, 94 + `${spacingPrefix}${styleText('dim', prefix)} `, 95 + `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} `, 96 + `${spacingPrefix}${styleText('dim', prefixEnd)} `, 97 + (str) => styleText('dim', str) 98 + ); 67 99 } 68 100 if (state === 'cancelled') { 69 101 return `${styleText(['strikethrough', 'dim'], label)}`; 70 102 } 71 103 if (state === 'active-selected') { 72 - return `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} ${label}${ 73 - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' 74 - }`; 104 + return wrapTextWithPrefix( 105 + opts.output, 106 + `${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`, 107 + `${spacingPrefix}${styleText('dim', prefix)} `, 108 + `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} `, 109 + `${spacingPrefix}${styleText('dim', prefixEnd)} ` 110 + ); 75 111 } 76 112 if (state === 'submitted') { 77 113 return `${styleText('dim', label)}`; 78 114 } 79 115 const unselectedCheckbox = 80 116 isItem || selectableGroups ? styleText('dim', S_CHECKBOX_INACTIVE) : ''; 81 - return `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} ${styleText('dim', label)}`; 117 + return wrapTextWithPrefix( 118 + opts.output, 119 + label, 120 + `${spacingPrefix}${styleText('dim', prefix)} `, 121 + `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} `, 122 + `${spacingPrefix}${styleText('dim', prefixEnd)} `, 123 + (str) => styleText('dim', str) 124 + ); 82 125 }; 83 126 const required = opts.required ?? true; 84 127
+34 -27
packages/prompts/src/limit-options.ts
··· 17 17 initialLineCount: number, 18 18 startIndex: number, 19 19 endIndex: number, 20 - maxLines: number 20 + maxLines: number, 21 + fromEnd = false 21 22 ) => { 22 23 let lineCount = initialLineCount; 23 24 let removals = 0; 24 - for (let i = startIndex; i < endIndex; i++) { 25 - const group = groups[i]; 26 - lineCount = lineCount - group.length; 27 - removals++; 28 - if (lineCount <= maxLines) { 29 - break; 25 + if (fromEnd) { 26 + for (let i = endIndex - 1; i >= startIndex; i--) { 27 + lineCount -= groups[i].length; 28 + removals++; 29 + if (lineCount <= maxLines) break; 30 + } 31 + } else { 32 + for (let i = startIndex; i < endIndex; i++) { 33 + lineCount -= groups[i].length; 34 + removals++; 35 + if (lineCount <= maxLines) break; 30 36 } 31 37 } 32 38 return { lineCount, removals }; ··· 94 100 let followingRemovals = 0; 95 101 let newLineCount = lineCount; 96 102 const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis; 97 - const trimLinesLocal = (startIndex: number, endIndex: number) => 98 - trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems); 103 + let adjustedMax = outputMaxItems; 104 + const trimPreceding = () => 105 + trimLines(lineGroups, newLineCount, 0, cursorGroupIndex, adjustedMax); 106 + const trimFollowing = () => 107 + trimLines( 108 + lineGroups, 109 + newLineCount, 110 + cursorGroupIndex + 1, 111 + lineGroups.length, 112 + adjustedMax, 113 + true 114 + ); 99 115 100 116 if (shouldRenderTopEllipsis) { 101 - ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( 102 - 0, 103 - cursorGroupIndex 104 - )); 105 - if (newLineCount > outputMaxItems) { 106 - ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( 107 - cursorGroupIndex + 1, 108 - lineGroups.length 109 - )); 117 + ({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding()); 118 + if (newLineCount > adjustedMax) { 119 + if (!shouldRenderBottomEllipsis) adjustedMax -= 1; 120 + ({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing()); 110 121 } 111 122 } else { 112 - ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( 113 - cursorGroupIndex + 1, 114 - lineGroups.length 115 - )); 116 - if (newLineCount > outputMaxItems) { 117 - ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( 118 - 0, 119 - cursorGroupIndex 120 - )); 123 + if (!shouldRenderBottomEllipsis) adjustedMax -= 1; 124 + ({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing()); 125 + if (newLineCount > adjustedMax) { 126 + adjustedMax -= 1; 127 + ({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding()); 121 128 } 122 129 } 123 130
+2 -2
packages/prompts/src/multi-line.ts
··· 41 41 case 'submit': { 42 42 const submitPrefix = `${styleText('gray', S_BAR)} `; 43 43 const lines = hasGuide 44 - ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, (str) => 44 + ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, undefined, (str) => 45 45 styleText('dim', str) 46 46 ) 47 47 : value ··· 52 52 case 'cancel': { 53 53 const cancelPrefix = `${styleText('gray', S_BAR)} `; 54 54 const lines = hasGuide 55 - ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, (str) => 55 + ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, undefined, (str) => 56 56 styleText(['strikethrough', 'dim'], str) 57 57 ) 58 58 : value
+2 -4
packages/prompts/test/__snapshots__/select.test.ts.snap
··· 220 220 "│ 221 221 ◆ Whatever 222 222 │ ... 223 - │ ○ Option 0 224 223 │ ○ Option 1 225 224 │ ○ Option 2 226 225 │ ● Option 3 227 226 └ 228 227 ", 229 - "<cursor.backward count=999><cursor.up count=8>", 228 + "<cursor.backward count=999><cursor.up count=7>", 230 229 "<cursor.down count=1>", 231 230 "<erase.down>", 232 231 "◇ Whatever ··· 700 699 "│ 701 700 ◆ Whatever 702 701 │ ... 703 - │ ○ Option 0 704 702 │ ○ Option 1 705 703 │ ○ Option 2 706 704 │ ● Option 3 707 705 └ 708 706 ", 709 - "<cursor.backward count=999><cursor.up count=8>", 707 + "<cursor.backward count=999><cursor.up count=7>", 710 708 "<cursor.down count=1>", 711 709 "<erase.down>", 712 710 "◇ Whatever
-6
packages/prompts/test/limit-options.test.ts
··· 142 142 'Item 4', 143 143 'Item 5', 144 144 'Item 6', 145 - 'Item 7', 146 - 'Item 8', 147 145 styleText('dim', '...'), 148 146 ]); 149 147 }); ··· 171 169 const result = limitOptions(options); 172 170 expect(result).toEqual([ 173 171 styleText('dim', '...'), 174 - 'Item 2', 175 - 'Item 3', 176 172 'Item 4', 177 173 'A long item that will take up a lot of space (line 0)', 178 174 'A long item that will take up a lot of space (line 1)', ··· 208 204 const result = limitOptions(options); 209 205 expect(result).toEqual([ 210 206 styleText('dim', '...'), 211 - 'Item 4', 212 - 'Item 5', 213 207 'Item 6', 214 208 'Item 7', 215 209 'A long item that will take up a lot of space (line 0)',