Skip to content

feat: tasklog groups #347

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Jul 11, 2025
5 changes: 5 additions & 0 deletions .changeset/sharp-lemons-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clack/prompts": minor
---

Using the `group` method, task logs can now have groups which themselves can have scrolling windows of logs.
3 changes: 3 additions & 0 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import color from 'picocolors';

export const unicode = isUnicodeSupported();
export const isCI = (): boolean => process.env.CI === 'true';
export const isTTY = (output: Writable): boolean => {
return (output as Writable & { isTTY?: boolean }).isTTY === true;
};
export const unicodeOr = (c: string, fallback: string) => (unicode ? c : fallback);
export const S_STEP_ACTIVE = unicodeOr('◆', '*');
export const S_STEP_CANCEL = unicodeOr('■', 'x');
Expand Down
188 changes: 149 additions & 39 deletions packages/prompts/src/task-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import type { Writable } from 'node:stream';
import { getColumns } from '@clack/core';
import color from 'picocolors';
import { erase } from 'sisteransi';
import { type CommonOptions, S_BAR, S_STEP_SUBMIT, isCI as isCIFn } from './common.js';
import {
type CommonOptions,
S_BAR,
S_STEP_SUBMIT,
isCI as isCIFn,
isTTY as isTTYFn,
} from './common.js';
import { log } from './log.js';

export interface TaskLogOptions extends CommonOptions {
Expand All @@ -20,6 +26,16 @@ export interface TaskLogCompletionOptions {
showLog?: boolean;
}

interface BufferEntry {
header?: string;
value: string;
full: string;
result?: {
status: 'success' | 'error';
message: string;
};
}

/**
* Renders a log which clears on success and remains on failure
*/
Expand All @@ -30,69 +46,159 @@ export const taskLog = (opts: TaskLogOptions) => {
const spacing = opts.spacing ?? 1;
const barSize = 3;
const retainLog = opts.retainLog === true;
const isCI = isCIFn();
const isTTY = !isCIFn() && isTTYFn(output);

output.write(`${secondarySymbol}\n`);
output.write(`${color.green(S_STEP_SUBMIT)} ${opts.title}\n`);
for (let i = 0; i < spacing; i++) {
output.write(`${secondarySymbol}\n`);
}

let buffer = '';
let fullBuffer = '';
const buffers: BufferEntry[] = [
{
value: '',
full: '',
},
];
let lastMessageWasRaw = false;

const clear = (clearTitle: boolean): void => {
if (buffer.length === 0) {
if (buffers.length === 0) {
return;
}
const bufferHeight = buffer.split('\n').reduce((count, line) => {
if (line === '') {
return count + 1;

let lines = 0;

if (clearTitle) {
lines += spacing + 2;
}

for (const buffer of buffers) {
const { value, result } = buffer;
let text = result?.message ?? value;

if (text.length === 0) {
continue;
}
return count + Math.ceil((line.length + barSize) / columns);
}, 0);
const lines = bufferHeight + 1 + (clearTitle ? spacing + 2 : 0);
output.write(erase.lines(lines));

if (result === undefined && buffer.header !== undefined && buffer.header !== '') {
text += `\n${buffer.header}`;
}

const bufferHeight = text.split('\n').reduce((count, line) => {
if (line === '') {
return count + 1;
}
return count + Math.ceil((line.length + barSize) / columns);
}, 0);

lines += bufferHeight;
}

if (lines > 0) {
lines += 1;
output.write(erase.lines(lines));
}
};
const printBuffer = (buf: string, messageSpacing?: number): void => {
log.message(buf.split('\n').map(color.dim), {
const printBuffer = (buffer: BufferEntry, messageSpacing?: number, full?: boolean): void => {
const messages = full ? `${buffer.full}\n${buffer.value}` : buffer.value;
if (buffer.header !== undefined && buffer.header !== '') {
log.message(buffer.header.split('\n').map(color.bold), {
output,
secondarySymbol,
symbol: secondarySymbol,
spacing: 0,
});
}
log.message(messages.split('\n').map(color.dim), {
output,
secondarySymbol,
symbol: secondarySymbol,
spacing: messageSpacing ?? spacing,
});
};
const renderBuffer = (): void => {
if (retainLog === true && fullBuffer.length > 0) {
printBuffer(`${fullBuffer}\n${buffer}`);
} else {
printBuffer(buffer);
for (const buffer of buffers) {
const { header, value, full } = buffer;
if ((header === undefined || header.length === 0) && value.length === 0) {
continue;
}
printBuffer(buffer, undefined, retainLog === true && full.length > 0);
}
};

return {
message(msg: string, mopts?: TaskLogMessageOptions) {
clear(false);
if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer !== '') {
buffer += '\n';
}
buffer += msg;
lastMessageWasRaw = mopts?.raw === true;
if (opts.limit !== undefined) {
const lines = buffer.split('\n');
const linesToRemove = lines.length - opts.limit;
if (linesToRemove > 0) {
const removedLines = lines.splice(0, linesToRemove);
if (retainLog) {
fullBuffer += (fullBuffer === '' ? '' : '\n') + removedLines.join('\n');
}
const message = (buffer: BufferEntry, msg: string, mopts?: TaskLogMessageOptions) => {
clear(false);
if ((mopts?.raw !== true || !lastMessageWasRaw) && buffer.value !== '') {
buffer.value += '\n';
}
buffer.value += msg;
lastMessageWasRaw = mopts?.raw === true;
if (opts.limit !== undefined) {
const lines = buffer.value.split('\n');
const linesToRemove = lines.length - opts.limit;
if (linesToRemove > 0) {
const removedLines = lines.splice(0, linesToRemove);
if (retainLog) {
buffer.full += (buffer.full === '' ? '' : '\n') + removedLines.join('\n');
}
buffer = lines.join('\n');
}
if (!isCI) {
buffer.value = lines.join('\n');
}
if (isTTY) {
printBuffers();
}
};
const printBuffers = (): void => {
for (const buffer of buffers) {
if (buffer.result) {
if (buffer.result.status === 'error') {
log.error(buffer.result.message, { output, secondarySymbol, spacing: 0 });
} else {
log.success(buffer.result.message, { output, secondarySymbol, spacing: 0 });
}
} else if (buffer.value !== '') {
printBuffer(buffer, 0);
}
}
};
const completeBuffer = (buffer: BufferEntry, result: BufferEntry['result']): void => {
clear(false);

buffer.result = result;

if (isTTY) {
printBuffers();
}
};

return {
message(msg: string, mopts?: TaskLogMessageOptions) {
message(buffers[0], msg, mopts);
},
group(name: string) {
const buffer: BufferEntry = {
header: name,
value: '',
full: '',
};
buffers.push(buffer);
return {
message(msg: string, mopts?: TaskLogMessageOptions) {
message(buffer, msg, mopts);
},
error(message: string) {
completeBuffer(buffer, {
status: 'error',
message,
});
},
success(message: string) {
completeBuffer(buffer, {
status: 'success',
message,
});
},
};
},
error(message: string, opts?: TaskLogCompletionOptions): void {
clear(true);
Expand All @@ -101,7 +207,9 @@ export const taskLog = (opts: TaskLogOptions) => {
renderBuffer();
}
// clear buffer since error is an end state
buffer = fullBuffer = '';
buffers.splice(1, buffers.length - 1);
buffers[0].value = '';
buffers[0].full = '';
},
success(message: string, opts?: TaskLogCompletionOptions): void {
clear(true);
Expand All @@ -110,7 +218,9 @@ export const taskLog = (opts: TaskLogOptions) => {
renderBuffer();
}
// clear buffer since success is an end state
buffer = fullBuffer = '';
buffers.splice(1, buffers.length - 1);
buffers[0].value = '';
buffers[0].full = '';
},
};
};
Loading