feat(terminal): add optional live timers and spinners to terminal tasks

This commit is contained in:
2026-05-13 20:18:52 +00:00
parent e2eb4eb040
commit 1ae31e36bc
8 changed files with 228 additions and 15 deletions
+137 -12
View File
@@ -1,3 +1,5 @@
import * as plugins from './smartcli.plugins.js';
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
@@ -20,6 +22,12 @@ export interface ISmartcliTerminalTaskOptions {
job: string;
rows?: number;
logLimit?: number;
showTimer?: boolean;
showSpinner?: boolean;
timer?: boolean;
spinner?: boolean;
spinnerFrames?: string[];
spinnerIntervalMs?: number;
}
export interface ISmartcliTerminalAttachErrorOptions {
@@ -56,6 +64,9 @@ const asciiSymbols = {
failed: 'X',
};
const unicodeSpinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const asciiSpinnerFrames = ['-', '\\', '|', '/'];
/**
* A live terminal renderer for multiple fixed-row tasks.
* It automatically falls back to append-only logs in non-interactive environments.
@@ -72,6 +83,8 @@ export class SmartcliTerminal {
private lastRenderedOutput = '';
private cursorHidden = false;
private cleanupRegistered = false;
private liveRenderInterval: ReturnType<typeof setInterval> | null = null;
private liveRenderIntervalMs = 0;
private nonInteractiveLogState = new Map<SmartcliTerminalTask, INonInteractiveLogState>();
private cleanupHandlers: Array<() => void> = [];
@@ -91,6 +104,7 @@ export class SmartcliTerminal {
if (this.interactive) {
this.ensureInteractiveSession();
this.render();
this.updateLiveRenderLoop();
} else {
this.writePermanentLine(`start ${task.job}`);
}
@@ -124,9 +138,12 @@ export class SmartcliTerminal {
}
if (this.interactive) {
this.updateLiveRenderLoop();
this.render();
} else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
this.writePermanentLine(`update ${taskArg.job}: ${messageArg}`);
for (const messageLine of normalizeLines(messageArg)) {
this.writePermanentLine(`update ${taskArg.job}: ${messageLine}`);
}
}
}
@@ -134,17 +151,19 @@ export class SmartcliTerminal {
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.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${message ? ` - ${message}` : ''}`
: `done ${taskArg.job} in ${taskArg.getElapsedSummaryText()}${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.getStatusSymbol('failed')} ${taskArg.job} ${taskArg.getElapsedSummaryText()}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`
: `fail ${taskArg.job} in ${taskArg.getElapsedSummaryText()}${errorLinesArg[0] ? `: ${errorLinesArg[0]}` : ''}`;
const detailLines = errorLinesArg.slice(1).map((lineArg) => {
return this.interactive ? ` ${lineArg}` : `fail ${taskArg.job}: ${lineArg}`;
});
this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
}
@@ -155,6 +174,7 @@ export class SmartcliTerminal {
}
this.tasks = [];
this.nonInteractiveLogState.clear();
this.stopLiveRenderLoop();
}
/** @internal */
@@ -206,6 +226,7 @@ export class SmartcliTerminal {
if (this.interactive) {
this.clearRenderedBlock();
this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
this.updateLiveRenderLoop();
this.render();
if (this.tasks.length === 0) {
this.restoreCursor();
@@ -308,6 +329,7 @@ export class SmartcliTerminal {
}
const restoreOnly = () => {
this.stopLiveRenderLoop();
this.clearRenderedBlock();
if (this.cursorHidden) {
this.stream.write('\u001B[?25h');
@@ -338,6 +360,38 @@ export class SmartcliTerminal {
this.cleanupRegistered = true;
}
private updateLiveRenderLoop(): void {
if (!this.interactive) {
this.stopLiveRenderLoop();
return;
}
const liveIntervals = this.tasks
.map((taskArg) => taskArg.getLiveRenderIntervalMs())
.filter((intervalArg): intervalArg is number => Boolean(intervalArg));
const nextInterval = liveIntervals.length > 0 ? Math.min(...liveIntervals) : 0;
if (nextInterval === this.liveRenderIntervalMs) {
return;
}
this.stopLiveRenderLoop();
if (nextInterval > 0) {
this.liveRenderInterval = setInterval(() => this.render(), nextInterval);
(this.liveRenderInterval as any).unref?.();
this.liveRenderIntervalMs = nextInterval;
}
}
private stopLiveRenderLoop(): void {
if (this.liveRenderInterval) {
clearInterval(this.liveRenderInterval);
this.liveRenderInterval = null;
}
this.liveRenderIntervalMs = 0;
}
private unregisterProcessCleanup(): void {
for (const cleanupHandler of this.cleanupHandlers) {
cleanupHandler();
@@ -362,12 +416,24 @@ export class SmartcliTerminalTask {
private errorLines: string[] = [];
private progressCurrent?: number;
private progressTotal?: number;
private showTimer: boolean;
private showSpinner: boolean;
private spinnerFrames: string[];
private spinnerIntervalMs: 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));
this.showTimer = Boolean(optionsArg.showTimer ?? optionsArg.timer ?? false);
this.showSpinner = Boolean(optionsArg.showSpinner ?? optionsArg.spinner ?? false);
this.spinnerFrames = optionsArg.spinnerFrames?.length
? optionsArg.spinnerFrames
: this.terminal.getStatusSymbol('running') === '*'
? asciiSpinnerFrames
: unicodeSpinnerFrames;
this.spinnerIntervalMs = Math.max(20, Math.floor(optionsArg.spinnerIntervalMs || 80));
}
public log(messageArg: string): this {
@@ -389,6 +455,26 @@ export class SmartcliTerminalTask {
return this.log(messageArg);
}
public setTimerEnabled(enabledArg = true): this {
if (this.status !== 'running') {
return this;
}
this.showTimer = enabledArg;
this.terminal.updateTask(this);
return this;
}
public setSpinnerEnabled(enabledArg = true): this {
if (this.status !== 'running') {
return this;
}
this.showSpinner = enabledArg;
this.terminal.updateTask(this);
return this;
}
public setProgress(currentArg: number, totalArg: number, messageArg?: string): this {
if (this.status !== 'running') {
return this;
@@ -450,7 +536,16 @@ export class SmartcliTerminalTask {
}
public getElapsedText(): string {
return formatDuration(Date.now() - this.startTime);
return formatSmarttimeSeconds(Date.now() - this.startTime);
}
public getTimerText(): string {
return formatSmarttimeSeconds(Date.now() - this.startTime);
}
/** @internal */
public getElapsedSummaryText(): string {
return this.showTimer ? this.getTimerText() : this.getElapsedText();
}
public getLastLogLine(): string | undefined {
@@ -465,6 +560,23 @@ export class SmartcliTerminalTask {
return [...this.errorLines];
}
/** @internal */
public getLiveRenderIntervalMs(): number | undefined {
if (this.status !== 'running') {
return undefined;
}
if (this.showSpinner) {
return this.spinnerIntervalMs;
}
if (this.showTimer) {
return 1000;
}
return undefined;
}
/** @internal */
public renderPlainRows(widthArg: number): string[] {
const lines: string[] = [];
@@ -493,7 +605,17 @@ export class SmartcliTerminalTask {
private getHeaderLine(): string {
const progressText = this.progressTotal ? ` ${this.getProgressText()}` : '';
return `${this.terminal.getStatusSymbol(this.status)} ${this.job}${progressText} ${this.getElapsedText()}`;
const timerText = this.showTimer ? ` ${this.getTimerText()}` : '';
return `${this.getRunningIndicator()} ${this.job}${progressText}${timerText}`;
}
private getRunningIndicator(): string {
if (this.status !== 'running' || !this.showSpinner) {
return this.terminal.getStatusSymbol(this.status);
}
const frameIndex = Math.floor((Date.now() - this.startTime) / this.spinnerIntervalMs) % this.spinnerFrames.length;
return this.spinnerFrames[frameIndex];
}
private getProgressText(): string {
@@ -615,12 +737,15 @@ function formatError(errorArg: unknown): string[] {
}
}
function formatDuration(millisecondsArg: number): string {
if (millisecondsArg < 1000) {
return `${millisecondsArg}ms`;
function formatSmarttimeSeconds(millisecondsArg: number): string {
const seconds = Math.floor(millisecondsArg / 1000);
if (seconds === 0) {
return '0s';
}
return `${(millisecondsArg / 1000).toFixed(1)}s`;
return plugins.smarttime.getMilliSecondsAsHumanReadableString(
plugins.smarttime.units.seconds(seconds)
);
}
function truncateLine(lineArg: string, widthArg: number): string {
+2 -1
View File
@@ -5,8 +5,9 @@ import * as path from 'node:path';
import * as smartparam from '@push.rocks/smartobject';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrx from '@push.rocks/smartrx';
import * as smarttime from '@push.rocks/smarttime';
export { smartlog, lik, path, smartparam, smartpromise, smartrx };
export { smartlog, lik, path, smartparam, smartpromise, smartrx, smarttime };
// thirdparty scope
import yargsParser from 'yargs-parser';