From 9843012d2b1d17cf1a38d50bffdca60d58933516 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Fri, 8 May 2026 11:26:59 +0100 Subject: [PATCH] fix: trim lines from correct end This changes the limit-options logic such that we trim lines from the end of the list we're dealing with. For example, if we know we are already going to render a top ellipsis, we should trim the preceding lines (top of the list) until we can fit on screen. Similarly, if we are rendering a bottom ellipsis, do the same for following lines. If there's currently no ellipsis, we trim the following lines, then the preceding lines. This particular part can be improved one day in a follow up. On top of this, correct wrapping has been added for the group multi-select prompt's options. --- .changeset/moody-lies-play.md | 6 ++ packages/core/src/utils/index.ts | 10 ++- packages/prompts/src/group-multi-select.ts | 71 +++++++++++++++---- packages/prompts/src/limit-options.ts | 61 +++++++++------- packages/prompts/src/multi-line.ts | 4 +- .../test/__snapshots__/select.test.ts.snap | 6 +- packages/prompts/test/limit-options.test.ts | 6 -- 7 files changed, 109 insertions(+), 55 deletions(-) create mode 100644 .changeset/moody-lies-play.md diff --git a/.changeset/moody-lies-play.md b/.changeset/moody-lies-play.md new file mode 100644 index 00000000..db103e94 --- /dev/null +++ b/.changeset/moody-lies-play.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Fix line wrapping and overflow computation in group multi-select and other list-like prompts. diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index b2215721..84ddd611 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -103,6 +103,7 @@ export function wrapTextWithPrefix( text: string, prefix: string, startPrefix: string = prefix, + endPrefix: string = prefix, lineFormatter?: (line: string, index: number) => string ): string { const columns = getColumns(output ?? stdout); @@ -112,9 +113,14 @@ export function wrapTextWithPrefix( }); const lines = wrapped .split('\n') - .map((line, index) => { + .map((line, index, arr) => { const lineString = lineFormatter ? lineFormatter(line, index) : line; - return `${index === 0 ? startPrefix : prefix}${lineString}`; + if (index === 0) { + return `${startPrefix}${lineString}`; + } else if (index === arr.length - 1) { + return `${endPrefix}${lineString}`; + } + return `${prefix}${lineString}`; }) .join('\n'); return lines; diff --git a/packages/prompts/src/group-multi-select.ts b/packages/prompts/src/group-multi-select.ts index 80445453..d8df0bfb 100644 --- a/packages/prompts/src/group-multi-select.ts +++ b/packages/prompts/src/group-multi-select.ts @@ -1,5 +1,5 @@ import { styleText } from 'node:util'; -import { GroupMultiSelectPrompt, settings } from '@clack/core'; +import { GroupMultiSelectPrompt, settings, wrapTextWithPrefix } from '@clack/core'; import { type CommonOptions, S_BAR, @@ -41,44 +41,87 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const isItem = typeof option.group === 'string'; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); const isLast = isItem && next && next.group === true; - const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : ''; + let prefix = ''; + let prefixEnd = ''; + if (isItem) { + if (selectableGroups) { + prefix = isLast ? `${S_BAR_END} ` : `${S_BAR} `; + prefixEnd = isLast ? ` ` : `${S_BAR} `; + } else { + prefix = ' '; + } + } let spacingPrefix = ''; if (groupSpacing > 0 && !isItem) { spacingPrefix = '\n'.repeat(groupSpacing); } if (state === 'active') { - return `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${label}${ - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' - }`; + return wrapTextWithPrefix( + opts.output, + `${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${styleText('cyan', S_CHECKBOX_ACTIVE)} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} ` + ); } if (state === 'group-active') { - return `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} ${styleText('dim', label)}`; + return wrapTextWithPrefix( + opts.output, + label, + `${spacingPrefix}${prefix} `, + `${spacingPrefix}${prefix}${styleText('cyan', S_CHECKBOX_ACTIVE)} `, + `${spacingPrefix}${prefixEnd} `, + (str) => styleText('dim', str) + ); } if (state === 'group-active-selected') { - return `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} ${styleText('dim', label)}`; + return wrapTextWithPrefix( + opts.output, + label, + `${spacingPrefix}${prefix} `, + `${spacingPrefix}${prefix}${styleText('green', S_CHECKBOX_SELECTED)} `, + `${spacingPrefix}${prefixEnd} `, + (str) => styleText('dim', str) + ); } if (state === 'selected') { const selectedCheckbox = isItem || selectableGroups ? styleText('green', S_CHECKBOX_SELECTED) : ''; - return `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} ${styleText('dim', label)}${ - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' - }`; + return wrapTextWithPrefix( + opts.output, + `${label}${option.hint ? ` (${option.hint})` : ''}`, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${selectedCheckbox} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} `, + (str) => styleText('dim', str) + ); } if (state === 'cancelled') { return `${styleText(['strikethrough', 'dim'], label)}`; } if (state === 'active-selected') { - return `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} ${label}${ - option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : '' - }`; + return wrapTextWithPrefix( + opts.output, + `${label}${option.hint ? ` ${styleText('dim', `(${option.hint})`)}` : ''}`, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${styleText('green', S_CHECKBOX_SELECTED)} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} ` + ); } if (state === 'submitted') { return `${styleText('dim', label)}`; } const unselectedCheckbox = isItem || selectableGroups ? styleText('dim', S_CHECKBOX_INACTIVE) : ''; - return `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} ${styleText('dim', label)}`; + return wrapTextWithPrefix( + opts.output, + label, + `${spacingPrefix}${styleText('dim', prefix)} `, + `${spacingPrefix}${styleText('dim', prefix)}${unselectedCheckbox} `, + `${spacingPrefix}${styleText('dim', prefixEnd)} `, + (str) => styleText('dim', str) + ); }; const required = opts.required ?? true; diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index f9b63d3d..1eef403b 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -17,16 +17,22 @@ const trimLines = ( initialLineCount: number, startIndex: number, endIndex: number, - maxLines: number + maxLines: number, + fromEnd = false ) => { let lineCount = initialLineCount; let removals = 0; - for (let i = startIndex; i < endIndex; i++) { - const group = groups[i]; - lineCount = lineCount - group.length; - removals++; - if (lineCount <= maxLines) { - break; + if (fromEnd) { + for (let i = endIndex - 1; i >= startIndex; i--) { + lineCount -= groups[i].length; + removals++; + if (lineCount <= maxLines) break; + } + } else { + for (let i = startIndex; i < endIndex; i++) { + lineCount -= groups[i].length; + removals++; + if (lineCount <= maxLines) break; } } return { lineCount, removals }; @@ -94,30 +100,31 @@ export const limitOptions = ({ let followingRemovals = 0; let newLineCount = lineCount; const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis; - const trimLinesLocal = (startIndex: number, endIndex: number) => - trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems); + let adjustedMax = outputMaxItems; + const trimPreceding = () => + trimLines(lineGroups, newLineCount, 0, cursorGroupIndex, adjustedMax); + const trimFollowing = () => + trimLines( + lineGroups, + newLineCount, + cursorGroupIndex + 1, + lineGroups.length, + adjustedMax, + true + ); if (shouldRenderTopEllipsis) { - ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( - 0, - cursorGroupIndex - )); - if (newLineCount > outputMaxItems) { - ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( - cursorGroupIndex + 1, - lineGroups.length - )); + ({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding()); + if (newLineCount > adjustedMax) { + if (!shouldRenderBottomEllipsis) adjustedMax -= 1; + ({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing()); } } else { - ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( - cursorGroupIndex + 1, - lineGroups.length - )); - if (newLineCount > outputMaxItems) { - ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( - 0, - cursorGroupIndex - )); + if (!shouldRenderBottomEllipsis) adjustedMax -= 1; + ({ lineCount: newLineCount, removals: followingRemovals } = trimFollowing()); + if (newLineCount > adjustedMax) { + adjustedMax -= 1; + ({ lineCount: newLineCount, removals: precedingRemovals } = trimPreceding()); } } diff --git a/packages/prompts/src/multi-line.ts b/packages/prompts/src/multi-line.ts index 4eac8d8b..4f984705 100644 --- a/packages/prompts/src/multi-line.ts +++ b/packages/prompts/src/multi-line.ts @@ -41,7 +41,7 @@ export const multiline = (opts: MultiLineOptions) => { case 'submit': { const submitPrefix = `${styleText('gray', S_BAR)} `; const lines = hasGuide - ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, (str) => + ? wrapTextWithPrefix(opts.output, value, submitPrefix, undefined, undefined, (str) => styleText('dim', str) ) : value @@ -52,7 +52,7 @@ export const multiline = (opts: MultiLineOptions) => { case 'cancel': { const cancelPrefix = `${styleText('gray', S_BAR)} `; const lines = hasGuide - ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, (str) => + ? wrapTextWithPrefix(opts.output, value, cancelPrefix, undefined, undefined, (str) => styleText(['strikethrough', 'dim'], str) ) : value diff --git a/packages/prompts/test/__snapshots__/select.test.ts.snap b/packages/prompts/test/__snapshots__/select.test.ts.snap index 4d046e4d..b213bff7 100644 --- a/packages/prompts/test/__snapshots__/select.test.ts.snap +++ b/packages/prompts/test/__snapshots__/select.test.ts.snap @@ -220,13 +220,12 @@ exports[`select (isCI = false) > handles mixed size re-renders 1`] = ` "│ ◆ Whatever │ ... -│ ○ Option 0 │ ○ Option 1 │ ○ Option 2 │ ● Option 3 └ ", - "", + "", "", "", "◇ Whatever @@ -700,13 +699,12 @@ exports[`select (isCI = true) > handles mixed size re-renders 1`] = ` "│ ◆ Whatever │ ... -│ ○ Option 0 │ ○ Option 1 │ ○ Option 2 │ ● Option 3 └ ", - "", + "", "", "", "◇ Whatever diff --git a/packages/prompts/test/limit-options.test.ts b/packages/prompts/test/limit-options.test.ts index 91fd8268..2cfc350b 100644 --- a/packages/prompts/test/limit-options.test.ts +++ b/packages/prompts/test/limit-options.test.ts @@ -142,8 +142,6 @@ describe('limitOptions', () => { 'Item 4', 'Item 5', 'Item 6', - 'Item 7', - 'Item 8', styleText('dim', '...'), ]); }); @@ -171,8 +169,6 @@ describe('limitOptions', () => { const result = limitOptions(options); expect(result).toEqual([ styleText('dim', '...'), - 'Item 2', - 'Item 3', 'Item 4', 'A long item that will take up a lot of space (line 0)', 'A long item that will take up a lot of space (line 1)', @@ -208,8 +204,6 @@ describe('limitOptions', () => { const result = limitOptions(options); expect(result).toEqual([ styleText('dim', '...'), - 'Item 4', - 'Item 5', 'Item 6', 'Item 7', 'A long item that will take up a lot of space (line 0)',