feat(terminal): add live terminal task rendering with interactive and non-interactive output modes
This commit is contained in:
@@ -1 +1,9 @@
|
||||
export { Smartcli } from './smartcli.classes.smartcli.js';
|
||||
export { SmartcliTerminal, SmartcliTerminalTask } from './smartcli.classes.terminal.js';
|
||||
export type {
|
||||
ISmartcliTerminalAttachErrorOptions,
|
||||
ISmartcliTerminalOptions,
|
||||
ISmartcliTerminalTaskOptions,
|
||||
ISmartcliWritable,
|
||||
TSmartcliTerminalTaskStatus,
|
||||
} from './smartcli.classes.terminal.js';
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
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)}...`;
|
||||
}
|
||||
Reference in New Issue
Block a user