Skip to content
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