Compare commits

...

2 Commits

Author SHA1 Message Date
jkunz e2eb4eb040 v4.2.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-13 18:33:08 +00:00
jkunz 502cca375f feat(terminal): enhance terminal task rendering with progress, lifecycle helpers, and configurable output modes 2026-05-13 18:33:06 +00:00
8 changed files with 512 additions and 126 deletions
+10
View File
@@ -3,6 +3,16 @@
## Pending
## 2026-05-13 - 4.2.0
### 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
### Features
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@push.rocks/smartcli",
"private": false,
"version": "4.1.0",
"version": "4.2.0",
"description": "A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
+16 -5
View File
@@ -75,20 +75,19 @@ 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. 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
import { SmartcliTerminal } from '@push.rocks/smartcli';
const terminal = new SmartcliTerminal();
const buildTask = terminal.createTask({
job: 'Build package',
const buildTask = terminal.task('Build package', {
rows: 3,
});
buildTask.update('Installing dependencies');
buildTask.log('Running tsbuild');
buildTask.setProgress(1, 2, 'Running tsbuild');
buildTask.complete('Build finished');
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
-79
View File
@@ -3,25 +3,6 @@ import * as smartrx from '@push.rocks/smartrx';
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 () => {
const smartCliTestObject = new smartcli.Smartcli();
expect(smartCliTestObject).toBeInstanceOf(smartcli.Smartcli);
@@ -55,64 +36,4 @@ tap.test('should accept a command', async () => {
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();
+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();
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartcli',
version: '4.1.0',
version: '4.2.0',
description: 'A library that simplifies building reactive command-line applications using observables, with robust support for commands, arguments, options, aliases, and asynchronous operation management.'
}
+2
View File
@@ -3,7 +3,9 @@ export { SmartcliTerminal, SmartcliTerminalTask } from './smartcli.classes.termi
export type {
ISmartcliTerminalAttachErrorOptions,
ISmartcliTerminalOptions,
ISmartcliTerminalTaskRunOptions,
ISmartcliTerminalTaskOptions,
ISmartcliWritable,
TSmartcliTerminalSymbolMode,
TSmartcliTerminalTaskStatus,
} from './smartcli.classes.terminal.js';
+292 -40
View File
@@ -1,4 +1,5 @@
export type TSmartcliTerminalTaskStatus = 'running' | 'completed' | 'failed';
export type TSmartcliTerminalSymbolMode = 'auto' | 'unicode' | 'ascii';
export interface ISmartcliWritable {
isTTY?: boolean;
@@ -10,6 +11,9 @@ export interface ISmartcliTerminalOptions {
stream?: ISmartcliWritable;
interactive?: boolean;
colors?: boolean;
symbols?: TSmartcliTerminalSymbolMode;
cleanup?: boolean;
nonInteractiveThrottleMs?: number;
}
export interface ISmartcliTerminalTaskOptions {
@@ -22,6 +26,16 @@ export interface ISmartcliTerminalAttachErrorOptions {
keepOpen?: boolean;
}
export interface ISmartcliTerminalTaskRunOptions {
successMessage?: string;
errorKeepOpen?: boolean;
}
interface INonInteractiveLogState {
message: string;
timestamp: number;
}
const ansiCodes = {
reset: '\u001B[0m',
red: '\u001B[31m',
@@ -30,6 +44,18 @@ const ansiCodes = {
gray: '\u001B[90m',
};
const unicodeSymbols = {
running: '●',
completed: '✓',
failed: '✕',
};
const asciiSymbols = {
running: '*',
completed: 'OK',
failed: 'X',
};
/**
* A live terminal renderer for multiple fixed-row tasks.
* It automatically falls back to append-only logs in non-interactive environments.
@@ -38,13 +64,24 @@ export class SmartcliTerminal {
private stream: ISmartcliWritable;
private interactive: boolean;
private colors: boolean;
private useUnicodeSymbols: boolean;
private cleanupEnabled: boolean;
private nonInteractiveThrottleMs: number;
private tasks: SmartcliTerminalTask[] = [];
private renderedLineCount = 0;
private lastRenderedOutput = '';
private cursorHidden = false;
private cleanupRegistered = false;
private nonInteractiveLogState = new Map<SmartcliTerminalTask, INonInteractiveLogState>();
private cleanupHandlers: Array<() => void> = [];
constructor(optionsArg: ISmartcliTerminalOptions = {}) {
this.stream = optionsArg.stream || getDefaultStream();
this.interactive = getInteractiveState(this.stream, optionsArg.interactive);
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 {
@@ -52,9 +89,10 @@ export class SmartcliTerminal {
this.tasks.push(task);
if (this.interactive) {
this.ensureInteractiveSession();
this.render();
} else {
this.writePermanentLine(`[start] ${task.job}`);
this.writePermanentLine(`start ${task.job}`);
}
return task;
@@ -64,6 +102,13 @@ export class SmartcliTerminal {
return this.createTask(optionsArg);
}
public task(jobArg: string, optionsArg: Omit<ISmartcliTerminalTaskOptions, 'job'> = {}) {
return this.createTask({
...optionsArg,
job: jobArg,
});
}
public isInteractive(): boolean {
return this.interactive;
}
@@ -80,39 +125,91 @@ export class SmartcliTerminal {
if (this.interactive) {
this.render();
} else if (messageArg) {
this.writePermanentLine(`[${taskArg.job}] ${messageArg}`);
} else if (messageArg && this.shouldWriteNonInteractiveUpdate(taskArg, messageArg)) {
this.writePermanentLine(`update ${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]);
const summary = this.interactive
? `${this.getStatusSymbol('completed')} ${taskArg.job} ${taskArg.getElapsedText()}${message ? ` - ${message}` : ''}`
: `done ${taskArg.job} in ${taskArg.getElapsedText()}${message ? `: ${message}` : ''}`;
this.finalizeTask(taskArg, [summary], 'completed');
}
/** @internal */
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}`);
this.finalizeTask(taskArg, [summary, ...detailLines]);
this.finalizeTask(taskArg, [summary, ...detailLines], 'failed');
}
public clear(): void {
if (this.interactive) {
this.clearRenderedBlock();
this.restoreCursor();
}
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.nonInteractiveLogState.delete(taskArg);
if (this.interactive) {
this.clearRenderedBlock();
this.writePermanentLines(linesArg);
this.writePermanentLines(linesArg.map((lineArg) => this.colorizeLine(lineArg, statusArg)));
this.render();
if (this.tasks.length === 0) {
this.restoreCursor();
}
} else {
this.writePermanentLines(linesArg);
}
@@ -124,27 +221,32 @@ export class SmartcliTerminal {
}
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;
});
const lines = this.tasks.flatMap((taskArg) => taskArg.renderPlainRows(width));
const coloredLines = lines.map((lineArg) => {
const status = lineArg.startsWith(' ') ? undefined : getStatusFromRenderedLine(lineArg, this);
return this.colorizeLine(lineArg, status);
});
const renderedOutput = coloredLines.join('\n');
if (renderedOutput === this.lastRenderedOutput) {
return;
}
if (this.renderedLineCount > 0) {
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++) {
this.stream.write('\u001B[2K\r');
if (index < lines.length) {
this.stream.write(lines[index]);
if (index < coloredLines.length) {
this.stream.write(coloredLines[index]);
}
this.stream.write('\n');
}
this.renderedLineCount = lines.length;
this.renderedLineCount = coloredLines.length;
this.lastRenderedOutput = renderedOutput;
}
private clearRenderedBlock(): void {
@@ -155,6 +257,7 @@ export class SmartcliTerminal {
this.stream.write(`\u001B[${this.renderedLineCount}F`);
this.stream.write(`\u001B[${this.renderedLineCount}M`);
this.renderedLineCount = 0;
this.lastRenderedOutput = '';
}
private writePermanentLines(linesArg: string[]): void {
@@ -164,28 +267,83 @@ export class SmartcliTerminal {
}
private writePermanentLine(lineArg: string): void {
const line = this.colors ? this.colorizeStatusLabel(lineArg) : lineArg;
this.stream.write(`${line}\n`);
this.stream.write(`${lineArg}\n`);
}
private colorizeStatusLabel(lineArg: string): string {
if (!this.colors) {
return lineArg;
private shouldWriteNonInteractiveUpdate(
taskArg: SmartcliTerminalTask,
messageArg: string
): boolean {
const now = Date.now();
const previousState = this.nonInteractiveLogState.get(taskArg);
if (previousState?.message === messageArg) {
return false;
}
if (lineArg.startsWith('[ok]')) {
return `${ansiCodes.green}[ok]${ansiCodes.reset}${lineArg.slice(4)}`;
if (previousState && now - previousState.timestamp < this.nonInteractiveThrottleMs) {
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;
}
if (lineArg.startsWith('[run]')) {
return `${ansiCodes.cyan}[run]${ansiCodes.reset}${lineArg.slice(5)}`;
private restoreCursor(): void {
if (!this.cursorHidden) {
return;
}
if (lineArg.startsWith('[start]')) {
return `${ansiCodes.gray}[start]${ansiCodes.reset}${lineArg.slice(7)}`;
this.stream.write('\u001B[?25h');
this.cursorHidden = false;
this.unregisterProcessCleanup();
}
return lineArg;
private registerProcessCleanup(): void {
const processObject = getProcessObject();
if (!processObject?.once) {
return;
}
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();
}
this.cleanupHandlers = [];
this.cleanupRegistered = false;
}
private getLineWidth(): number {
@@ -196,11 +354,14 @@ export class SmartcliTerminal {
export class SmartcliTerminalTask {
public readonly job: string;
public readonly rows: number;
public readonly startTime = Date.now();
public status: TSmartcliTerminalTaskStatus = 'running';
private terminal: SmartcliTerminal;
private logLimit: number;
private logLines: string[] = [];
private errorLines: string[] = [];
private progressCurrent?: number;
private progressTotal?: number;
constructor(terminalArg: SmartcliTerminal, optionsArg: ISmartcliTerminalTaskOptions) {
this.terminal = terminalArg;
@@ -228,6 +389,32 @@ export class SmartcliTerminalTask {
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 {
if (this.status !== 'running') {
return this;
@@ -262,6 +449,10 @@ export class SmartcliTerminalTask {
return this.attachError(errorArg);
}
public getElapsedText(): string {
return formatDuration(Date.now() - this.startTime);
}
public getLastLogLine(): string | undefined {
return this.logLines[this.logLines.length - 1];
}
@@ -276,17 +467,17 @@ export class SmartcliTerminalTask {
/** @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;
const header = this.getHeaderLine();
if (this.rows === 1) {
const lastDetail = detailLines[detailLines.length - 1];
lines.push(`${statusLabel} ${this.job}${lastDetail ? ` - ${lastDetail}` : ''}`);
return lines;
lines.push(`${header}${lastDetail ? ` - ${lastDetail}` : ''}`);
return lines.map((lineArg) => truncateLine(lineArg, widthArg));
}
lines.push(`${statusLabel} ${this.job}`);
lines.push(header);
const visibleDetailLineCount = this.rows - 1;
const visibleDetailLines = detailLines.slice(-visibleDetailLineCount);
for (const line of visibleDetailLines) {
@@ -299,12 +490,26 @@ export class SmartcliTerminalTask {
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 {
const globalObject: any = globalThis as any;
if (globalObject.process?.stdout?.write) {
return globalObject.process.stdout;
const processObject = getProcessObject();
if (processObject?.stdout?.write) {
return processObject.stdout;
}
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 {
const value = getEnvValue(nameArg);
return Boolean(value && value !== '0' && value.toLowerCase() !== 'false');
}
function getEnvValue(nameArg: string): string | undefined {
return getProcessObject()?.env?.[nameArg];
}
function getProcessObject(): any {
const globalObject: any = globalThis as any;
return globalObject.process?.env?.[nameArg];
return globalObject.process;
}
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 {
if (lineArg.length <= widthArg) {
return lineArg;