diff --git a/.changeset/late-squids-obey.md b/.changeset/late-squids-obey.md new file mode 100644 index 0000000..1ab23d6 --- /dev/null +++ b/.changeset/late-squids-obey.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Support wrapping autocomplete and select prompts. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6331172..139954a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,6 @@ export { default as SelectPrompt } from './prompts/select.js'; export { default as SelectKeyPrompt } from './prompts/select-key.js'; export { default as TextPrompt } from './prompts/text.js'; export type { ClackState as State } from './types.js'; -export { block, getColumns, isCancel } from './utils/index.js'; +export { block, getColumns, getRows, isCancel } from './utils/index.js'; export type { ClackSettings } from './utils/settings.js'; export { settings, updateSettings } from './utils/settings.js'; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 9e7b33e..1c102d2 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -84,9 +84,15 @@ export function block({ } export const getColumns = (output: Writable): number => { - const withColumns = output as Writable & { columns?: number }; - if ('columns' in withColumns && typeof withColumns.columns === 'number') { - return withColumns.columns; + if ('columns' in output && typeof output.columns === 'number') { + return output.columns; } return 80; }; + +export const getRows = (output: Writable): number => { + if ('rows' in output && typeof output.rows === 'number') { + return output.rows; + } + return 20; +}; diff --git a/packages/prompts/src/autocomplete.ts b/packages/prompts/src/autocomplete.ts index 5cd79ca..e55b285 100644 --- a/packages/prompts/src/autocomplete.ts +++ b/packages/prompts/src/autocomplete.ts @@ -89,7 +89,7 @@ export const autocomplete = (opts: AutocompleteOptions) => { validate: opts.validate, render() { // Title and message display - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; + const headings = [`${color.gray(S_BAR)}`, `${symbol(this.state)} ${opts.message}`]; const userInput = this.userInput; const valueAsString = String(this.value ?? ''); const options = this.options; @@ -103,12 +103,12 @@ export const autocomplete = (opts: AutocompleteOptions) => { const selected = getSelectedOptions(this.selectedValues, options); const label = selected.length > 0 ? ` ${color.dim(selected.map(getLabel).join(', '))}` : ''; - return `${title}${color.gray(S_BAR)}${label}`; + return `${headings.join('\n')}\n${color.gray(S_BAR)}${label}`; } case 'cancel': { const userInputText = userInput ? ` ${color.strikethrough(color.dim(userInput))}` : ''; - return `${title}${color.gray(S_BAR)}${userInputText}`; + return `${headings.join('\n')}\n${color.gray(S_BAR)}${userInputText}`; } default: { @@ -129,6 +129,34 @@ export const autocomplete = (opts: AutocompleteOptions) => { ) : ''; + // No matches message + const noResults = + this.filteredOptions.length === 0 && userInput + ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] + : []; + + const validationError = + this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : []; + + headings.push( + `${color.cyan(S_BAR)}`, + `${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`, + ...noResults, + ...validationError + ); + + // Show instructions + const instructions = [ + `${color.dim('↑/↓')} to select`, + `${color.dim('Enter:')} confirm`, + `${color.dim('Type:')} to search`, + ]; + + const footers = [ + `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, + `${color.cyan(S_BAR_END)}`, + ]; + // Render options with selection const displayOptions = this.filteredOptions.length === 0 @@ -136,6 +164,8 @@ export const autocomplete = (opts: AutocompleteOptions) => { : limitOptions({ cursor: this.cursor, options: this.filteredOptions, + columnPadding: 3, // for `| ` + rowPadding: headings.length + footers.length, style: (option, active) => { const label = getLabel(option); const hint = @@ -151,31 +181,11 @@ export const autocomplete = (opts: AutocompleteOptions) => { output: opts.output, }); - // Show instructions - const instructions = [ - `${color.dim('↑/↓')} to select`, - `${color.dim('Enter:')} confirm`, - `${color.dim('Type:')} to search`, - ]; - - // No matches message - const noResults = - this.filteredOptions.length === 0 && userInput - ? [`${color.cyan(S_BAR)} ${color.yellow('No matches found')}`] - : []; - - const validationError = - this.state === 'error' ? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`] : []; - // Return the formatted prompt return [ - `${title}${color.cyan(S_BAR)}`, - `${color.cyan(S_BAR)} ${color.dim('Search:')}${searchText}${matches}`, - ...noResults, - ...validationError, + ...headings, ...displayOptions.map((option) => `${color.cyan(S_BAR)} ${option}`), - `${color.cyan(S_BAR)} ${color.dim(instructions.join(' • '))}`, - `${color.cyan(S_BAR_END)}`, + ...footers, ].join('\n'); } } diff --git a/packages/prompts/src/limit-options.ts b/packages/prompts/src/limit-options.ts index f1c317a..7f0f56f 100644 --- a/packages/prompts/src/limit-options.ts +++ b/packages/prompts/src/limit-options.ts @@ -1,5 +1,6 @@ import type { Writable } from 'node:stream'; -import { WriteStream } from 'node:tty'; +import { getColumns, getRows } from '@clack/core'; +import { wrapAnsi } from 'fast-wrap-ansi'; import color from 'picocolors'; import type { CommonOptions } from './common.js'; @@ -8,37 +9,129 @@ export interface LimitOptionsParams extends CommonOptions { maxItems: number | undefined; cursor: number; style: (option: TOption, active: boolean) => string; + columnPadding?: number; + rowPadding?: number; } +const trimLines = ( + groups: Array, + initialLineCount: number, + startIndex: number, + endIndex: number, + maxLines: number +) => { + 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; + } + } + return { lineCount, removals }; +}; + export const limitOptions = (params: LimitOptionsParams): string[] => { const { cursor, options, style } = params; const output: Writable = params.output ?? process.stdout; - const rows = output instanceof WriteStream && output.rows !== undefined ? output.rows : 10; + const columns = getColumns(output); + const columnPadding = params.columnPadding ?? 0; + const rowPadding = params.rowPadding ?? 4; + const maxWidth = columns - columnPadding; + const rows = getRows(output); const overflowFormat = color.dim('...'); const paramMaxItems = params.maxItems ?? Number.POSITIVE_INFINITY; - const outputMaxItems = Math.max(rows - 4, 0); + const outputMaxItems = Math.max(rows - rowPadding, 0); // We clamp to minimum 5 because anything less doesn't make sense UX wise - const maxItems = Math.min(outputMaxItems, Math.max(paramMaxItems, 5)); + const maxItems = Math.max(paramMaxItems, 5); let slidingWindowLocation = 0; - if (cursor >= slidingWindowLocation + maxItems - 3) { + if (cursor >= maxItems - 3) { slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0); - } else if (cursor < slidingWindowLocation + 2) { - slidingWindowLocation = Math.max(cursor - 2, 0); } - const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0; - const shouldRenderBottomEllipsis = + let shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0; + let shouldRenderBottomEllipsis = maxItems < options.length && slidingWindowLocation + maxItems < options.length; - return options - .slice(slidingWindowLocation, slidingWindowLocation + maxItems) - .map((option, i, arr) => { - const isTopLimit = i === 0 && shouldRenderTopEllipsis; - const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; - return isTopLimit || isBottomLimit - ? overflowFormat - : style(option, i + slidingWindowLocation === cursor); - }); + const slidingWindowLocationEnd = Math.min(slidingWindowLocation + maxItems, options.length); + const lineGroups: Array = []; + let lineCount = 0; + if (shouldRenderTopEllipsis) { + lineCount++; + } + if (shouldRenderBottomEllipsis) { + lineCount++; + } + + const slidingWindowLocationWithEllipsis = + slidingWindowLocation + (shouldRenderTopEllipsis ? 1 : 0); + const slidingWindowLocationEndWithEllipsis = + slidingWindowLocationEnd - (shouldRenderBottomEllipsis ? 1 : 0); + + for (let i = slidingWindowLocationWithEllipsis; i < slidingWindowLocationEndWithEllipsis; i++) { + const wrappedLines = wrapAnsi(style(options[i], i === cursor), maxWidth).split('\n'); + lineGroups.push(wrappedLines); + lineCount += wrappedLines.length; + } + + if (lineCount > outputMaxItems) { + let precedingRemovals = 0; + let followingRemovals = 0; + let newLineCount = lineCount; + const cursorGroupIndex = cursor - slidingWindowLocationWithEllipsis; + const trimLinesLocal = (startIndex: number, endIndex: number) => + trimLines(lineGroups, newLineCount, startIndex, endIndex, outputMaxItems); + + if (shouldRenderTopEllipsis) { + ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( + 0, + cursorGroupIndex + )); + if (newLineCount > outputMaxItems) { + ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( + cursorGroupIndex + 1, + lineGroups.length + )); + } + } else { + ({ lineCount: newLineCount, removals: followingRemovals } = trimLinesLocal( + cursorGroupIndex + 1, + lineGroups.length + )); + if (newLineCount > outputMaxItems) { + ({ lineCount: newLineCount, removals: precedingRemovals } = trimLinesLocal( + 0, + cursorGroupIndex + )); + } + } + + if (precedingRemovals > 0) { + shouldRenderTopEllipsis = true; + lineGroups.splice(0, precedingRemovals); + } + if (followingRemovals > 0) { + shouldRenderBottomEllipsis = true; + lineGroups.splice(lineGroups.length - followingRemovals, followingRemovals); + } + } + + const result: string[] = []; + if (shouldRenderTopEllipsis) { + result.push(overflowFormat); + } + for (const lineGroup of lineGroups) { + for (const line of lineGroup) { + result.push(line); + } + } + if (shouldRenderBottomEllipsis) { + result.push(overflowFormat); + } + + return result; }; diff --git a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap index 830ea79..632967d 100644 --- a/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap +++ b/packages/prompts/test/__snapshots__/autocomplete.test.ts.snap @@ -46,6 +46,34 @@ exports[`autocomplete > limits displayed options when maxItems is set 1`] = ` ] `; +exports[`autocomplete > renders bottom ellipsis when items do not fit 1`] = ` +[ + "", + "│ +◆ Select an option +│ +│ Search: _ +│ ● Line 0 +│ Line 1 +│ Line 2 +│ Line 3 +│ ... +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select an option +│ Line 0 +Line 1 +Line 2 +Line 3", + " +", + "", +] +`; + exports[`autocomplete > renders initial UI with message and instructions 1`] = ` [ "", @@ -96,6 +124,28 @@ exports[`autocomplete > renders placeholder if set 1`] = ` ] `; +exports[`autocomplete > renders top ellipsis when scrolled down and its do not fit 1`] = ` +[ + "", + "│ +◆ Select an option +│ +│ Search: _ +│ ... +│ ● Option 2 +│ ↑/↓ to select • Enter: confirm • Type: to search +└", + "", + "", + "", + "◇ Select an option +│ Option 2", + " +", + "", +] +`; + exports[`autocomplete > shows hint when option has hint and is focused 1`] = ` [ "", diff --git a/packages/prompts/test/autocomplete.test.ts b/packages/prompts/test/autocomplete.test.ts index eb14abb..dcd2789 100644 --- a/packages/prompts/test/autocomplete.test.ts +++ b/packages/prompts/test/autocomplete.test.ts @@ -168,6 +168,63 @@ describe('autocomplete', () => { expect(isCancel(value)).toBe(true); expect(output.buffer).toMatchSnapshot(); }); + + test('renders bottom ellipsis when items do not fit', async () => { + output.rows = 5; + + const options = [ + { + value: Array.from({ length: 4 }) + .map((_val, index) => `Line ${index}`) + .join('\n'), + }, + { + value: 'Option 2', + }, + ]; + + const result = autocomplete({ + message: 'Select an option', + options, + maxItems: 5, + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders top ellipsis when scrolled down and its do not fit', async () => { + output.rows = 5; + + const options = [ + { + value: 'option1', + label: Array.from({ length: 4 }) + .map((_val, index) => `Line ${index}`) + .join('\n'), + }, + { + value: 'option2', + label: 'Option 2', + }, + ]; + + const result = autocomplete({ + message: 'Select an option', + options, + initialValue: 'option2', + maxItems: 5, + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + await result; + expect(output.buffer).toMatchSnapshot(); + }); }); describe('autocompleteMultiselect', () => { diff --git a/packages/prompts/test/limit-options.test.ts b/packages/prompts/test/limit-options.test.ts new file mode 100644 index 0000000..95d1397 --- /dev/null +++ b/packages/prompts/test/limit-options.test.ts @@ -0,0 +1,244 @@ +import color from 'picocolors'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { type LimitOptionsParams, limitOptions } from '../src/index.js'; +import { MockWritable } from './test-utils.js'; + +describe('limitOptions', () => { + let output: MockWritable; + let options: LimitOptionsParams<{ value: string }>; + + beforeEach(() => { + output = new MockWritable(); + options = { + output, + options: [], + maxItems: undefined, + cursor: 0, + style: (option) => option.value, + columnPadding: undefined, + rowPadding: undefined, + }; + }); + + test('returns all items if they fit', async () => { + options.options = [{ value: 'Item 1' }, { value: 'Item 2' }, { value: 'Item 3' }]; + options.maxItems = 5; + const result = limitOptions(options); + expect(result).toEqual(['Item 1', 'Item 2', 'Item 3']); + }); + + test('clamps to 5 rows minimum', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + ]; + options.maxItems = 3; + const result = limitOptions(options); + expect(result).toEqual(['Item 1', 'Item 2', 'Item 3', 'Item 4', color.dim('...')]); + }); + + test('returns sliding window when cursor moves down', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 20; + options.maxItems = 5; + options.cursor = 6; + const result = limitOptions(options); + expect(result).toEqual([color.dim('...'), 'Item 6', 'Item 7', 'Item 8', color.dim('...')]); + }); + + test('returns sliding window near end of list', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + options.maxItems = 5; + options.cursor = 8; + const result = limitOptions(options); + expect(result).toEqual([color.dim('...'), 'Item 7', 'Item 8', 'Item 9', 'Item 10']); + }); + + test('handles empty options list', async () => { + options.options = []; + const result = limitOptions(options); + expect(result).toEqual([]); + }); + + test('if items exceed output height, clamp to fit', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 7; + options.maxItems = 10; + const result = limitOptions(options); + expect(result).toEqual(['Item 1', 'Item 2', 'Item 3', color.dim('...')]); + }); + + test('handle multi-line item clamping (start)', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { + value: Array.from({ length: 4 }) + .map((_val, index) => `A long item that will take up a lot of space (line ${index})`) + .join('\n'), + }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 14; + options.maxItems = 10; + const result = limitOptions(options); + expect(result).toEqual([ + 'Item 1', + 'Item 2', + '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)', + 'A long item that will take up a lot of space (line 2)', + 'A long item that will take up a lot of space (line 3)', + 'Item 4', + 'Item 5', + 'Item 6', + 'Item 7', + 'Item 8', + color.dim('...'), + ]); + }); + + test('handle multi-line item clamping (middle)', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { + value: Array.from({ length: 4 }) + .map((_val, index) => `A long item that will take up a lot of space (line ${index})`) + .join('\n'), + }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { value: 'Item 8' }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 14; + options.maxItems = 10; + options.cursor = 7; + const result = limitOptions(options); + expect(result).toEqual([ + color.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)', + 'A long item that will take up a lot of space (line 2)', + 'A long item that will take up a lot of space (line 3)', + 'Item 6', + 'Item 7', + 'Item 8', + color.dim('...'), + ]); + }); + + test('handle multi-line item clamping (end)', async () => { + options.options = [ + { value: 'Item 1' }, + { value: 'Item 2' }, + { value: 'Item 3' }, + { value: 'Item 4' }, + { value: 'Item 5' }, + { value: 'Item 6' }, + { value: 'Item 7' }, + { + value: Array.from({ length: 4 }) + .map((_val, index) => `A long item that will take up a lot of space (line ${index})`) + .join('\n'), + }, + { value: 'Item 9' }, + { value: 'Item 10' }, + ]; + output.rows = 14; + options.maxItems = 10; + options.cursor = 9; + const result = limitOptions(options); + expect(result).toEqual([ + color.dim('...'), + 'Item 4', + 'Item 5', + 'Item 6', + 'Item 7', + '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)', + 'A long item that will take up a lot of space (line 2)', + 'A long item that will take up a lot of space (line 3)', + 'Item 9', + 'Item 10', + ]); + }); + + test('style option is used to style lines', async () => { + options.options = [{ value: 'Item 1' }, { value: 'Item 2' }, { value: 'Item 3' }]; + options.maxItems = 5; + options.style = (option) => `-- ${option.value} --`; + const result = limitOptions(options); + expect(result).toEqual(['-- Item 1 --', '-- Item 2 --', '-- Item 3 --']); + }); + + test('style option styles across multi-line items', async () => { + options.options = [{ value: 'Item 1' }, { value: 'Item 2' }, { value: 'Item 3\nContinued' }]; + options.maxItems = 5; + options.style = (option) => `-- ${option.value} --`; + const result = limitOptions(options); + expect(result).toEqual(['-- Item 1 --', '-- Item 2 --', '-- Item 3', 'Continued --']); + }); + + test('style option receives correct cursor index', async () => { + options.options = [{ value: 'Item 1' }, { value: 'Item 2' }, { value: 'Item 3' }]; + options.maxItems = 5; + options.cursor = 1; + options.style = (option, isSelected) => { + return isSelected ? `-- ${option.value} --` : option.value; + }; + const result = limitOptions(options); + expect(result).toEqual(['Item 1', '-- Item 2 --', 'Item 3']); + }); +}); diff --git a/packages/prompts/test/test-utils.ts b/packages/prompts/test/test-utils.ts index 5a74f03..414ce24 100644 --- a/packages/prompts/test/test-utils.ts +++ b/packages/prompts/test/test-utils.ts @@ -4,6 +4,7 @@ export class MockWritable extends Writable { public buffer: string[] = []; public isTTY = false; public columns = 80; + public rows = 20; _write( chunk: any, diff --git a/tsconfig.json b/tsconfig.json index 311a852..5350b4a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,5 +17,5 @@ "@clack/prompts": ["./packages/prompts/src/index.ts"] } }, - "include": ["packages/*/src/**/*"] + "include": ["packages/*/src/**/*.ts", "packages/*/test/**/*.ts"] }