export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed'; export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii'; export interface ISmartcliWritable { isTTY?: boolean; columns?: number; write(chunk: string): void | boolean; } export interface ISmartcliTerminalOptions { stream?: ISmartcliWritable; interactive?: boolean; colors?: boolean; symbols?: TSmartcliTerminalSymbolMode; cleanup?: boolean; nonInteractiveThrottleMs?: number; } export interface ISmartcliTerminalTaskOptions { job: string; rows?: number; logLimit?: number; } 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', green: '\u001B[32m', cyan: '\u001B[36m', 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. */ 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(); 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 { const task = new SmartcliTerminalTask(this, optionsArg); this.tasks.push(task); if (this.interactive) { this.ensureInteractiveSession(); this.render(); } else { this.writePermanentLine(`start ${task.job}`); } return task; } public createProcess(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask { return this.createTask(optionsArg); } public task(jobArg: string, optionsArg: Omit = {}) { return this.createTask({ ...optionsArg, job: jobArg, }); } public isInteractive(): boolean { return this.interactive; } public getTasks(): SmartcliTerminalTask[] { return [...this.tasks]; } /** @internal */ public updateTask(taskArg: SmartcliTerminalTask, messageArg?: string): void { if (!this.tasks.includes(taskArg)) { return; } if (this.interactive) { this.render(); } 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 = 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 = 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], 'failed'); } public clear(): void { if (this.interactive) { this.clearRenderedBlock(); this.restoreCursor(); } this.tasks = []; this.nonInteractiveLogState.clear(); } /** @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.map((lineArg) => this.colorizeLine(lineArg, statusArg))); this.render(); if (this.tasks.length === 0) { this.restoreCursor(); } } else { this.writePermanentLines(linesArg); } } private render(): void { if (!this.interactive) { return; } const width = this.getLineWidth(); 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, coloredLines.length); for (let index = 0; index < lineCount; index++) { this.stream.write('\u001B[2K\r'); if (index < coloredLines.length) { this.stream.write(coloredLines[index]); } this.stream.write('\n'); } this.renderedLineCount = coloredLines.length; this.lastRenderedOutput = renderedOutput; } private clearRenderedBlock(): void { if (this.renderedLineCount === 0) { return; } this.stream.write(`\u001B[${this.renderedLineCount}F`); this.stream.write(`\u001B[${this.renderedLineCount}M`); this.renderedLineCount = 0; this.lastRenderedOutput = ''; } private writePermanentLines(linesArg: string[]): void { for (const line of linesArg) { this.writePermanentLine(line); } } private writePermanentLine(lineArg: string): void { this.stream.write(`${lineArg}\n`); } private shouldWriteNonInteractiveUpdate( taskArg: SmartcliTerminalTask, messageArg: string ): boolean { const now = Date.now(); const previousState = this.nonInteractiveLogState.get(taskArg); if (previousState?.message === messageArg) { return false; } if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) { return false; } this.nonInteractiveLogState.set(taskArg, { message: messageArg, timestamp: now, }); return true; } private restoreCursor(): void { if (!this.cursorHidden) { return; } this.stream.write('\u001B[?25h'); this.cursorHidden = false; this.unregisterProcessCleanup(); } private registerProcessCleanup(): void { const processObject = getProcessObject(); if (!processObject?.once) { return; } 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(); } this.cleanupHandlers = []; this.cleanupRegistered = false; } private getLineWidth(): number { return Math.max(20, (this.stream.columns || 80) - 1); } } 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; this.job = optionsArg.job; this.rows = Math.max(1, Math.floor(optionsArg.rows || 3)); this.logLimit = Math.max(this.rows, Math.floor(optionsArg.logLimit || 100)); } public log(messageArg: string): this { if (this.status !== 'running') { return this; } const newLines = normalizeLines(messageArg); this.logLines.push(...newLines); if (this.logLines.length > this.logLimit) { this.logLines.splice(0, this.logLines.length - this.logLimit); } this.terminal.updateTask(this, newLines.join('\n')); return this; } public update(messageArg: string): this { 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( operationArg: (taskArg: this) => T | Promise, optionsArg: ISmartcliTerminalTaskRunOptions = {} ): Promise { 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; } this.status = 'completed'; this.terminal.completeTask(this, messageArg); return this; } public attachError( errorArg: unknown, optionsArg: ISmartcliTerminalAttachErrorOptions = {} ): this { if (this.status !== 'running') { return this; } this.status = 'failed'; this.errorLines = formatError(errorArg); if (optionsArg.keepOpen) { this.terminal.updateTask(this, this.errorLines.join('\n')); } else { this.terminal.failTask(this, this.errorLines); } return this; } public fail(errorArg: unknown): this { return this.attachError(errorArg); } public getElapsedText(): string { return formatDuration(Date.now() - this.startTime); } public getLastLogLine(): string | undefined { return this.logLines[this.logLines.length - 1]; } public getLogLines(): string[] { return [...this.logLines]; } public getErrorLines(): string[] { return [...this.errorLines]; } /** @internal */ public renderPlainRows(widthArg: number): string[] { 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(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`); return lines.map((lineArg) => truncateLine(lineArg, widthArg)); } lines.push(header); const visibleDetailLineCount = this.rows - 1; const visibleDetailLines = detailLines.slice(-visibleDetailLineCount); for (const line of visibleDetailLines) { lines.push(` ${line}`); } while (lines.length < this.rows) { lines.push(''); } 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 processObject = getProcessObject(); if (processObject?.stdout?.write) { return processObject.stdout; } return { isTTY: false, write: (chunkArg: string) => { if (typeof console !== 'undefined') { console.log(chunkArg.replace(/\n$/, '')); } }, }; } function getInteractiveState(streamArg: ISmartcliWritable, overrideArg?: boolean): boolean { if (typeof overrideArg === 'boolean') { return overrideArg; } if (!streamArg.isTTY) { return false; } return !( hasEnvFlag('CI') || hasEnvFlag('GITHUB_ACTIONS') || hasEnvFlag('JENKINS_URL') || hasEnvFlag('GITLAB_CI') || hasEnvFlag('TRAVIS') || hasEnvFlag('CIRCLECI') || getEnvValue('TERM') === 'dumb' ); } 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; } function normalizeLines(messageArg: string): string[] { return String(messageArg) .split(/\r?\n/) .map((lineArg) => lineArg.trimEnd()) .filter((lineArg) => lineArg.length > 0); } function formatError(errorArg: unknown): string[] { if (errorArg instanceof Error) { return normalizeLines(errorArg.stack || errorArg.message || errorArg.name); } if (typeof errorArg === 'string') { return normalizeLines(errorArg); } try { const jsonString = JSON.stringify(errorArg); return normalizeLines(jsonString === undefined ? String(errorArg) : jsonString); } catch { return [String(errorArg)]; } } 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; } if (widthArg <= 3) { return lineArg.slice(0, widthArg); } return `${lineArg.slice(0, widthArg - 3)}...`; }