feat(terminal): enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes

This commit is contained in:
2026-05-13 18:33:06 +00:00
parent c07b2969b8
commit 502cca375f
6 changed files with 507 additions and 124 deletions
+2
View File
@@ -3,7 +3,9 @@ export { SmartcliTerminal, SmartcliTerminalTask } from './smartcli.classes.termi
export type {
ISmartcliTerminalAttachErrorOptions,
ISmartcliTerminalOptions,
ISmartcliTerminalTaskRunOptions,
ISmartcliTerminalTaskOptions,
ISmartcliWritable,
TSmartcliTerminalSymbolMode,
TSmartcliTerminalTaskStatus,
} from './smartcli.classes.terminal.js';
+292 -40
View File
@@ -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;