From e53d32c80645211e8b7060eb11a0bbcced2813a6 Mon Sep 17 00:00:00 2001 From: Qingyu Wang Date: Thu, 9 Apr 2026 01:32:58 +0800 Subject: [PATCH 1/2] fix(@clack/prompts): handle carriage return output in taskLog --- .changeset/lovely-radios-joke.md | 5 + packages/prompts/src/task-log.ts | 37 +++++- .../test/__snapshots__/task-log.test.ts.snap | 122 ++++++++++++++++++ packages/prompts/test/task-log.test.ts | 47 +++++++ 4 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 .changeset/lovely-radios-joke.md diff --git a/.changeset/lovely-radios-joke.md b/.changeset/lovely-radios-joke.md new file mode 100644 index 00000000..78f482eb --- /dev/null +++ b/.changeset/lovely-radios-joke.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": patch +--- + +Fix `taskLog` raw message handling so carriage-return spinner updates do not accumulate repeated frames. diff --git a/packages/prompts/src/task-log.ts b/packages/prompts/src/task-log.ts index e69f2206..9cfd295e 100644 --- a/packages/prompts/src/task-log.ts +++ b/packages/prompts/src/task-log.ts @@ -41,6 +41,24 @@ const stripDestructiveANSI = (input: string): string => { return input.replace(/\x1b\[(?:\d+;)*\d*[ABCDEFGHfJKSTsu]|\x1b\[(s|u)/g, ''); }; +const replaceLastLine = (input: string, replacement: string): string => { + const lastNewline = input.lastIndexOf('\n'); + if (lastNewline === -1) { + return replacement; + } + return `${input.slice(0, lastNewline + 1)}${replacement}`; +}; + +const appendRawMessage = (input: string, msg: string, prependNewline: boolean): string => { + let next = prependNewline ? `${input}\n` : input; + const [first, ...overwrites] = msg.split('\r'); + next += first; + for (const overwrite of overwrites) { + next = replaceLastLine(next, overwrite); + } + return next; +}; + /** * Renders a log which clears on success and remains on failure */ @@ -139,11 +157,22 @@ export const taskLog = (opts: TaskLogOptions) => { }; const message = (buffer: BufferEntry, msg: string, mopts?: TaskLogMessageOptions) => { clear(false); - if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') { - buffer.value += '\n'; + const sanitized = stripDestructiveANSI(msg); + if (mopts?.raw === true) { + const rawMessage = sanitized.replace(/\n+$/g, ''); + buffer.value = appendRawMessage( + buffer.value, + rawMessage, + buffer.value !== '' && !lastMessageWasRaw && !rawMessage.startsWith('\n') && !rawMessage.startsWith('\r') + ); + lastMessageWasRaw = !sanitized.endsWith('\n'); + } else { + if (buffer.value !== '') { + buffer.value += '\n'; + } + buffer.value += sanitized; + lastMessageWasRaw = false; } - buffer.value += stripDestructiveANSI(msg); - lastMessageWasRaw = mopts?.raw === true; if (opts.limit !== undefined) { const lines = buffer.value.split('\n'); const linesToRemove = lines.length - opts.limit; diff --git a/packages/prompts/test/__snapshots__/task-log.test.ts.snap b/packages/prompts/test/__snapshots__/task-log.test.ts.snap index 39242084..240af355 100644 --- a/packages/prompts/test/__snapshots__/task-log.test.ts.snap +++ b/packages/prompts/test/__snapshots__/task-log.test.ts.snap @@ -1,5 +1,28 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`taskLog (isCI = false) > error > clears carriage return spinner line when showLog = false 1`] = ` +[ + "│ +", + "◇ foo +", + "│ +", + "│ ◒ Cloning repository +", + "", + "│ ◐ Cloning repository +", + "", + "│ ◓ Cloning repository +", + "", + "│ +■ some error! +", +] +`; + exports[`taskLog (isCI = false) > error > clears output if showLog = false 1`] = ` [ "│ @@ -21,6 +44,32 @@ exports[`taskLog (isCI = false) > error > clears output if showLog = false 1`] = ] `; +exports[`taskLog (isCI = false) > error > renders latest carriage return spinner line with error 1`] = ` +[ + "│ +", + "◇ foo +", + "│ +", + "│ ◒ Cloning repository +", + "", + "│ ◐ Cloning repository +", + "", + "│ ◓ Cloning repository +", + "", + "│ +■ some error! +", + "│ +│ ◓ Cloning repository +", +] +`; + exports[`taskLog (isCI = false) > error > renders output with message 1`] = ` [ "│ @@ -712,6 +761,28 @@ exports[`taskLog (isCI = false) > message > raw = true appends message text unti ] `; +exports[`taskLog (isCI = false) > message > raw = true replaces carriage return spinner updates 1`] = ` +[ + "│ +", + "◇ foo +", + "│ +", + "│ ◒ Cloning repository +", + "", + "│ ◐ Cloning repository +", + "", + "│ ◓ Cloning repository +", + "", + "│ ◇ Repository cloned +", +] +`; + exports[`taskLog (isCI = false) > message > raw = true works when mixed with non-raw messages 1`] = ` [ "│ @@ -1164,6 +1235,23 @@ exports[`taskLog (isCI = false) > writes message header 1`] = ` ] `; +exports[`taskLog (isCI = true) > error > clears carriage return spinner line when showLog = false 1`] = ` +[ + "│ +", + "◇ foo +", + "│ +", + "", + "", + "", + "│ +■ some error! +", +] +`; + exports[`taskLog (isCI = true) > error > clears output if showLog = false 1`] = ` [ "│ @@ -1180,6 +1268,26 @@ exports[`taskLog (isCI = true) > error > clears output if showLog = false 1`] = ] `; +exports[`taskLog (isCI = true) > error > renders latest carriage return spinner line with error 1`] = ` +[ + "│ +", + "◇ foo +", + "│ +", + "", + "", + "", + "│ +■ some error! +", + "│ +│ ◓ Cloning repository +", +] +`; + exports[`taskLog (isCI = true) > error > renders output with message 1`] = ` [ "│ @@ -1432,6 +1540,20 @@ exports[`taskLog (isCI = true) > message > raw = true appends message text until ] `; +exports[`taskLog (isCI = true) > message > raw = true replaces carriage return spinner updates 1`] = ` +[ + "│ +", + "◇ foo +", + "│ +", + "", + "", + "", +] +`; + exports[`taskLog (isCI = true) > message > raw = true works when mixed with non-raw messages 1`] = ` [ "│ diff --git a/packages/prompts/test/task-log.test.ts b/packages/prompts/test/task-log.test.ts index dbbc31a6..ba87efee 100644 --- a/packages/prompts/test/task-log.test.ts +++ b/packages/prompts/test/task-log.test.ts @@ -119,6 +119,21 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + test('raw = true replaces carriage return spinner updates', async () => { + const log = prompts.taskLog({ + input, + output, + title: 'foo', + }); + + log.message('◒ Cloning repository', { raw: true }); + log.message('\r◐ Cloning repository', { raw: true }); + log.message('\r◓ Cloning repository', { raw: true }); + log.message('\r◇ Repository cloned\n', { raw: true }); + + expect(output.buffer).toMatchSnapshot(); + }); + test('prints empty lines', async () => { const log = prompts.taskLog({ input, @@ -178,6 +193,38 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('renders latest carriage return spinner line with error', () => { + const log = prompts.taskLog({ + input, + output, + title: 'foo', + }); + + log.message('◒ Cloning repository', { raw: true }); + log.message('\r◐ Cloning repository', { raw: true }); + log.message('\r◓ Cloning repository', { raw: true }); + + log.error('some error!'); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('clears carriage return spinner line when showLog = false', () => { + const log = prompts.taskLog({ + input, + output, + title: 'foo', + }); + + log.message('◒ Cloning repository', { raw: true }); + log.message('\r◐ Cloning repository', { raw: true }); + log.message('\r◓ Cloning repository', { raw: true }); + + log.error('some error!', { showLog: false }); + + expect(output.buffer).toMatchSnapshot(); + }); }); describe('success', () => { From 64f726a934c1aa5ea7553301d857911ca5868c0f Mon Sep 17 00:00:00 2001 From: Qingyu Wang Date: Thu, 9 Apr 2026 01:46:07 +0800 Subject: [PATCH 2/2] fix(@clack/prompts): collapse carriage returns in line-based taskLog output --- packages/prompts/src/task-log.ts | 9 ++++++- packages/prompts/test/task-log.test.ts | 35 ++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/prompts/src/task-log.ts b/packages/prompts/src/task-log.ts index 9cfd295e..e99504b3 100644 --- a/packages/prompts/src/task-log.ts +++ b/packages/prompts/src/task-log.ts @@ -59,6 +59,13 @@ const appendRawMessage = (input: string, msg: string, prependNewline: boolean): return next; }; +const collapseCarriageReturnUpdates = (input: string): string => { + return input + .split('\n') + .map((line) => line.slice(line.lastIndexOf('\r') + 1)) + .join('\n'); +}; + /** * Renders a log which clears on success and remains on failure */ @@ -170,7 +177,7 @@ export const taskLog = (opts: TaskLogOptions) => { if (buffer.value !== '') { buffer.value += '\n'; } - buffer.value += sanitized; + buffer.value += collapseCarriageReturnUpdates(sanitized); lastMessageWasRaw = false; } if (opts.limit !== undefined) { diff --git a/packages/prompts/test/task-log.test.ts b/packages/prompts/test/task-log.test.ts index ba87efee..b117edfb 100644 --- a/packages/prompts/test/task-log.test.ts +++ b/packages/prompts/test/task-log.test.ts @@ -134,6 +134,23 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + test('raw = false keeps only the last carriage return update when success shows the log', async () => { + const log = prompts.taskLog({ + input, + output, + title: 'foo', + }); + + log.message('◒ Cloning repository\r◐ Cloning repository\r◓ Cloning repository\r◇ Repository cloned'); + log.success('done!', { showLog: true }); + + const renderedLog = output.buffer.at(-1); + expect(renderedLog).toContain('◇ Repository cloned'); + expect(renderedLog).not.toContain('◒ Cloning repository'); + expect(renderedLog).not.toContain('◐ Cloning repository'); + expect(renderedLog).not.toContain('◓ Cloning repository'); + }); + test('prints empty lines', async () => { const log = prompts.taskLog({ input, @@ -225,6 +242,24 @@ describe.each(['true', 'false'])('taskLog (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); + + test('raw = false keeps only the last carriage return update on error', () => { + const log = prompts.taskLog({ + input, + output, + title: 'foo', + }); + + log.message('◒ Cloning repository\r◐ Cloning repository\r◓ Cloning repository\r◇ Repository cloned'); + + log.error('some error!'); + + const renderedLog = output.buffer.at(-1); + expect(renderedLog).toContain('◇ Repository cloned'); + expect(renderedLog).not.toContain('◒ Cloning repository'); + expect(renderedLog).not.toContain('◐ Cloning repository'); + expect(renderedLog).not.toContain('◓ Cloning repository'); + }); }); describe('success', () => {