feat(terminal): add optional live timers and spinners to terminal tasks
This commit is contained in:
+137
-12
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user