diff --git a/.changeset/dull-singers-mate.md b/.changeset/dull-singers-mate.md new file mode 100644 index 00000000..026140d8 --- /dev/null +++ b/.changeset/dull-singers-mate.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Wrap spinner output to allow for multi-line/wrapped messages. diff --git a/packages/prompts/package.json b/packages/prompts/package.json index 705d273b..1cc8e042 100644 --- a/packages/prompts/package.json +++ b/packages/prompts/package.json @@ -61,6 +61,7 @@ "is-unicode-supported": "^1.3.0", "memfs": "^4.17.2", "vitest": "^3.2.4", - "vitest-ansi-serializer": "^0.1.2" + "vitest-ansi-serializer": "^0.1.2", + "wrap-ansi": "^8.1.0" } } diff --git a/packages/prompts/src/spinner.ts b/packages/prompts/src/spinner.ts index 434ceb1d..0e7545de 100644 --- a/packages/prompts/src/spinner.ts +++ b/packages/prompts/src/spinner.ts @@ -1,6 +1,7 @@ -import { block, settings } from '@clack/core'; +import { block, getColumns, settings } from '@clack/core'; import color from 'picocolors'; import { cursor, erase } from 'sisteransi'; +import wrap from 'wrap-ansi'; import { type CommonOptions, S_BAR, @@ -46,6 +47,7 @@ export const spinner = ({ let _message = ''; let _prevMessage: string | undefined ; let _origin: number = performance.now(); + const columns = getColumns(output); const handleExit = (code: number) => { const msg = @@ -94,9 +96,14 @@ export const spinner = ({ const clearPrevMessage = () => { if (_prevMessage === undefined) return; if (isCI) output.write('\n'); - const prevLines = _prevMessage.split('\n'); - output.write(cursor.move(-999, prevLines.length - 1)); - output.write(erase.down(prevLines.length)); + const wrapped = wrap(_prevMessage, columns, { + hard: true, + trim: false, + }); + const prevLines = wrapped.split('\n'); + output.write(cursor.up(prevLines.length - 1)); + output.write(cursor.to(0)); + output.write(erase.down()); }; const removeTrailingDots = (msg: string): string => { @@ -126,16 +133,23 @@ export const spinner = ({ clearPrevMessage(); _prevMessage = _message; const frame = color.magenta(frames[frameIndex]); + let outputMessage: string; if (isCI) { - output.write(`${frame} ${_message}...`); + outputMessage = `${frame} ${_message}...`; } else if (indicator === 'timer') { - output.write(`${frame} ${_message} ${formatTimer(_origin)}`); + outputMessage = `${frame} ${_message} ${formatTimer(_origin)}`; } else { const loadingDots = '.'.repeat(Math.floor(indicatorTimer)).slice(0, 3); - output.write(`${frame} ${_message}${loadingDots}`); + outputMessage = `${frame} ${_message}${loadingDots}`; } + const wrapped = wrap(outputMessage, columns, { + hard: true, + trim: false, + }); + output.write(wrapped); + frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; // indicator increase by 1 every 8 frames indicatorTimer = indicatorTimer < 4 ? indicatorTimer + 0.125 : 0; diff --git a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap b/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap index 596cb329..86593d50 100644 --- a/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap +++ b/packages/prompts/test/__snapshots__/progress-bar.test.ts.snap @@ -6,7 +6,8 @@ exports[`prompts - progress (isCI = false) > message > sets message for next fra "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◐ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ foo", ] @@ -95,13 +96,16 @@ exports[`prompts - progress (isCI = false) > start > renders frames at interval "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◐ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◓ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◑ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", ] @@ -131,7 +135,8 @@ exports[`prompts - progress (isCI = false) > stop > renders cancel symbol if cod "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "■ ", @@ -145,7 +150,8 @@ exports[`prompts - progress (isCI = false) > stop > renders error symbol if code "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "▲ ", @@ -159,7 +165,8 @@ exports[`prompts - progress (isCI = false) > stop > renders message 1`] = ` "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◇ foo ", @@ -173,7 +180,8 @@ exports[`prompts - progress (isCI = false) > stop > renders message without remo "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◇ foo. ", @@ -187,7 +195,8 @@ exports[`prompts - progress (isCI = false) > stop > renders submit symbol and st "│ ", "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ", - "", + "", + "", "", "◇ ", @@ -201,16 +210,20 @@ exports[`prompts - progress (isCI = false) > style > renders block progressbar 1 "│ ", "◒ ██████████ ", - "", + "", + "", "", "◐ ██████████ ", - "", + "", + "", "", "◓ ██████████ ", - "", + "", + "", "", "◑ ██████████ ", - "", + "", + "", "", "◇ ", @@ -224,16 +237,20 @@ exports[`prompts - progress (isCI = false) > style > renders heavy progressbar 1 "│ ", "◒ ━━━━━━━━━━ ", - "", + "", + "", "", "◐ ━━━━━━━━━━ ", - "", + "", + "", "", "◓ ━━━━━━━━━━ ", - "", + "", + "", "", "◑ ━━━━━━━━━━ ", - "", + "", + "", "", "◇ ", @@ -247,16 +264,20 @@ exports[`prompts - progress (isCI = false) > style > renders light progressbar 1 "│ ", "◒ ────────── ", - "", + "", + "", "", "◐ ────────── ", - "", + "", + "", "", "◓ ────────── ", - "", + "", + "", "", "◑ ────────── ", - "", + "", + "", "", "◇ ", @@ -272,7 +293,8 @@ exports[`prompts - progress (isCI = true) > message > sets message for next fram "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", " ", - "", + "", + "", "", "◐ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ foo...", ] @@ -390,7 +412,8 @@ exports[`prompts - progress (isCI = true) > stop > renders cancel symbol if code "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", " ", - "", + "", + "", "", "■ ", @@ -406,7 +429,8 @@ exports[`prompts - progress (isCI = true) > stop > renders error symbol if code "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", " ", - "", + "", + "", "", "▲ ", @@ -422,7 +446,8 @@ exports[`prompts - progress (isCI = true) > stop > renders message 1`] = ` "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", " ", - "", + "", + "", "", "◇ foo ", @@ -438,7 +463,8 @@ exports[`prompts - progress (isCI = true) > stop > renders message without remov "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", " ", - "", + "", + "", "", "◇ foo. ", @@ -454,7 +480,8 @@ exports[`prompts - progress (isCI = true) > stop > renders submit symbol and sto "◒ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ...", " ", - "", + "", + "", "", "◇ ", @@ -470,12 +497,14 @@ exports[`prompts - progress (isCI = true) > style > renders block progressbar 1` "◒ ██████████ ...", " ", - "", + "", + "", "", "◐ ██████████ ...", " ", - "", + "", + "", "", "◇ ", @@ -491,12 +520,14 @@ exports[`prompts - progress (isCI = true) > style > renders heavy progressbar 1` "◒ ━━━━━━━━━━ ...", " ", - "", + "", + "", "", "◐ ━━━━━━━━━━ ...", " ", - "", + "", + "", "", "◇ ", @@ -512,12 +543,14 @@ exports[`prompts - progress (isCI = true) > style > renders light progressbar 1` "◒ ────────── ...", " ", - "", + "", + "", "", "◐ ────────── ...", " ", - "", + "", + "", "", "◇ ", diff --git a/packages/prompts/test/__snapshots__/spinner.test.ts.snap b/packages/prompts/test/__snapshots__/spinner.test.ts.snap index 244e3ef4..0d2c987e 100644 --- a/packages/prompts/test/__snapshots__/spinner.test.ts.snap +++ b/packages/prompts/test/__snapshots__/spinner.test.ts.snap @@ -17,16 +17,20 @@ exports[`spinner (isCI = false) > indicator customization > custom delay 1`] = ` "│ ", "◒ ", - "", + "", + "", "", "◐ ", - "", + "", + "", "", "◓ ", - "", + "", + "", "", "◑ ", - "", + "", + "", "", "◇ ", @@ -40,16 +44,20 @@ exports[`spinner (isCI = false) > indicator customization > custom frames 1`] = "│ ", "🐴 ", - "", + "", + "", "", "🦋 ", - "", + "", + "", "", "🐙 ", - "", + "", + "", "", "🐶 ", - "", + "", + "", "", "◇ ", @@ -63,196 +71,260 @@ exports[`spinner (isCI = false) > indicator customization > custom frames with l "│ ", "0 ", - "", + "", + "", "", "1 ", - "", + "", + "", "", "2 ", - "", + "", + "", "", "3 ", - "", + "", + "", "", "4 ", - "", + "", + "", "", "5 ", - "", + "", + "", "", "6 ", - "", + "", + "", "", "7 ", - "", + "", + "", "", "8 .", - "", + "", + "", "", "9 .", - "", + "", + "", "", "0 .", - "", + "", + "", "", "1 .", - "", + "", + "", "", "2 .", - "", + "", + "", "", "3 .", - "", + "", + "", "", "4 .", - "", + "", + "", "", "5 .", - "", + "", + "", "", "6 ..", - "", + "", + "", "", "7 ..", - "", + "", + "", "", "8 ..", - "", + "", + "", "", "9 ..", - "", + "", + "", "", "0 ..", - "", + "", + "", "", "1 ..", - "", + "", + "", "", "2 ..", - "", + "", + "", "", "3 ..", - "", + "", + "", "", "4 ...", - "", + "", + "", "", "5 ...", - "", + "", + "", "", "6 ...", - "", + "", + "", "", "7 ...", - "", + "", + "", "", "8 ...", - "", + "", + "", "", "9 ...", - "", + "", + "", "", "0 ...", - "", + "", + "", "", "1 ...", - "", + "", + "", "", "2 ...", - "", + "", + "", "", "3 ", - "", + "", + "", "", "4 ", - "", + "", + "", "", "5 ", - "", + "", + "", "", "6 ", - "", + "", + "", "", "7 ", - "", + "", + "", "", "8 ", - "", + "", + "", "", "9 ", - "", + "", + "", "", "0 ", - "", + "", + "", "", "1 .", - "", + "", + "", "", "2 .", - "", + "", + "", "", "3 .", - "", + "", + "", "", "4 .", - "", + "", + "", "", "5 .", - "", + "", + "", "", "6 .", - "", + "", + "", "", "7 .", - "", + "", + "", "", "8 .", - "", + "", + "", "", "9 ..", - "", + "", + "", "", "0 ..", - "", + "", + "", "", "1 ..", - "", + "", + "", "", "2 ..", - "", + "", + "", "", "3 ..", - "", + "", + "", "", "4 ..", - "", + "", + "", "", "5 ..", - "", + "", + "", "", "6 ..", - "", + "", + "", "", "7 ...", - "", + "", + "", "", "8 ...", - "", + "", + "", "", "9 ...", - "", + "", + "", "", "0 ...", - "", + "", + "", "", "1 ...", - "", + "", + "", "", "2 ...", - "", + "", + "", "", "3 ...", - "", + "", + "", "", "◇ ", @@ -266,10 +338,12 @@ exports[`spinner (isCI = false) > message > sets message for next frame 1`] = ` "│ ", "◒ ", - "", + "", + "", "", "◐ foo", - "", + "", + "", "", "◇ ", @@ -354,22 +428,42 @@ exports[`spinner (isCI = false) > process exit handling > uses global custom err ] `; +exports[`spinner (isCI = false) > start > handles wrapping 1`] = ` +[ + "", + "│ +", + "◒ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +xxxxxxxxxxxxx", + "", + "", + "", + "◇ stopped +", + "", +] +`; + exports[`spinner (isCI = false) > start > renders frames at interval 1`] = ` [ "", "│ ", "◒ ", - "", + "", + "", "", "◐ ", - "", + "", + "", "", "◓ ", - "", + "", + "", "", "◑ ", - "", + "", + "", "", "◇ ", @@ -383,7 +477,8 @@ exports[`spinner (isCI = false) > start > renders message 1`] = ` "│ ", "◒ foo", - "", + "", + "", "", "◇ ", @@ -397,7 +492,8 @@ exports[`spinner (isCI = false) > start > renders timer when indicator is "timer "│ ", "◒ [0s]", - "", + "", + "", "", "◇ [0s] ", @@ -411,7 +507,8 @@ exports[`spinner (isCI = false) > stop > renders cancel symbol if code = 1 1`] = "│ ", "◒ ", - "", + "", + "", "", "■ ", @@ -425,7 +522,8 @@ exports[`spinner (isCI = false) > stop > renders error symbol if code > 1 1`] = "│ ", "◒ ", - "", + "", + "", "", "▲ ", @@ -439,7 +537,8 @@ exports[`spinner (isCI = false) > stop > renders message 1`] = ` "│ ", "◒ ", - "", + "", + "", "", "◇ foo ", @@ -453,7 +552,8 @@ exports[`spinner (isCI = false) > stop > renders message without removing dots 1 "│ ", "◒ ", - "", + "", + "", "", "◇ foo. ", @@ -467,7 +567,8 @@ exports[`spinner (isCI = false) > stop > renders submit symbol and stops spinner "│ ", "◒ ", - "", + "", + "", "", "◇ ", @@ -494,7 +595,8 @@ exports[`spinner (isCI = true) > indicator customization > custom delay 1`] = ` "◒ ...", " ", - "", + "", + "", "", "◇ ", @@ -510,7 +612,8 @@ exports[`spinner (isCI = true) > indicator customization > custom frames 1`] = ` "🐴 ...", " ", - "", + "", + "", "", "◇ ", @@ -526,7 +629,8 @@ exports[`spinner (isCI = true) > indicator customization > custom frames with lo "0 ...", " ", - "", + "", + "", "", "◇ ", @@ -542,12 +646,14 @@ exports[`spinner (isCI = true) > message > sets message for next frame 1`] = ` "◒ ...", " ", - "", + "", + "", "", "◐ foo...", " ", - "", + "", + "", "", "◇ ", @@ -632,6 +738,24 @@ exports[`spinner (isCI = true) > process exit handling > uses global custom erro ] `; +exports[`spinner (isCI = true) > start > handles wrapping 1`] = ` +[ + "", + "│ +", + "◒ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +xxxxxxxxxxxxx...", + " +", + "", + "", + "", + "◇ stopped +", + "", +] +`; + exports[`spinner (isCI = true) > start > renders frames at interval 1`] = ` [ "", @@ -640,7 +764,8 @@ exports[`spinner (isCI = true) > start > renders frames at interval 1`] = ` "◒ ...", " ", - "", + "", + "", "", "◇ ", @@ -656,7 +781,8 @@ exports[`spinner (isCI = true) > start > renders message 1`] = ` "◒ foo...", " ", - "", + "", + "", "", "◇ ", @@ -672,7 +798,8 @@ exports[`spinner (isCI = true) > start > renders timer when indicator is "timer" "◒ ...", " ", - "", + "", + "", "", "◇ [0s] ", @@ -688,7 +815,8 @@ exports[`spinner (isCI = true) > stop > renders cancel symbol if code = 1 1`] = "◒ ...", " ", - "", + "", + "", "", "■ ", @@ -704,7 +832,8 @@ exports[`spinner (isCI = true) > stop > renders error symbol if code > 1 1`] = ` "◒ ...", " ", - "", + "", + "", "", "▲ ", @@ -720,7 +849,8 @@ exports[`spinner (isCI = true) > stop > renders message 1`] = ` "◒ ...", " ", - "", + "", + "", "", "◇ foo ", @@ -736,7 +866,8 @@ exports[`spinner (isCI = true) > stop > renders message without removing dots 1` "◒ ...", " ", - "", + "", + "", "", "◇ foo. ", @@ -752,7 +883,8 @@ exports[`spinner (isCI = true) > stop > renders submit symbol and stops spinner "◒ ...", " ", - "", + "", + "", "", "◇ ", diff --git a/packages/prompts/test/spinner.test.ts b/packages/prompts/test/spinner.test.ts index 21c02ce5..346aaee5 100644 --- a/packages/prompts/test/spinner.test.ts +++ b/packages/prompts/test/spinner.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:stream'; +import { getColumns } from '@clack/core'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; import * as prompts from '../src/index.js'; import { MockWritable } from './test-utils.js'; @@ -73,6 +74,19 @@ describe.each(['true', 'false'])('spinner (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('handles wrapping', () => { + const columns = getColumns(output); + const result = prompts.spinner({ output }); + + result.start('x'.repeat(columns + 10)); + + vi.advanceTimersByTime(80); + + result.stop('stopped'); + + expect(output.buffer).toMatchSnapshot(); + }); }); describe('stop', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf785376..34235281 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: vitest-ansi-serializer: specifier: ^0.1.2 version: 0.1.2(vitest@3.2.4(@types/node@24.1.0)(jiti@2.5.0)) + wrap-ansi: + specifier: ^8.1.0 + version: 8.1.0 packages: