feat(terminal): add optional live timers and spinners to terminal tasks
This commit is contained in:
@@ -4,6 +4,13 @@
|
||||
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
- add optional live timers and spinners to terminal tasks (terminal)
|
||||
- Adds task options and runtime toggles for live timers and animated spinners in interactive terminal rendering.
|
||||
- Prefixes every line of multiline updates and failure details with the task name in non-interactive output for clearer logs.
|
||||
- Uses @push.rocks/smarttime to format timer output as human-readable second-based durations.
|
||||
|
||||
## 2026-05-13 - 4.2.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"npm:@push.rocks/smartobject@^1.0.12": "1.0.12",
|
||||
"npm:@push.rocks/smartpromise@^4.2.4": "4.2.4",
|
||||
"npm:@push.rocks/smartrx@^3.0.10": "3.0.10",
|
||||
"npm:@push.rocks/smarttime@^4.2.3": "4.2.3",
|
||||
"npm:@types/node@^25.7.0": "25.7.0",
|
||||
"npm:@types/yargs-parser@^21.0.3": "21.0.3",
|
||||
"npm:yargs-parser@22.0.0": "22.0.0"
|
||||
@@ -6235,6 +6236,7 @@
|
||||
"npm:@push.rocks/smartobject@^1.0.12",
|
||||
"npm:@push.rocks/smartpromise@^4.2.4",
|
||||
"npm:@push.rocks/smartrx@^3.0.10",
|
||||
"npm:@push.rocks/smarttime@^4.2.3",
|
||||
"npm:@types/node@^25.7.0",
|
||||
"npm:@types/yargs-parser@^21.0.3",
|
||||
"npm:yargs-parser@22.0.0"
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@push.rocks/smartobject": "^1.0.12",
|
||||
"@push.rocks/smartpromise": "^4.2.4",
|
||||
"@push.rocks/smartrx": "^3.0.10",
|
||||
"@push.rocks/smarttime": "^4.2.3",
|
||||
"yargs-parser": "22.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+4
-1
@@ -23,6 +23,9 @@ importers:
|
||||
'@push.rocks/smartrx':
|
||||
specifier: ^3.0.10
|
||||
version: 3.0.10
|
||||
'@push.rocks/smarttime':
|
||||
specifier: ^4.2.3
|
||||
version: 4.2.3
|
||||
yargs-parser:
|
||||
specifier: 22.0.0
|
||||
version: 22.0.0
|
||||
@@ -5220,7 +5223,7 @@ snapshots:
|
||||
'@push.rocks/smarttime@4.2.3':
|
||||
dependencies:
|
||||
'@push.rocks/lik': 6.4.1
|
||||
'@push.rocks/smartdelay': 3.0.5
|
||||
'@push.rocks/smartdelay': 3.1.0
|
||||
'@push.rocks/smartpromise': 4.2.4
|
||||
croner: 10.0.1
|
||||
date-fns: 4.1.0
|
||||
|
||||
@@ -75,7 +75,7 @@ demo help
|
||||
|
||||
## Terminal Task Rendering
|
||||
|
||||
Use `SmartcliTerminal` for long-running jobs that should render cleanly in both interactive and non-interactive environments. In a TTY, active tasks render below each other with a fixed row count, colored status symbols, and elapsed time. In CI, pipes, Docker logs, or `TERM=dumb`, the same calls become throttled append-only lifecycle logs.
|
||||
Use `SmartcliTerminal` for long-running jobs that should render cleanly in both interactive and non-interactive environments. In a TTY, active tasks render below each other with a fixed row count, colored status symbols, and optional live timers/spinners. In CI, pipes, Docker logs, or `TERM=dumb`, the same calls become throttled append-only lifecycle logs where every message line is prefixed with its task name.
|
||||
|
||||
```ts
|
||||
import { SmartcliTerminal } from '@push.rocks/smartcli';
|
||||
@@ -84,6 +84,8 @@ const terminal = new SmartcliTerminal();
|
||||
|
||||
const buildTask = terminal.task('Build package', {
|
||||
rows: 3,
|
||||
showTimer: true,
|
||||
showSpinner: true,
|
||||
});
|
||||
|
||||
buildTask.update('Installing dependencies');
|
||||
@@ -105,6 +107,8 @@ try {
|
||||
|
||||
Completed tasks collapse into one permanent success line. Failed tasks collapse into one permanent failure line with error details. If an error should remain visible inside the live task area, use `attachError(error, { keepOpen: true })`.
|
||||
|
||||
`showTimer` renders a second-precision live counter using `@push.rocks/smarttime`, for example `4s` or `1m 30s`. `showSpinner` animates the running indicator in interactive terminals and is ignored for append-only output. The shorter aliases `timer` and `spinner` are also accepted.
|
||||
|
||||
For scoped work, `task.run()` completes or fails automatically:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -2,6 +2,10 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as smartcli from '../ts/index.js';
|
||||
|
||||
const delay = async (millisecondsArg: number) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, millisecondsArg));
|
||||
};
|
||||
|
||||
class TestWritable implements smartcli.ISmartcliWritable {
|
||||
public chunks: string[] = [];
|
||||
public isTTY: boolean;
|
||||
@@ -109,6 +113,29 @@ tap.test('should collapse failed terminal tasks into permanent output', async ()
|
||||
expect(terminal.getTasks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
tap.test('should prefix every non-interactive multiline message with the task name', async () => {
|
||||
const stream = new TestWritable(false);
|
||||
const terminal = new smartcli.SmartcliTerminal({
|
||||
stream,
|
||||
interactive: false,
|
||||
colors: false,
|
||||
nonInteractiveThrottleMs: 0,
|
||||
});
|
||||
const updateTask = terminal.task('multiline update');
|
||||
|
||||
updateTask.update('line one\nline two');
|
||||
updateTask.complete('done');
|
||||
|
||||
const failedTask = terminal.task('multiline failure');
|
||||
failedTask.fail('first failure line\nsecond failure line');
|
||||
|
||||
const output = stream.toString();
|
||||
expect(output).toInclude('update multiline update: line one');
|
||||
expect(output).toInclude('update multiline update: line two');
|
||||
expect(output).toInclude('fail multiline failure in');
|
||||
expect(output).toInclude('fail multiline failure: second failure line');
|
||||
});
|
||||
|
||||
tap.test('should set task progress', async () => {
|
||||
const stream = new TestWritable(true);
|
||||
const terminal = new smartcli.SmartcliTerminal({
|
||||
@@ -127,6 +154,49 @@ tap.test('should set task progress', async () => {
|
||||
terminal.clear();
|
||||
});
|
||||
|
||||
tap.test('should render an optional second-precision timer', async () => {
|
||||
const stream = new TestWritable(true);
|
||||
const terminal = new smartcli.SmartcliTerminal({
|
||||
stream,
|
||||
interactive: true,
|
||||
colors: false,
|
||||
symbols: 'ascii',
|
||||
cleanup: false,
|
||||
});
|
||||
const task = terminal.task('timed task', { rows: 2, showTimer: true });
|
||||
|
||||
expect(task.getTimerText()).toEqual('0s');
|
||||
expect(task.renderPlainRows(80)[0]).toInclude('0s');
|
||||
task.complete('timed complete');
|
||||
|
||||
expect(stream.toString()).toInclude('timed complete');
|
||||
});
|
||||
|
||||
tap.test('should render an optional spinner', async () => {
|
||||
const stream = new TestWritable(true);
|
||||
const terminal = new smartcli.SmartcliTerminal({
|
||||
stream,
|
||||
interactive: true,
|
||||
colors: false,
|
||||
symbols: 'ascii',
|
||||
cleanup: false,
|
||||
});
|
||||
const task = terminal.task('spinner task', {
|
||||
rows: 2,
|
||||
showSpinner: true,
|
||||
spinnerFrames: ['a', 'b'],
|
||||
spinnerIntervalMs: 20,
|
||||
});
|
||||
|
||||
await delay(90);
|
||||
|
||||
const output = stream.toString();
|
||||
expect(output).toInclude('spinner task');
|
||||
expect(output.includes('a spinner task') || output.includes('b spinner task')).toBeTrue();
|
||||
expect(task.getLiveRenderIntervalMs()).toEqual(20);
|
||||
task.complete('spinner complete');
|
||||
});
|
||||
|
||||
tap.test('should auto-complete task.run', async () => {
|
||||
const stream = new TestWritable(false);
|
||||
const terminal = new smartcli.SmartcliTerminal({
|
||||
|
||||
+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