feat(terminal): enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes

This commit is contained in:
2026-05-13 18:33:06 +00:00
parent c07b2969b8
commit 502cca375f
6 changed files with 507 additions and 124 deletions
+7
View File
@@ -3,6 +3,13 @@
## Pending ## Pending
### Features
- enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes (terminal)
- add task progress reporting and task.run() helpers for automatic completion and failure handling
- support configurable unicode or ascii symbols, cleanup behavior, and throttled non-interactive lifecycle logs
- export new terminal task run and symbol mode types and document the updated terminal API
## 2026-05-13 - 4.1.0 ## 2026-05-13 - 4.1.0
### Features ### Features
+16 -5
View File
@@ -75,20 +75,19 @@ demo help
## Terminal Task Rendering ## 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. In CI, pipes, Docker logs, or `TERM=dumb`, the same calls become append-only log lines. 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.
```ts ```ts
import { SmartcliTerminal } from '@push.rocks/smartcli'; import { SmartcliTerminal } from '@push.rocks/smartcli';
const terminal = new SmartcliTerminal(); const terminal = new SmartcliTerminal();
const buildTask = terminal.createTask({ const buildTask = terminal.task('Build package', {
job: 'Build package',
rows: 3, rows: 3,
}); });
buildTask.update('Installing dependencies'); buildTask.update('Installing dependencies');
buildTask.log('Running tsbuild'); buildTask.setProgress(1, 2, 'Running tsbuild');
buildTask.complete('Build finished'); buildTask.complete('Build finished');
const publishTask = terminal.createProcess({ const publishTask = terminal.createProcess({
@@ -104,7 +103,19 @@ try {
} }
``` ```
Completed tasks collapse into a permanent `[ok]` line. Failed tasks collapse into a permanent `[err]` line with error details. If an error should remain visible inside the live task area, use `attachError(error, { keepOpen: true })`. 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 })`.
For scoped work, `task.run()` completes or fails automatically:
```ts
await terminal.task('Generate assets').run(async (task) => {
task.setProgress(1, 3, 'Reading source files');
await readSourceFiles();
task.setProgress(2, 3, 'Rendering assets');
await renderAssets();
task.setProgress(3, 3, 'Writing output');
}, { successMessage: 'Assets generated' });
```
## Execution Model ## Execution Model
-79
View File
@@ -3,25 +3,6 @@ import * as smartrx from '@push.rocks/smartrx';
import * as smartcli from '../ts/index.js'; import * as smartcli from '../ts/index.js';
class TestWritable implements smartcli.ISmartcliWritable {
public chunks: string[] = [];
public isTTY: boolean;
public columns = 80;
constructor(isTTYArg: boolean) {
this.isTTY = isTTYArg;
}
public write(chunkArg: string): boolean {
this.chunks.push(chunkArg);
return true;
}
public toString(): string {
return this.chunks.join('');
}
}
tap.test('should create a new Smartcli', async () => { tap.test('should create a new Smartcli', async () => {
const smartCliTestObject = new smartcli.Smartcli(); const smartCliTestObject = new smartcli.Smartcli();
expect(smartCliTestObject).toBeInstanceOf(smartcli.Smartcli); expect(smartCliTestObject).toBeInstanceOf(smartcli.Smartcli);
@@ -55,64 +36,4 @@ tap.test('should accept a command', async () => {
expect(hasExecuted).toBeTrue(); expect(hasExecuted).toBeTrue();
}); });
tap.test('should render terminal tasks in non-interactive mode', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false });
const task = terminal.createTask({ job: 'build package', rows: 2 });
task.update('running tsbuild');
task.complete('done');
const output = stream.toString();
expect(output).toInclude('[start] build package');
expect(output).toInclude('[build package] running tsbuild');
expect(output).toInclude('[ok] build package - done');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should render fixed rows in interactive mode', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false });
const task = terminal.createTask({ job: 'install dependencies', rows: 2 });
task.update('fetching packages');
const renderedRows = task.renderPlainRows(80);
expect(renderedRows).toHaveLength(2);
expect(renderedRows[0]).toInclude('[run] install dependencies');
expect(stream.toString()).toInclude('\u001B[2K');
task.complete('installed');
expect(stream.toString()).toInclude('[ok] install dependencies - installed');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should attach persistent terminal task errors', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: true, colors: false });
const task = terminal.createTask({ job: 'deploy release', rows: 3 });
task.attachError('deployment failed', { keepOpen: true });
expect(task.status).toEqual('failed');
expect(task.getErrorLines()).toContain('deployment failed');
expect(task.renderPlainRows(80)[0]).toInclude('[err] deploy release');
expect(terminal.getTasks()).toHaveLength(1);
terminal.clear();
});
tap.test('should collapse failed terminal tasks into permanent output', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false });
const task = terminal.createTask({ job: 'publish package' });
task.attachError('registry rejected package');
const output = stream.toString();
expect(output).toInclude('[err] publish package - registry rejected package');
expect(terminal.getTasks()).toHaveLength(0);
});
export default tap.start(); export default tap.start();
+190
View File
@@ -0,0 +1,190 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as smartcli from '../ts/index.js';
class TestWritable implements smartcli.ISmartcliWritable {
public chunks: string[] = [];
public isTTY: boolean;
public columns = 80;
constructor(isTTYArg: boolean) {
this.isTTY = isTTYArg;
}
public write(chunkArg: string): boolean {
this.chunks.push(chunkArg);
return true;
}
public toString(): string {
return this.chunks.join('');
}
}
tap.test('should render terminal tasks in non-interactive mode', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
symbols: 'ascii',
nonInteractiveThrottleMs: 0,
});
const task = terminal.createTask({ job: 'build package', rows: 2 });
task.update('running tsbuild');
task.complete('done');
const output = stream.toString();
expect(output).toInclude('start build package');
expect(output).toInclude('update build package: running tsbuild');
expect(output).toInclude('done build package in');
expect(output).toInclude(': done');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should render fixed rows in interactive mode', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.createTask({ job: 'install dependencies', rows: 2 });
task.update('fetching packages');
const renderedRows = task.renderPlainRows(80);
expect(renderedRows).toHaveLength(2);
expect(renderedRows[0]).toInclude('* install dependencies');
expect(stream.toString()).toInclude('\u001B[?25l');
expect(stream.toString()).toInclude('\u001B[2K');
task.complete('installed');
expect(stream.toString()).toInclude('OK install dependencies');
expect(stream.toString()).toInclude('installed');
expect(stream.toString()).toInclude('\u001B[?25h');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should attach persistent terminal task errors', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.createTask({ job: 'deploy release', rows: 3 });
task.attachError('deployment failed', { keepOpen: true });
expect(task.status).toEqual('failed');
expect(task.getErrorLines()).toContain('deployment failed');
expect(task.renderPlainRows(80)[0]).toInclude('X deploy release');
expect(terminal.getTasks()).toHaveLength(1);
terminal.clear();
});
tap.test('should collapse failed terminal tasks into permanent output', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
symbols: 'ascii',
});
const task = terminal.createTask({ job: 'publish package' });
task.attachError('registry rejected package');
const output = stream.toString();
expect(output).toInclude('fail publish package in');
expect(output).toInclude(': registry rejected package');
expect(terminal.getTasks()).toHaveLength(0);
});
tap.test('should set task progress', async () => {
const stream = new TestWritable(true);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: true,
colors: false,
symbols: 'ascii',
cleanup: false,
});
const task = terminal.task('process files', { rows: 2 });
task.setProgress(2, 5, 'processed files');
expect(task.getLastLogLine()).toInclude('40% (2/5)');
expect(task.renderPlainRows(80)[0]).toInclude('40% (2/5)');
terminal.clear();
});
tap.test('should auto-complete task.run', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
nonInteractiveThrottleMs: 0,
});
const task = terminal.task('run operation');
const result = await task.run(async (taskArg) => {
taskArg.update('inside operation');
return 'result';
}, { successMessage: 'operation finished' });
expect(result).toEqual('result');
expect(task.status).toEqual('completed');
expect(stream.toString()).toInclude('done run operation in');
expect(stream.toString()).toInclude('operation finished');
});
tap.test('should auto-fail task.run', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({ stream, interactive: false, colors: false });
const task = terminal.task('failing operation');
let caughtError: Error | undefined;
try {
await task.run(async () => {
throw new Error('operation failed');
});
} catch (error) {
caughtError = error as Error;
}
expect(caughtError?.message).toEqual('operation failed');
expect(task.status).toEqual('failed');
expect(stream.toString()).toInclude('fail failing operation in');
expect(stream.toString()).toInclude('operation failed');
});
tap.test('should throttle duplicate non-interactive updates', async () => {
const stream = new TestWritable(false);
const terminal = new smartcli.SmartcliTerminal({
stream,
interactive: false,
colors: false,
nonInteractiveThrottleMs: 10000,
});
const task = terminal.task('quiet task');
task.update('same update');
task.update('same update');
task.update('different but throttled');
const output = stream.toString();
expect(output).toInclude('update quiet task: same update');
expect(output).not.toInclude('different but throttled');
});
export default tap.start();
+2
View File
@@ -3,7 +3,9 @@ export { SmartcliTerminal, SmartcliTerminalTask } from './smartcli.classes.termi
export type { export type {
ISmartcliTerminalAttachErrorOptions, ISmartcliTerminalAttachErrorOptions,
ISmartcliTerminalOptions, ISmartcliTerminalOptions,
ISmartcliTerminalTaskRunOptions,
ISmartcliTerminalTaskOptions, ISmartcliTerminalTaskOptions,
ISmartcliWritable, ISmartcliWritable,
TSmartcliTerminalSymbolMode,
TSmartcliTerminalTaskStatus, TSmartcliTerminalTaskStatus,
} from './smartcli.classes.terminal.js'; } from './smartcli.classes.terminal.js';
+292 -40
View File
@@ -1,4 +1,5 @@
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed'; export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
export interface ISmartcliWritable { export interface ISmartcliWritable {
isTTY?: boolean; isTTY?: boolean;
@@ -10,6 +11,9 @@ export interface ISmartcliTerminalOptions {
stream?: ISmartcliWritable; stream?: ISmartcliWritable;
interactive?: boolean; interactive?: boolean;
colors?: boolean; colors?: boolean;
symbols?: TSmartcliTerminalSymbolMode;
cleanup?: boolean;
nonInteractiveThrottleMs?: number;
} }
export interface ISmartcliTerminalTaskOptions { export interface ISmartcliTerminalTaskOptions {
@@ -22,6 +26,16 @@ export interface ISmartcliTerminalAttachErrorOptions {
keepOpen?: boolean; keepOpen?: boolean;
} }
export interface ISmartcliTerminalTaskRunOptions {
successMessage?: string;
errorKeepOpen?: boolean;
}
interface INonInteractiveLogState {
message: string;
timestamp: number;
}
const ansiCodes = { const ansiCodes = {
reset: '\u001B[0m', reset: '\u001B[0m',
red: '\u001B[31m', red: '\u001B[31m',
@@ -30,6 +44,18 @@ const ansiCodes = {
gray: '\u001B[90m', gray: '\u001B[90m',
}; };
const unicodeSymbols = {
running: '●',
completed: '✓',
failed: '✕',
};
const asciiSymbols = {
running: '*',
completed: 'OK',
failed: 'X',
};
/** /**
* A live terminal renderer for multiple fixed-row tasks. * A live terminal renderer for multiple fixed-row tasks.
* It automatically falls back to append-only logs in non-interactive environments. * It automatically falls back to append-only logs in non-interactive environments.
@@ -38,13 +64,24 @@ export class SmartcliTerminal {
private stream: ISmartcliWritable; private stream: ISmartcliWritable;
private interactive: boolean; private interactive: boolean;
private colors: boolean; private colors: boolean;
private useUnicodeSymbols: boolean;
private cleanupEnabled: boolean;
private nonInteractiveThrottleMs: number;
private tasks: SmartcliTerminalTask[] = []; private tasks: SmartcliTerminalTask[] = [];
private renderedLineCount = 0; private renderedLineCount = 0;
private lastRenderedOutput = '';
private cursorHidden = false;
private cleanupRegistered = false;
private nonInteractiveLogState = new Map<SmartcliTerminalTask, INonInteractiveLogState>();
private cleanupHandlers: Array<() => void> = [];
constructor(optionsArg: ISmartcliTerminalOptions = {}) { constructor(optionsArg: ISmartcliTerminalOptions = {}) {
this.stream = optionsArg.stream || getDefaultStream(); this.stream = optionsArg.stream || getDefaultStream();
this.interactive = getInteractiveState(this.stream, optionsArg.interactive); this.interactive = getInteractiveState(this.stream, optionsArg.interactive);
this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR')); this.colors = optionsArg.colors ?? (this.interactive && !hasEnvFlag('NO_COLOR'));
this.useUnicodeSymbols = getUnicodeSymbolState(this.stream, optionsArg.symbols);
this.cleanupEnabled = optionsArg.cleanup ?? true;
this.nonInteractiveThrottleMs = Math.max(0, optionsArg.nonInteractiveThrottleMs ?? 250);
} }
public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask { public createTask(optionsArg: ISmartcliTerminalTaskOptions): SmartcliTerminalTask {
@@ -52,9 +89,10 @@ export class SmartcliTerminal {
this.tasks.push(task); this.tasks.push(task);
if (this.interactive) { if (this.interactive) {
this.ensureInteractiveSession();
this.render(); this.render();
} else { } else {
this.writePermanentLine(`[start] ${task.job}`); this.writePermanentLine(`start ${task.job}`);
} }
return task; return task;
@@ -64,6 +102,13 @@ export class SmartcliTerminal {
return this.createTask(optionsArg); return this.createTask(optionsArg);
} }
public task(jobArg: string, optionsArg: Omit<ISmartcliTerminalTaskOptions, 'job'> = {}) {
return this.createTask({
...optionsArg,
job: jobArg,
});
}
public isInteractive(): boolean { public isInteractive(): boolean {
return this.interactive; return this.interactive;
} }
@@ -80,39 +125,91 @@ export class SmartcliTerminal {
if (this.interactive) { if (this.interactive) {
this.render(); this.render();
} else if (messageArg) { } else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
this.writePermanentLine(`[${taskArg.job}] ${messageArg}`); this.writePermanentLine(`update ${taskArg.job}: ${messageArg}`);
} }
} }
/** @internal */ /** @internal */
public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void { public completeTask(taskArg: SmartcliTerminalTask, messageArg?: string): void {
const message = messageArg || taskArg.getLastLogLine(); const message = messageArg || taskArg.getLastLogLine();
const summary = `[ok] ${taskArg.job}${message ? ` - ${message}` : ''}`; const summary = this.interactive
this.finalizeTask(taskArg, [summary]); ? `${this.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedText()}${message ? ` - ${message}` : ''}`
: `done ${taskArg.job} in ${taskArg.getElapsedText()}${message ? `: ${message}` : ''}`;
this.finalizeTask(taskArg, [summary], 'completed');
} }
/** @internal */ /** @internal */
public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void { public failTask(taskArg: SmartcliTerminalTask, errorLinesArg: string[]): void {
const summary = `[err] ${taskArg.job}${errorLinesArg[0] ? ` - ${errorLinesArg[0]}` : ''}`; 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}`); const detailLines = errorLinesArg.slice(1).map((lineArg) => ` ${lineArg}`);
this.finalizeTask(taskArg, [summary, ...detailLines]); this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
} }
public clear(): void { public clear(): void {
if (this.interactive) { if (this.interactive) {
this.clearRenderedBlock(); this.clearRenderedBlock();
this.restoreCursor();
} }
this.tasks = []; this.tasks = [];
this.nonInteractiveLogState.clear();
} }
private finalizeTask(taskArg: SmartcliTerminalTask, linesArg: string[]): void { /** @internal */
public getStatusSymbol(statusArg: TSmartcliTerminalTaskStatus): string {
const symbols = this.useUnicodeSymbols ? unicodeSymbols : asciiSymbols;
return symbols[statusArg];
}
/** @internal */
public colorizeLine(lineArg: string, statusArg?: TSmartcliTerminalTaskStatus): string {
if (!this.colors) {
return lineArg;
}
if (statusArg === 'completed') {
return `${ansiCodes.green}${lineArg}${ansiCodes.reset}`;
}
if (statusArg === 'failed') {
return `${ansiCodes.red}${lineArg}${ansiCodes.reset}`;
}
if (statusArg === 'running') {
return `${ansiCodes.cyan}${lineArg}${ansiCodes.reset}`;
}
if (lineArg.startsWith(' ')) {
return `${ansiCodes.gray}${lineArg}${ansiCodes.reset}`;
}
return lineArg;
}
private ensureInteractiveSession(): void {
if (!this.cursorHidden) {
this.stream.write('\u001B[?25l');
this.cursorHidden = true;
}
if (this.cleanupEnabled && !this.cleanupRegistered) {
this.registerProcessCleanup();
}
}
private finalizeTask(
taskArg: SmartcliTerminalTask,
linesArg: string[],
statusArg: TSmartcliTerminalTaskStatus
): void {
this.tasks = this.tasks.filter((task) => task !== taskArg); this.tasks = this.tasks.filter((task) => task !== taskArg);
this.nonInteractiveLogState.delete(taskArg);
if (this.interactive) { if (this.interactive) {
this.clearRenderedBlock(); this.clearRenderedBlock();
this.writePermanentLines(linesArg); this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
this.render(); this.render();
if (this.tasks.length === 0) {
this.restoreCursor();
}
} else { } else {
this.writePermanentLines(linesArg); this.writePermanentLines(linesArg);
} }
@@ -124,27 +221,32 @@ export class SmartcliTerminal {
} }
const width = this.getLineWidth(); const width = this.getLineWidth();
const lines = this.tasks.flatMap((taskArg) => { const lines = this.tasks.flatMap((taskArg) => taskArg.renderPlainRows(width));
return taskArg.renderPlainRows(width).map((lineArg, indexArg) => { const coloredLines = lines.map((lineArg) => {
const truncatedLine = truncateLine(lineArg, width); const status = lineArg.startsWith(' ') ? undefined : getStatusFromRenderedLine(lineArg, this);
return indexArg === 0 ? this.colorizeStatusLabel(truncatedLine) : truncatedLine; return this.colorizeLine(lineArg, status);
});
}); });
const renderedOutput = coloredLines.join('\n');
if (renderedOutput === this.lastRenderedOutput) {
return;
}
if (this.renderedLineCount > 0) { if (this.renderedLineCount > 0) {
this.stream.write(`\u001B[${this.renderedLineCount}F`); this.stream.write(`\u001B[${this.renderedLineCount}F`);
} }
const lineCount = Math.max(this.renderedLineCount, lines.length); const lineCount = Math.max(this.renderedLineCount, coloredLines.length);
for (let index = 0; index < lineCount; index++) { for (let index = 0; index < lineCount; index++) {
this.stream.write('\u001B[2K\r'); this.stream.write('\u001B[2K\r');
if (index < lines.length) { if (index < coloredLines.length) {
this.stream.write(lines[index]); this.stream.write(coloredLines[index]);
} }
this.stream.write('\n'); this.stream.write('\n');
} }
this.renderedLineCount = lines.length; this.renderedLineCount = coloredLines.length;
this.lastRenderedOutput = renderedOutput;
} }
private clearRenderedBlock(): void { private clearRenderedBlock(): void {
@@ -155,6 +257,7 @@ export class SmartcliTerminal {
this.stream.write(`\u001B[${this.renderedLineCount}F`); this.stream.write(`\u001B[${this.renderedLineCount}F`);
this.stream.write(`\u001B[${this.renderedLineCount}M`); this.stream.write(`\u001B[${this.renderedLineCount}M`);
this.renderedLineCount = 0; this.renderedLineCount = 0;
this.lastRenderedOutput = '';
} }
private writePermanentLines(linesArg: string[]): void { private writePermanentLines(linesArg: string[]): void {
@@ -164,28 +267,83 @@ export class SmartcliTerminal {
} }
private writePermanentLine(lineArg: string): void { private writePermanentLine(lineArg: string): void {
const line = this.colors ? this.colorizeStatusLabel(lineArg) : lineArg; this.stream.write(`${lineArg}\n`);
this.stream.write(`${line}\n`);
} }
private colorizeStatusLabel(lineArg: string): string { private shouldWriteNonInteractiveUpdate(
if (!this.colors) { taskArg: SmartcliTerminalTask,
return lineArg; messageArg: string
): boolean {
const now = Date.now();
const previousState = this.nonInteractiveLogState.get(taskArg);
if (previousState?.message === messageArg) {
return false;
} }
if (lineArg.startsWith('[ok]')) { if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) {
return `${ansiCodes.green}[ok]${ansiCodes.reset}${lineArg.slice(4)}`; return false;
} }
if (lineArg.startsWith('[err]')) {
return `${ansiCodes.red}[err]${ansiCodes.reset}${lineArg.slice(5)}`; this.nonInteractiveLogState.set(taskArg, {
message: messageArg,
timestamp: now,
});
return true;
}
private restoreCursor(): void {
if (!this.cursorHidden) {
return;
} }
if (lineArg.startsWith('[run]')) { this.stream.write('\u001B[?25h');
return `${ansiCodes.cyan}[run]${ansiCodes.reset}${lineArg.slice(5)}`; this.cursorHidden = false;
this.unregisterProcessCleanup();
}
private registerProcessCleanup(): void {
const processObject = getProcessObject();
if (!processObject?.once) {
return;
} }
if (lineArg.startsWith('[start]')) {
return `${ansiCodes.gray}[start]${ansiCodes.reset}${lineArg.slice(7)}`; const restoreOnly = () => {
this.clearRenderedBlock();
if (this.cursorHidden) {
this.stream.write('\u001B[?25h');
this.cursorHidden = false;
}
};
const exitWithSignal = (codeArg: number) => {
restoreOnly();
processObject.exit?.(codeArg);
};
const throwAfterRestore = (errorArg: unknown) => {
restoreOnly();
throw errorArg;
};
const sigintHandler = () => exitWithSignal(130);
const sigtermHandler = () => exitWithSignal(143);
processObject.once('exit', restoreOnly);
processObject.once('SIGINT', sigintHandler);
processObject.once('SIGTERM', sigtermHandler);
processObject.once('uncaughtException', throwAfterRestore);
this.cleanupHandlers = [
() => processObject.off?.('exit', restoreOnly),
() => processObject.off?.('SIGINT', sigintHandler),
() => processObject.off?.('SIGTERM', sigtermHandler),
() => processObject.off?.('uncaughtException', throwAfterRestore),
];
this.cleanupRegistered = true;
}
private unregisterProcessCleanup(): void {
for (const cleanupHandler of this.cleanupHandlers) {
cleanupHandler();
} }
return lineArg; this.cleanupHandlers = [];
this.cleanupRegistered = false;
} }
private getLineWidth(): number { private getLineWidth(): number {
@@ -196,11 +354,14 @@ export class SmartcliTerminal {
export class SmartcliTerminalTask { export class SmartcliTerminalTask {
public readonly job: string; public readonly job: string;
public readonly rows: number; public readonly rows: number;
public readonly startTime = Date.now();
public status: TSmartcliTerminalTaskStatus = 'running'; public status: TSmartcliTerminalTaskStatus = 'running';
private terminal: SmartcliTerminal; private terminal: SmartcliTerminal;
private logLimit: number; private logLimit: number;
private logLines: string[] = []; private logLines: string[] = [];
private errorLines: string[] = []; private errorLines: string[] = [];
private progressCurrent?: number;
private progressTotal?: number;
constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) { constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) {
this.terminal = terminalArg; this.terminal = terminalArg;
@@ -228,6 +389,32 @@ export class SmartcliTerminalTask {
return this.log(messageArg); return this.log(messageArg);
} }
public setProgress(currentArg: number, totalArg: number, messageArg?: string): this {
if (this.status !== 'running') {
return this;
}
this.progressCurrent = Math.max(0, currentArg);
this.progressTotal = Math.max(0, totalArg);
const progressText = this.getProgressText();
this.log(messageArg ? `${messageArg} ${progressText}` : progressText);
return this;
}
public async run<T>(
operationArg: (taskArg: this) => T | Promise<T>,
optionsArg: ISmartcliTerminalTaskRunOptions = {}
): Promise<T> {
try {
const result = await operationArg(this);
this.complete(optionsArg.successMessage);
return result;
} catch (error) {
this.attachError(error, { keepOpen: optionsArg.errorKeepOpen });
throw error;
}
}
public complete(messageArg?: string): this { public complete(messageArg?: string): this {
if (this.status !== 'running') { if (this.status !== 'running') {
return this; return this;
@@ -262,6 +449,10 @@ export class SmartcliTerminalTask {
return this.attachError(errorArg); return this.attachError(errorArg);
} }
public getElapsedText(): string {
return formatDuration(Date.now() - this.startTime);
}
public getLastLogLine(): string | undefined { public getLastLogLine(): string | undefined {
return this.logLines[this.logLines.length - 1]; return this.logLines[this.logLines.length - 1];
} }
@@ -276,17 +467,17 @@ export class SmartcliTerminalTask {
/** @internal */ /** @internal */
public renderPlainRows(widthArg: number): string[] { public renderPlainRows(widthArg: number): string[] {
const statusLabel = this.status === 'failed' ? '[err]' : '[run]';
const lines: string[] = []; const lines: string[] = [];
const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines; const detailLines = this.errorLines.length > 0 ? this.errorLines : this.logLines;
const header = this.getHeaderLine();
if (this.rows === 1) { if (this.rows === 1) {
const lastDetail = detailLines[detailLines.length - 1]; const lastDetail = detailLines[detailLines.length - 1];
lines.push(`${statusLabel} ${this.job}${lastDetail ? ` - ${lastDetail}` : ''}`); lines.push(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`);
return lines; return lines.map((lineArg) => truncateLine(lineArg, widthArg));
} }
lines.push(`${statusLabel} ${this.job}`); lines.push(header);
const visibleDetailLineCount = this.rows - 1; const visibleDetailLineCount = this.rows - 1;
const visibleDetailLines = detailLines.slice(-visibleDetailLineCount); const visibleDetailLines = detailLines.slice(-visibleDetailLineCount);
for (const line of visibleDetailLines) { for (const line of visibleDetailLines) {
@@ -299,12 +490,26 @@ export class SmartcliTerminalTask {
return lines.map((lineArg) => truncateLine(lineArg, widthArg)); return lines.map((lineArg) => truncateLine(lineArg, widthArg));
} }
private getHeaderLine(): string {
const progressText = this.progressTotal ? ` ${this.getProgressText()}` : '';
return `${this.terminal.getStatusSymbol(this.status)} ${this.job}${progressText} ${this.getElapsedText()}`;
}
private getProgressText(): string {
if (!this.progressTotal) {
return '';
}
const percent = Math.floor((this.progressCurrent || 0) / this.progressTotal * 100);
return `${Math.min(100, percent)}% (${this.progressCurrent}/${this.progressTotal})`;
}
} }
function getDefaultStream(): ISmartcliWritable { function getDefaultStream(): ISmartcliWritable {
const globalObject: any = globalThis as any; const processObject = getProcessObject();
if (globalObject.process?.stdout?.write) { if (processObject?.stdout?.write) {
return globalObject.process.stdout; return processObject.stdout;
} }
return { return {
@@ -337,14 +542,53 @@ function getInteractiveState(streamArg: ISmartcliWritable, overrideArg?: boolean
); );
} }
function getUnicodeSymbolState(
streamArg: ISmartcliWritable,
modeArg: TSmartcliTerminalSymbolMode = 'auto'
): boolean {
if (modeArg === 'unicode') {
return true;
}
if (modeArg === 'ascii') {
return false;
}
const processObject = getProcessObject();
if (processObject?.platform === 'win32' && !getEnvValue('WT_SESSION')) {
return false;
}
return streamArg.isTTY !== false && getEnvValue('TERM') !== 'dumb';
}
function getStatusFromRenderedLine(
lineArg: string,
terminalArg: SmartcliTerminal
): TSmartcliTerminalTaskStatus | undefined {
if (lineArg.startsWith(terminalArg.getStatusSymbol('completed'))) {
return 'completed';
}
if (lineArg.startsWith(terminalArg.getStatusSymbol('failed'))) {
return 'failed';
}
if (lineArg.startsWith(terminalArg.getStatusSymbol('running'))) {
return 'running';
}
return undefined;
}
function hasEnvFlag(nameArg: string): boolean { function hasEnvFlag(nameArg: string): boolean {
const value = getEnvValue(nameArg); const value = getEnvValue(nameArg);
return Boolean(value && value !== '0' && value.toLowerCase() !== 'false'); return Boolean(value && value !== '0' && value.toLowerCase() !== 'false');
} }
function getEnvValue(nameArg: string): string | undefined { function getEnvValue(nameArg: string): string | undefined {
return getProcessObject()?.env?.[nameArg];
}
function getProcessObject(): any {
const globalObject: any = globalThis as any; const globalObject: any = globalThis as any;
return globalObject.process?.env?.[nameArg]; return globalObject.process;
} }
function normalizeLines(messageArg: string): string[] { function normalizeLines(messageArg: string): string[] {
@@ -371,6 +615,14 @@ function formatError(errorArg: unknown): string[] {
} }
} }
function formatDuration(millisecondsArg: number): string {
if (millisecondsArg < 1000) {
return `${millisecondsArg}ms`;
}
return `${(millisecondsArg / 1000).toFixed(1)}s`;
}
function truncateLine(lineArg: string, widthArg: number): string { function truncateLine(lineArg: string, widthArg: number): string {
if (lineArg.length <= widthArg) { if (lineArg.length <= widthArg) {
return lineArg; return lineArg;