Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shy-wolves-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": patch
---

Fix `spinner`, `progress` and `task` not wrapping or prefixing newlines in their messages.
15 changes: 6 additions & 9 deletions packages/prompts/src/spinner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { styleText } from 'node:util';
import { block, getColumns, settings } from '@clack/core';
import { block, getColumns, settings, wrapTextWithPrefix } from '@clack/core';
import { wrapAnsi } from 'fast-wrap-ansi';
import { cursor, erase } from 'sisteransi';
import {
Expand Down Expand Up @@ -127,15 +127,16 @@ export const spinner = ({
return min > 0 ? `[${min}m ${secs}s]` : `[${secs}s]`;
};

const prefix = `${styleText('gray', S_BAR)} `;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

head up that wrapTextWithPrefix does column - prefix.length and this prefix has ansi codes in it, so it's ~13 chars instead of 3 visible cols. messages wrap ~ 10 columns early. not this PR's fault necessarily but passing a styled prefix here surfaces it, might be worth stripping ansi (or using visible width) in core as follow up.

const hasGuide = opts.withGuide ?? settings.withGuide;

const start = (msg = ''): void => {
isSpinnerActive = true;
unblock = block({ output });
_message = removeTrailingDots(msg);
_message = wrapTextWithPrefix(output, removeTrailingDots(msg), hasGuide ? prefix : '', '');
_origin = performance.now();
if (hasGuide) {
output.write(`${styleText('gray', S_BAR)}\n`);
output.write(`${prefix}\n`);

@dreyfus92 dreyfus92 Jul 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we keep this as just the bar? the 2 trailing spaces from prefix are why basically every snapshot in both files changed. writing trailing whitespace doesn't buy anything and would shrink the diff a ton.

}
let frameIndex = 0;
let indicatorTimer = 0;
Expand All @@ -159,11 +160,7 @@ export const spinner = ({
outputMessage = `${frame} ${_message}${loadingDots}`;
}

const wrapped = wrapAnsi(outputMessage, columns, {
hard: true,
trim: false,
});
output.write(wrapped);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pre-existing rather than introduced here, but this diff changes its failure mode: clearPrevMessage counts erase lines from _prevMessage alone, but what's on screen is ${frame} ${_message}${loadingDots} which is 3 cols wider. with withGuide: false and a near-full-width message, the output soft-wraps one row more than counted and a stale line survives the erase. repro (fake 40-col output, start('x'.repeat(40))):

main:        ""                                        <- stale stub
             "o  done"
this branch: "•  xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"   <- stale full line
             "o  done"

so same bug, but without the write-time wrap the leftover is now a full line of content instead of a 3-char stub. proper fix is probably counting from the actually-written string (needs its own var, the isCI dedup compares _message === _prevMessage and the frame changes every tick). fine as a follow-up, just wanted it on record

output.write(outputMessage);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like message() slipped thru, it still sets _message raw in line 203 but the per frame wrap that used to catch it is gone now. so anything long/multiline set mid-spin renders unwrapped until stop. should probable mirror what start() does on line 136 and also a test calling .message() with a columns + 10 string would cover the gap.


frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0;
// indicator increase by 1 every 8 frames
Expand All @@ -182,7 +179,7 @@ export const spinner = ({
: code === 1
? styleText('red', S_STEP_CANCEL)
: styleText('red', S_STEP_ERROR);
_message = msg ?? _message;
_message = wrapTextWithPrefix(output, msg ?? _message, hasGuide ? prefix : '', '');
if (!silent) {
if (indicator === 'timer') {
output.write(`${step} ${_message} ${formatTimer(_origin)}\n`);
Expand Down
Loading
Loading