export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed'; export interface ISmartcliWritable { isTTY?: boolean; columns?: number; write(chunk: string): void | boolean; } export interface ISmartcliTerminalOptions { stream?: ISmartcliWritable; interactive?: boolean; colors?: boolean; } export interface ISmartcliTerminalTaskOptions { job: string; rows?: number; logLimit?: number; } export interface ISmartcliTerminalAttachErrorOptions { keepOpen?: boolean; } const ansiCodes = { reset: '\u001B[0m', red: '\u001B[31m', green: '\u001B[32m', cyan: '\u001B[36m', gray: '\u001B[90m', }; /** * 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 tasks: SmartcliTerminalTask[] = []; private renderedLineCount = 0; constructor(optionsArg: ISmartcliTerminalOptions = {}) { this.stream = optionsArg.stream || getDefaultStream(); this.interactive = getInteractiveState(this.stream, optionsArg.interactive); this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR')); } public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask { const task = new SmartcliTerminalTask(this, optionsArg); this.tasks.push(task); if (this.interactive) { this.render(); } else { this.writePermanentLine(`[start] ${task.job}`); } return task; } public createProcess(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask { return this.createTask(optionsArg); } 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.writePermanentLine(`[${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]); } /** @internal */ public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void { const summary = `[err] ${taskArg.job}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`; const detailLines = errorLinesArg.slice(1).map((lineArg) => ` ${lineArg}`); this.finalizeTask(taskArg, [summary, ...detailLines]); } public clear(): void { if (this.interactive) { this.clearRenderedBlock(); } this.tasks = []; } private finalizeTask(taskArg: SmartcliTerminalTask, linesArg: string[]): void { this.tasks = this.tasks.filter((task) => task !== taskArg); if (this.interactive) { this.clearRenderedBlock(); this.writePermanentLines(linesArg); this.render(); } else { this.writePermanentLines(linesArg); } } private render(): void { if (!this.interactive) { return; } 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; }); }); if (this.renderedLineCount > 0) { this.stream.write(`\u001B[${this.renderedLineCount}F`); } const lineCount = Math.max(this.renderedLineCount, lines.length); for (let index = 0; index < lineCount; index++) { this.stream.write('\u001B[2K\r'); if (index < lines.length) { this.stream.write(lines[index]); } this.stream.write('\n'); } this.renderedLineCount = lines.length; } 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; } private writePermanentLines(linesArg: string[]): void { for (const line of linesArg) { this.writePermanentLine(line); } } private writePermanentLine(lineArg: string): void { const line = this.colors ? this.colorizeStatusLabel(lineArg) : lineArg; this.stream.write(`${line}\n`); } private colorizeStatusLabel(lineArg: string): string { if (!this.colors) { return lineArg; } if (lineArg.startsWith('[ok]')) { return `${ansiCodes.green}[ok]${ansiCodes.reset}${lineArg.slice(4)}`; } if (lineArg.startsWith('[err]')) { return `${ansiCodes.red}[err]${ansiCodes.reset}${lineArg.slice(5)}`; } if (lineArg.startsWith('[run]')) { return `${ansiCodes.cyan}[run]${ansiCodes.reset}${lineArg.slice(5)}`; } if (lineArg.startsWith('[start]')) { return `${ansiCodes.gray}[start]${ansiCodes.reset}${lineArg.slice(7)}`; } return lineArg; } private getLineWidth(): number { return Math.max(20, (this.stream.columns || 80) - 1); } } export class SmartcliTerminalTask { public readonly job: string; public readonly rows: number; public status: TSmartcliTerminalTaskStatus = 'running'; private terminal: SmartcliTerminal; private logLimit: number; private logLines: string[] = []; private errorLines: string[] = []; 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 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 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 statusLabel = this.status === 'failed' ? '[err]' : '[run]'; const lines: string[] = []; const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines; if (this.rows === 1) { const lastDetail = detailLines[detailLines.length - 1]; lines.push(`${statusLabel} ${this.job}${lastDetail ? ` - ${lastDetail}` : ''}`); return lines; } lines.push(`${statusLabel} ${this.job}`); 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)); } } function getDefaultStream(): ISmartcliWritable { const globalObject: any = globalThis as any; if (globalObject.process?.stdout?.write) { return globalObject.process.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 hasEnvFlag(nameArg: string): boolean { const value = getEnvValue(nameArg); return Boolean(value && value !== '0' && value.toLowerCase() !== 'false'); } function getEnvValue(nameArg: string): string | undefined { const globalObject: any = globalThis as any; return globalObject.process?.env?.[nameArg]; } 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 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)}...`; }