feat(terminal): enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes
This commit is contained in:
+292
-40
@@ -1,4 +1,5 @@
|
||||
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
|
||||
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
|
||||
|
||||
export interface ISmartcliWritable {
|
||||
isTTY?: boolean;
|
||||
@@ -10,6 +11,9 @@ export interface ISmartcliTerminalOptions {
|
||||
stream?: ISmartcliWritable;
|
||||
interactive?: boolean;
|
||||
colors?: boolean;
|
||||
symbols?: TSmartcliTerminalSymbolMode;
|
||||
cleanup?: boolean;
|
||||
nonInteractiveThrottleMs?: number;
|
||||
}
|
||||
|
||||
export interface ISmartcliTerminalTaskOptions {
|
||||
@@ -22,6 +26,16 @@ export interface ISmartcliTerminalAttachErrorOptions {
|
||||
keepOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface ISmartcliTerminalTaskRunOptions {
|
||||
successMessage?: string;
|
||||
errorKeepOpen?: boolean;
|
||||
}
|
||||
|
||||
interface INonInteractiveLogState {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const ansiCodes = {
|
||||
reset: '\u001B[0m',
|
||||
red: '\u001B[31m',
|
||||
@@ -30,6 +44,18 @@ const ansiCodes = {
|
||||
gray: '\u001B[90m',
|
||||
};
|
||||
|
||||
const unicodeSymbols = {
|
||||
running: '●',
|
||||
completed: '✓',
|
||||
failed: '✕',
|
||||
};
|
||||
|
||||
const asciiSymbols = {
|
||||
running: '*',
|
||||
completed: 'OK',
|
||||
failed: 'X',
|
||||
};
|
||||
|
||||
/**
|
||||
* A live terminal renderer for multiple fixed-row tasks.
|
||||
* It automatically falls back to append-only logs in non-interactive environments.
|
||||
@@ -38,13 +64,24 @@ export class SmartcliTerminal {
|
||||
private stream: ISmartcliWritable;
|
||||
private interactive: boolean;
|
||||
private colors: boolean;
|
||||
private useUnicodeSymbols: boolean;
|
||||
private cleanupEnabled: boolean;
|
||||
private nonInteractiveThrottleMs: number;
|
||||
private tasks: SmartcliTerminalTask[] = [];
|
||||
private renderedLineCount = 0;
|
||||
private lastRenderedOutput = '';
|
||||
private cursorHidden = false;
|
||||
private cleanupRegistered = false;
|
||||
private nonInteractiveLogState = new Map<SmartcliTerminalTask, INonInteractiveLogState>();
|
||||
private cleanupHandlers: Array<() => void> = [];
|
||||
|
||||
constructor(optionsArg: ISmartcliTerminalOptions = {}) {
|
||||
this.stream = optionsArg.stream || getDefaultStream();
|
||||
this.interactive = getInteractiveState(this.stream, optionsArg.interactive);
|
||||
this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR'));
|
||||
this.useUnicodeSymbols = getUnicodeSymbolState(this.stream, optionsArg.symbols);
|
||||
this.cleanupEnabled = optionsArg.cleanup ?? true;
|
||||
this.nonInteractiveThrottleMs = Math.max(0, optionsArg.nonInteractiveThrottleMs ?? 250);
|
||||
}
|
||||
|
||||
public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask {
|
||||
@@ -52,9 +89,10 @@ export class SmartcliTerminal {
|
||||
this.tasks.push(task);
|
||||
|
||||
if (this.interactive) {
|
||||
this.ensureInteractiveSession();
|
||||
this.render();
|
||||
} else {
|
||||
this.writePermanentLine(`[start] ${task.job}`);
|
||||
this.writePermanentLine(`start ${task.job}`);
|
||||
}
|
||||
|
||||
return task;
|
||||
@@ -64,6 +102,13 @@ export class SmartcliTerminal {
|
||||
return this.createTask(optionsArg);
|
||||
}
|
||||
|
||||
public task(jobArg: string, optionsArg: Omit<ISmartcliTerminalTaskOptions, 'job'> = {}) {
|
||||
return this.createTask({
|
||||
...optionsArg,
|
||||
job: jobArg,
|
||||
});
|
||||
}
|
||||
|
||||
public isInteractive(): boolean {
|
||||
return this.interactive;
|
||||
}
|
||||
@@ -80,39 +125,91 @@ export class SmartcliTerminal {
|
||||
|
||||
if (this.interactive) {
|
||||
this.render();
|
||||
} else if (messageArg) {
|
||||
this.writePermanentLine(`[${taskArg.job}] ${messageArg}`);
|
||||
} else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
|
||||
this.writePermanentLine(`update ${taskArg.job}: ${messageArg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void {
|
||||
const message = messageArg || taskArg.getLastLogLine();
|
||||
const summary = `[ok] ${taskArg.job}${message ? ` - ${message}` : ''}`;
|
||||
this.finalizeTask(taskArg, [summary]);
|
||||
const summary = this.interactive
|
||||
? `${this.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedText()}${message ? ` - ${message}` : ''}`
|
||||
: `done ${taskArg.job} in ${taskArg.getElapsedText()}${message ? `: ${message}` : ''}`;
|
||||
this.finalizeTask(taskArg, [summary], 'completed');
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void {
|
||||
const summary = `[err] ${taskArg.job}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`;
|
||||
const summary = this.interactive
|
||||
? `${this.getStatusSymbol('failed')} ${taskArg.job} ${taskArg.getElapsedText()}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`
|
||||
: `fail ${taskArg.job} in ${taskArg.getElapsedText()}${errorLinesArg[0] ? `: ${errorLinesArg[0]}` : ''}`;
|
||||
const detailLines = errorLinesArg.slice(1).map((lineArg) => ` ${lineArg}`);
|
||||
this.finalizeTask(taskArg, [summary, ...detailLines]);
|
||||
this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
if (this.interactive) {
|
||||
this.clearRenderedBlock();
|
||||
this.restoreCursor();
|
||||
}
|
||||
this.tasks = [];
|
||||
this.nonInteractiveLogState.clear();
|
||||
}
|
||||
|
||||
private finalizeTask(taskArg: SmartcliTerminalTask, linesArg: string[]): void {
|
||||
/** @internal */
|
||||
public getStatusSymbol(statusArg: TSmartcliTerminalTaskStatus): string {
|
||||
const symbols = this.useUnicodeSymbols ? unicodeSymbols : asciiSymbols;
|
||||
return symbols[statusArg];
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
public colorizeLine(lineArg: string, statusArg?: TSmartcliTerminalTaskStatus): string {
|
||||
if (!this.colors) {
|
||||
return lineArg;
|
||||
}
|
||||
|
||||
if (statusArg === 'completed') {
|
||||
return `${ansiCodes.green}${lineArg}${ansiCodes.reset}`;
|
||||
}
|
||||
if (statusArg === 'failed') {
|
||||
return `${ansiCodes.red}${lineArg}${ansiCodes.reset}`;
|
||||
}
|
||||
if (statusArg === 'running') {
|
||||
return `${ansiCodes.cyan}${lineArg}${ansiCodes.reset}`;
|
||||
}
|
||||
if (lineArg.startsWith(' ')) {
|
||||
return `${ansiCodes.gray}${lineArg}${ansiCodes.reset}`;
|
||||
}
|
||||
return lineArg;
|
||||
}
|
||||
|
||||
private ensureInteractiveSession(): void {
|
||||
if (!this.cursorHidden) {
|
||||
this.stream.write('\u001B[?25l');
|
||||
this.cursorHidden = true;
|
||||
}
|
||||
|
||||
if (this.cleanupEnabled && !this.cleanupRegistered) {
|
||||
this.registerProcessCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private finalizeTask(
|
||||
taskArg: SmartcliTerminalTask,
|
||||
linesArg: string[],
|
||||
statusArg: TSmartcliTerminalTaskStatus
|
||||
): void {
|
||||
this.tasks = this.tasks.filter((task) => task !== taskArg);
|
||||
this.nonInteractiveLogState.delete(taskArg);
|
||||
|
||||
if (this.interactive) {
|
||||
this.clearRenderedBlock();
|
||||
this.writePermanentLines(linesArg);
|
||||
this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
|
||||
this.render();
|
||||
if (this.tasks.length === 0) {
|
||||
this.restoreCursor();
|
||||
}
|
||||
} else {
|
||||
this.writePermanentLines(linesArg);
|
||||
}
|
||||
@@ -124,27 +221,32 @@ export class SmartcliTerminal {
|
||||
}
|
||||
|
||||
const width = this.getLineWidth();
|
||||
const lines = this.tasks.flatMap((taskArg) => {
|
||||
return taskArg.renderPlainRows(width).map((lineArg, indexArg) => {
|
||||
const truncatedLine = truncateLine(lineArg, width);
|
||||
return indexArg === 0 ? this.colorizeStatusLabel(truncatedLine) : truncatedLine;
|
||||
});
|
||||
const lines = this.tasks.flatMap((taskArg) => taskArg.renderPlainRows(width));
|
||||
const coloredLines = lines.map((lineArg) => {
|
||||
const status = lineArg.startsWith(' ') ? undefined : getStatusFromRenderedLine(lineArg, this);
|
||||
return this.colorizeLine(lineArg, status);
|
||||
});
|
||||
const renderedOutput = coloredLines.join('\n');
|
||||
|
||||
if (renderedOutput === this.lastRenderedOutput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.renderedLineCount > 0) {
|
||||
this.stream.write(`\u001B[${this.renderedLineCount}F`);
|
||||
}
|
||||
|
||||
const lineCount = Math.max(this.renderedLineCount, lines.length);
|
||||
const lineCount = Math.max(this.renderedLineCount, coloredLines.length);
|
||||
for (let index = 0; index < lineCount; index++) {
|
||||
this.stream.write('\u001B[2K\r');
|
||||
if (index < lines.length) {
|
||||
this.stream.write(lines[index]);
|
||||
if (index < coloredLines.length) {
|
||||
this.stream.write(coloredLines[index]);
|
||||
}
|
||||
this.stream.write('\n');
|
||||
}
|
||||
|
||||
this.renderedLineCount = lines.length;
|
||||
this.renderedLineCount = coloredLines.length;
|
||||
this.lastRenderedOutput = renderedOutput;
|
||||
}
|
||||
|
||||
private clearRenderedBlock(): void {
|
||||
@@ -155,6 +257,7 @@ export class SmartcliTerminal {
|
||||
this.stream.write(`\u001B[${this.renderedLineCount}F`);
|
||||
this.stream.write(`\u001B[${this.renderedLineCount}M`);
|
||||
this.renderedLineCount = 0;
|
||||
this.lastRenderedOutput = '';
|
||||
}
|
||||
|
||||
private writePermanentLines(linesArg: string[]): void {
|
||||
@@ -164,28 +267,83 @@ export class SmartcliTerminal {
|
||||
}
|
||||
|
||||
private writePermanentLine(lineArg: string): void {
|
||||
const line = this.colors ? this.colorizeStatusLabel(lineArg) : lineArg;
|
||||
this.stream.write(`${line}\n`);
|
||||
this.stream.write(`${lineArg}\n`);
|
||||
}
|
||||
|
||||
private colorizeStatusLabel(lineArg: string): string {
|
||||
if (!this.colors) {
|
||||
return lineArg;
|
||||
private shouldWriteNonInteractiveUpdate(
|
||||
taskArg: SmartcliTerminalTask,
|
||||
messageArg: string
|
||||
): boolean {
|
||||
const now = Date.now();
|
||||
const previousState = this.nonInteractiveLogState.get(taskArg);
|
||||
|
||||
if (previousState?.message === messageArg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lineArg.startsWith('[ok]')) {
|
||||
return `${ansiCodes.green}[ok]${ansiCodes.reset}${lineArg.slice(4)}`;
|
||||
if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) {
|
||||
return false;
|
||||
}
|
||||
if (lineArg.startsWith('[err]')) {
|
||||
return `${ansiCodes.red}[err]${ansiCodes.reset}${lineArg.slice(5)}`;
|
||||
|
||||
this.nonInteractiveLogState.set(taskArg, {
|
||||
message: messageArg,
|
||||
timestamp: now,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private restoreCursor(): void {
|
||||
if (!this.cursorHidden) {
|
||||
return;
|
||||
}
|
||||
if (lineArg.startsWith('[run]')) {
|
||||
return `${ansiCodes.cyan}[run]${ansiCodes.reset}${lineArg.slice(5)}`;
|
||||
this.stream.write('\u001B[?25h');
|
||||
this.cursorHidden = false;
|
||||
this.unregisterProcessCleanup();
|
||||
}
|
||||
|
||||
private registerProcessCleanup(): void {
|
||||
const processObject = getProcessObject();
|
||||
if (!processObject?.once) {
|
||||
return;
|
||||
}
|
||||
if (lineArg.startsWith('[start]')) {
|
||||
return `${ansiCodes.gray}[start]${ansiCodes.reset}${lineArg.slice(7)}`;
|
||||
|
||||
const restoreOnly = () => {
|
||||
this.clearRenderedBlock();
|
||||
if (this.cursorHidden) {
|
||||
this.stream.write('\u001B[?25h');
|
||||
this.cursorHidden = false;
|
||||
}
|
||||
};
|
||||
const exitWithSignal = (codeArg: number) => {
|
||||
restoreOnly();
|
||||
processObject.exit?.(codeArg);
|
||||
};
|
||||
const throwAfterRestore = (errorArg: unknown) => {
|
||||
restoreOnly();
|
||||
throw errorArg;
|
||||
};
|
||||
const sigintHandler = () => exitWithSignal(130);
|
||||
const sigtermHandler = () => exitWithSignal(143);
|
||||
|
||||
processObject.once('exit', restoreOnly);
|
||||
processObject.once('SIGINT', sigintHandler);
|
||||
processObject.once('SIGTERM', sigtermHandler);
|
||||
processObject.once('uncaughtException', throwAfterRestore);
|
||||
this.cleanupHandlers = [
|
||||
() => processObject.off?.('exit', restoreOnly),
|
||||
() => processObject.off?.('SIGINT', sigintHandler),
|
||||
() => processObject.off?.('SIGTERM', sigtermHandler),
|
||||
() => processObject.off?.('uncaughtException', throwAfterRestore),
|
||||
];
|
||||
this.cleanupRegistered = true;
|
||||
}
|
||||
|
||||
private unregisterProcessCleanup(): void {
|
||||
for (const cleanupHandler of this.cleanupHandlers) {
|
||||
cleanupHandler();
|
||||
}
|
||||
return lineArg;
|
||||
this.cleanupHandlers = [];
|
||||
this.cleanupRegistered = false;
|
||||
}
|
||||
|
||||
private getLineWidth(): number {
|
||||
@@ -196,11 +354,14 @@ export class SmartcliTerminal {
|
||||
export class SmartcliTerminalTask {
|
||||
public readonly job: string;
|
||||
public readonly rows: number;
|
||||
public readonly startTime = Date.now();
|
||||
public status: TSmartcliTerminalTaskStatus = 'running';
|
||||
private terminal: SmartcliTerminal;
|
||||
private logLimit: number;
|
||||
private logLines: string[] = [];
|
||||
private errorLines: string[] = [];
|
||||
private progressCurrent?: number;
|
||||
private progressTotal?: number;
|
||||
|
||||
constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) {
|
||||
this.terminal = terminalArg;
|
||||
@@ -228,6 +389,32 @@ export class SmartcliTerminalTask {
|
||||
return this.log(messageArg);
|
||||
}
|
||||
|
||||
public setProgress(currentArg: number, totalArg: number, messageArg?: string): this {
|
||||
if (this.status !== 'running') {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.progressCurrent = Math.max(0, currentArg);
|
||||
this.progressTotal = Math.max(0, totalArg);
|
||||
const progressText = this.getProgressText();
|
||||
this.log(messageArg ? `${messageArg} ${progressText}` : progressText);
|
||||
return this;
|
||||
}
|
||||
|
||||
public async run<T>(
|
||||
operationArg: (taskArg: this) => T | Promise<T>,
|
||||
optionsArg: ISmartcliTerminalTaskRunOptions = {}
|
||||
): Promise<T> {
|
||||
try {
|
||||
const result = await operationArg(this);
|
||||
this.complete(optionsArg.successMessage);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.attachError(error, { keepOpen: optionsArg.errorKeepOpen });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public complete(messageArg?: string): this {
|
||||
if (this.status !== 'running') {
|
||||
return this;
|
||||
@@ -262,6 +449,10 @@ export class SmartcliTerminalTask {
|
||||
return this.attachError(errorArg);
|
||||
}
|
||||
|
||||
public getElapsedText(): string {
|
||||
return formatDuration(Date.now() - this.startTime);
|
||||
}
|
||||
|
||||
public getLastLogLine(): string | undefined {
|
||||
return this.logLines[this.logLines.length - 1];
|
||||
}
|
||||
@@ -276,17 +467,17 @@ export class SmartcliTerminalTask {
|
||||
|
||||
/** @internal */
|
||||
public renderPlainRows(widthArg: number): string[] {
|
||||
const statusLabel = this.status === 'failed' ? '[err]' : '[run]';
|
||||
const lines: string[] = [];
|
||||
const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines;
|
||||
const header = this.getHeaderLine();
|
||||
|
||||
if (this.rows === 1) {
|
||||
const lastDetail = detailLines[detailLines.length - 1];
|
||||
lines.push(`${statusLabel} ${this.job}${lastDetail ? ` - ${lastDetail}` : ''}`);
|
||||
return lines;
|
||||
lines.push(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`);
|
||||
return lines.map((lineArg) => truncateLine(lineArg, widthArg));
|
||||
}
|
||||
|
||||
lines.push(`${statusLabel} ${this.job}`);
|
||||
lines.push(header);
|
||||
const visibleDetailLineCount = this.rows - 1;
|
||||
const visibleDetailLines = detailLines.slice(-visibleDetailLineCount);
|
||||
for (const line of visibleDetailLines) {
|
||||
@@ -299,12 +490,26 @@ export class SmartcliTerminalTask {
|
||||
|
||||
return lines.map((lineArg) => truncateLine(lineArg, widthArg));
|
||||
}
|
||||
|
||||
private getHeaderLine(): string {
|
||||
const progressText = this.progressTotal ? ` ${this.getProgressText()}` : '';
|
||||
return `${this.terminal.getStatusSymbol(this.status)} ${this.job}${progressText} ${this.getElapsedText()}`;
|
||||
}
|
||||
|
||||
private getProgressText(): string {
|
||||
if (!this.progressTotal) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const percent = Math.floor((this.progressCurrent || 0) / this.progressTotal * 100);
|
||||
return `${Math.min(100, percent)}% (${this.progressCurrent}/${this.progressTotal})`;
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultStream(): ISmartcliWritable {
|
||||
const globalObject: any = globalThis as any;
|
||||
if (globalObject.process?.stdout?.write) {
|
||||
return globalObject.process.stdout;
|
||||
const processObject = getProcessObject();
|
||||
if (processObject?.stdout?.write) {
|
||||
return processObject.stdout;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -337,14 +542,53 @@ function getInteractiveState(streamArg: ISmartcliWritable, overrideArg?: boolean
|
||||
);
|
||||
}
|
||||
|
||||
function getUnicodeSymbolState(
|
||||
streamArg: ISmartcliWritable,
|
||||
modeArg: TSmartcliTerminalSymbolMode = 'auto'
|
||||
): boolean {
|
||||
if (modeArg === 'unicode') {
|
||||
return true;
|
||||
}
|
||||
if (modeArg === 'ascii') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const processObject = getProcessObject();
|
||||
if (processObject?.platform === 'win32' && !getEnvValue('WT_SESSION')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return streamArg.isTTY !== false && getEnvValue('TERM') !== 'dumb';
|
||||
}
|
||||
|
||||
function getStatusFromRenderedLine(
|
||||
lineArg: string,
|
||||
terminalArg: SmartcliTerminal
|
||||
): TSmartcliTerminalTaskStatus | undefined {
|
||||
if (lineArg.startsWith(terminalArg.getStatusSymbol('completed'))) {
|
||||
return 'completed';
|
||||
}
|
||||
if (lineArg.startsWith(terminalArg.getStatusSymbol('failed'))) {
|
||||
return 'failed';
|
||||
}
|
||||
if (lineArg.startsWith(terminalArg.getStatusSymbol('running'))) {
|
||||
return 'running';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasEnvFlag(nameArg: string): boolean {
|
||||
const value = getEnvValue(nameArg);
|
||||
return Boolean(value && value !== '0' && value.toLowerCase() !== 'false');
|
||||
}
|
||||
|
||||
function getEnvValue(nameArg: string): string | undefined {
|
||||
return getProcessObject()?.env?.[nameArg];
|
||||
}
|
||||
|
||||
function getProcessObject(): any {
|
||||
const globalObject: any = globalThis as any;
|
||||
return globalObject.process?.env?.[nameArg];
|
||||
return globalObject.process;
|
||||
}
|
||||
|
||||
function normalizeLines(messageArg: string): string[] {
|
||||
@@ -371,6 +615,14 @@ function formatError(errorArg: unknown): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(millisecondsArg: number): string {
|
||||
if (millisecondsArg < 1000) {
|
||||
return `${millisecondsArg}ms`;
|
||||
}
|
||||
|
||||
return `${(millisecondsArg / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function truncateLine(lineArg: string, widthArg: number): string {
|
||||
if (lineArg.length <= widthArg) {
|
||||
return lineArg;
|
||||
|
||||
Reference in New Issue
Block a user