@push.rocks/smartconsole

Typed console output, structured colors, tables and Markdown across backend and browser runtimes. The backend surface also provides command routing, interactive questions, and live task progress in one coordinated terminal session.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install and choose a surface

pnpm add @push.rocks/smartconsole
Import Runtime Capabilities
@push.rocks/smartconsole Node.js, Deno, Bun Output, ANSI, terminal tables, CLI, prompts, tasks
@push.rocks/smartconsole/web Browser window or worker Output, CSS console arguments, native inspection/tables, collapsed groups
@push.rocks/smartconsole/iso Backend or browser Common output, structured colors, inspection, tables, Markdown, groups
@push.rocks/smartconsole/color Any runtime Structured colors with ANSI and CSS console renderers; no other imports

/iso stays restricted even when imported on a backend. Unsupported members and options are absent from its types. JavaScript callers receive errors for unsupported options. Wrong-runtime entrypoints throw; unknown hosts are rejected. Browser bundlers must honor the browser export condition. The browser and portable browser dependency graphs contain no Node builtins or terminal prompts.

Optional peer dependencies

The package depends only on string-width and yargs-parser. Prompts and Markdown use optional peer dependencies that the application installs when it uses them:

Feature Optional peers to install
/color, output, tables, inspection, groups, CLI, tasks, TUI without Markdown none
prompts.ask(), askAll(), runQueue() inquirer
markdown() on any surface, the TUI markdown widget @push.rocks/smartmarkdown and lowlight
pnpm add inquirer                                 # prompts
pnpm add @push.rocks/smartmarkdown lowlight       # Markdown

Peers load on first use. A missing peer rejects that call with MissingPeerDependencyError (code: 'SMARTCONSOLE_MISSING_PEER', packageName, feature), naming the package to install; nothing degrades silently. A TUI whose view contains a Markdown widget rejects run() the same way after restoring the terminal. Everything else works without the peers, and no entry loads a peer when it is imported.

Browser bundles of /iso and /web include the lazily loaded Markdown module, so bundlers need @push.rocks/smartmarkdown and lowlight installed to resolve it, even when the application never calls markdown(). /color imports nothing and needs no peer.

import { SmartConsole, color } from '@push.rocks/smartconsole/iso';

const out = new SmartConsole();
await out.success(color.green('Ready'));
await out.inspect({ connected: true });
await out.markdown('# Status\n\n- [x] Connected\n- [ ] Synchronized');
await out.group('Details', async scope => {
  await scope.info('Output inside the group');
});
await out.dispose();

Common output

log, info, success, warn, error, and debug accept strings or IStyledText, with spaces between arguments. Use inspect(value) for arbitrary objects. { debug: false } suppresses debug output. Methods return promises; output is ordered and backend stream completion is awaited. flush() observes pending output and reports stream failures. dispose() drains output and releases resources; repeated disposal returns the same promise. Writes after disposal fail.

Groups serialize their callback with surrounding output. Use the supplied scope inside a group, including for nested groups. Awaiting the parent console from inside its own group would wait on that group. Scoped output exposes the common output API and becomes invalid when the callback finishes. Browser groups also accept { collapsed: true } on the browser console.

During a prompt, ordinary output is accepted into a session buffer and rendered after the prompt finishes. This allows an asynchronous validator to await logging. flush() during an active prompt throws; await the prompt before flushing.

Structured colors

import { color } from '@push.rocks/smartconsole/iso';

const label = color.concat(
  color.green('Saved'),
  ' ',
  color.text('Work account', { foreground: 'orange', bold: true }),
);
const custom = color.rgb('Custom', { r: 80, g: 160, b: 240 });
const plain = color.plain(label);

Named colors are black, blue, brown, cyan, green, orange, pink, red, and white. Styles support foreground/background colors, bold, dim, italic, underline and strikethrough. RGB channels are integers from 0 through 255.

IStyledText is serializable: { type: 'smartconsole.text', segments: [...] }. It carries text and styles, never ANSI or CSS. Control characters in user text are rendered visibly; tabs and newlines remain layout characters. A JSON transport can preserve this value and pass it back to an output method without an encoder in the transporting application.

The root additionally exposes color.toAnsi(value): string and color.toTrustedAnsi(value): string. /web additionally exposes color.toConsoleArgs(value): [string, ...string[]] for use with console.log(...args). /iso exposes neither conversion. Browser formatting uses fixed format placeholders, so percent signs in user text remain literal.

ANSI rendering writes colors on the xterm 256-color cube (every named color, and RGB values whose channels are 0, 95, 135, 175, 215 or 255) as 256-color codes, and all other RGB values as 24-bit codes. toAnsi shows control characters in the text visibly. toTrustedAnsi keeps them, for trusted text that already carries its own terminal styling, such as child-process output.

Lightweight color entry

import { color } from '@push.rocks/smartconsole/color';

console.log(color.toAnsi(color.text(' ok ', { foreground: 'green', background: 'black' })));
console.log(...color.toConsoleArgs(color.green('Ready')));

/color provides the structured color API together with toAnsi, toTrustedAnsi and toConsoleArgs as pure string functions in every runtime. It imports nothing beyond its own modules: no Node builtins, prompts, Markdown, highlighting or width tables. Libraries that only color strings, such as loggers shared between servers and browsers, use this entry. It also exports palette and the color types.

Tables

await out.table([
  { account: 'Work', usage: 25 },
  { account: 'Personal', usage: undefined },
], {
  columns: [
    { key: 'account', title: 'Account', value: row => row.account },
    { key: 'usage', title: 'Usage', value: row => row.usage == null ? undefined : `${row.usage}%` },
  ],
  missingText: 'Unavailable',
  emptyText: 'No accounts.',
});

Column order is explicit; keys and titles must be unique. Common cell values are string, number, boolean, null or undefined. Null/undefined use missingText. Native browser tables preserve numeric and boolean values. Cell styling belongs only to backend tables because native browser consoles cannot color individual table cells.

Backend tables add width, border: 'none' | 'ascii' | 'unicode', and overflow: 'wrap' | 'truncate'. Backend columns add width, align: 'left' | 'right' | 'center', and style: row => ITextStyle. Multiline cells, Unicode graphemes and display widths are handled before color encoding. Impossible explicit widths throw.

overflow: 'wrap', the default, breaks a cell's lines between words. Spaces within a line stay as written; the whitespace where a line breaks is dropped, and so is indentation that the first word does not fit beside. No-break spaces (U+00A0, U+2007, U+202F) keep their neighbours together. A word wider than its column, such as a long URL or identifier, starts a new line and then breaks between graphemes, so no line exceeds the column; text without whitespace, including CJK runs, breaks the same way. Styles stay on their characters across breaks. overflow: 'truncate' keeps what fits of each line.

Backend columns may supply render: row => TText for mixed styles within a cell; value remains its scalar representation. A table theme accepts header, border, and alternateRow text styles, and colorLine styles for grouping.

Backend tables group rows visually with the optional, independent groups.divider and groups.colorLine callbacks. Each returns a scalar key per row. Rows keep the caller's order; a group is a run of consecutive rows with equal keys, where null equals undefined. divider draws a rule between consecutive rows whose keys differ: the header rule for unicode and ascii borders, an empty line for none. colorLine draws the left edge of every line of a data row, wrapped lines included, as a line in its run's color: ┃ for unicode, | for ascii, and a two-column ┃ gutter for none. Consecutive runs take consecutive styles from theme.colorLine, an array of at least two text styles that defaults to cyan, orange, green, pink and blue foregrounds. The line follows the console's color mode, so the heavy ┃ remains visible without colors.

await out.table(limits, {
  columns: [
    { key: 'provider', title: 'Provider', value: row => row.provider },
    { key: 'account', title: 'Account', value: row => row.account },
    { key: 'limit', title: 'Limit type', value: row => row.limit },
    { key: 'used', title: 'Used %', value: row => `${row.used}%`, align: 'right' },
  ],
  groups: {
    divider: row => `${row.provider}/${row.account}`,
    colorLine: row => row.provider,
  },
});
┌─────────────┬───────────────────┬───────────────┬────────┐
│ Provider    │ Account           │ Limit type    │ Used % │
├─────────────┼───────────────────┼───────────────┼────────┤
┃ Claude Code │ alice@example.com │ Claude weekly │    73% │
┃ Claude Code │ alice@example.com │ Fable weekly  │   100% │
├─────────────┼───────────────────┼───────────────┼────────┤
┃ Claude Code │ bob@example.com   │ Claude weekly │    41% │
├─────────────┼───────────────────┼───────────────┼────────┤
┃ Codex       │ bob@example.com   │ Weekly        │    30% │
└─────────────┴───────────────────┴───────────────┴────────┘

The line is cyan beside both Claude Code accounts and orange beside Codex. The divider key includes the provider, so the same account under another provider still starts a new group.

A cell may span several lines: a value containing \n or a multiline render result keeps its line breaks, and each line wraps or truncates within the cell.

Backend columns merge cells with rowSpan: (row, rowIndex) => number and colSpan: (row, rowIndex) => number. Each returns an integer of at least 1 for the cell that starts at that row and column; the default is 1. Covered positions are neither evaluated nor rendered: their value, render, style, rowSpan and colSpan callbacks are not called. A spanning cell takes its content, style, alignment and theme.alternateRow from its starting row and column. A span that reaches past the last row or column, or that overlaps another span, throws. The header never spans.

rowSpansBy(rows, key) returns a rowSpan callback that merges each run of consecutive rows with equal keys, with the equality rule of groups: null equals undefined, and other keys compare strictly. Pass it the same rows array that the table renders. Keys are read once, when the callback is created, so render those rows unchanged and in the same order; the callback throws a RangeError for any row that is not rows[rowIndex].

The lines of a row span flow over the lines of the rows it covers. If the span needs more lines, its last row grows. A column span wraps within its columns and the separators between them. If its content is wider, the spanned columns without an explicit width grow evenly, with the remainder going to the rightmost one, before the table shrinks to its width; narrower spans grow first, so a wider span over the same columns only adds what it still lacks. Rules are drawn only across columns that no row span crosses, so a divider inside a row span becomes a partial rule, and each junction joins exactly the lines that meet there. With border: 'none', a column span includes the column gaps, and dividers remain empty lines.

import { rowSpansBy } from '@push.rocks/smartconsole';

interface ILimit { provider: string; email: string; accountType: string; limit: string; used: number; resets: string }
declare const limits: ILimit[]; // ordered by provider, then account

const account = (row: ILimit) => `${row.provider}/${row.email}`;
await out.table(limits, {
  columns: [
    { key: 'provider', title: 'Provider', value: row => row.provider, rowSpan: rowSpansBy(limits, row => row.provider) },
    { key: 'account', title: 'Account', value: row => row.email, render: row => `${row.email}\n${row.accountType}`, rowSpan: rowSpansBy(limits, account) },
    { key: 'limit', title: 'Limit type', value: row => row.limit },
    { key: 'used', title: 'Used %', value: row => `${row.used}%`, align: 'right' },
    { key: 'resets', title: 'Resets in', value: row => row.resets, align: 'right' },
  ],
  groups: { divider: account, colorLine: row => row.provider },
});
┌─────────────┬───────────────────┬──────────────────┬────────┬───────────┐
│ Provider    │ Account           │ Limit type       │ Used % │ Resets in │
├─────────────┼───────────────────┼──────────────────┼────────┼───────────┤
┃ Claude Code │ alice@example.com │ Claude weekly    │    73% │     2d 4h │
┃             │ Max               │ Claude five-hour │     5% │    3h 12m │
┃             │                   │ Fable weekly     │   100% │     5d 1h │
┃             ├───────────────────┼──────────────────┼────────┼───────────┤
┃             │ bob@example.com   │ Claude weekly    │    41% │     6d 2h │
┃             │ Pro               │ Claude five-hour │    88% │       47m │
├─────────────┼───────────────────┼──────────────────┼────────┼───────────┤
┃ Codex       │ bob@example.com   │ Codex primary    │   100% │        2d │
┃             │ Plus              │ Codex secondary  │     0% │    6d 23h │
└─────────────┴───────────────────┴──────────────────┴────────┴───────────┘

Each provider spans all of its rows, and each account spans its rows with a two-line cell. The divider between the two Claude Code accounts starts at the Account column because the Provider span crosses it. There, the color line continues along the left edge. The provider change draws a full rule.

A column span across the whole table forms a section row. Here a divider separates each section row from the rows around it. The covered limit and used columns are never evaluated for section rows; their cell wrapper only satisfies the row type.

type TRow = { section: string } | ILimit;
const rows: TRow[] = [{ section: 'Claude Code — 2 accounts' }, limits[0], limits[1], { section: 'Codex — 1 account' }, limits[5]];
const cell = (read: (row: ILimit) => string) => (row: TRow) => 'section' in row ? row.section : read(row);
await out.table(rows, {
  columns: [
    { key: 'account', title: 'Account', value: cell(row => row.email), colSpan: row => 'section' in row ? 3 : 1 },
    { key: 'limit', title: 'Limit type', value: cell(row => row.limit) },
    { key: 'used', title: 'Used %', value: cell(row => `${row.used}%`), align: 'right' },
  ],
  groups: { divider: row => 'section' in row ? row.section : 'limit' },
});
┌───────────────────┬──────────────────┬────────┐
│ Account           │ Limit type       │ Used % │
├───────────────────┴──────────────────┴────────┤
│ Claude Code — 2 accounts                      │
├───────────────────┬──────────────────┬────────┤
│ alice@example.com │ Claude weekly    │    73% │
│ alice@example.com │ Claude five-hour │     5% │
├───────────────────┴──────────────────┴────────┤
│ Codex — 1 account                             │
├───────────────────┬──────────────────┬────────┤
│ bob@example.com   │ Codex primary    │   100% │
└───────────────────┴──────────────────┴────────┘

All backend table options are rejected by /web and /iso, whose tables retain native scalar cells.

Markdown and inspection

Markdown needs the optional peers @push.rocks/smartmarkdown and lowlight. It is parsed through @push.rocks/smartmarkdown/iso. CommonMark/GFM headings, paragraphs, emphasis, links, lists, task lists, quotes, code blocks, tables, references and footnotes are rendered as console text. Frontmatter is omitted. headingPrefix: false hides heading markers. Backend Markdown also accepts width; browser Markdown tables use formatted lines to preserve styles. Within that width, text and Markdown table cells wrap between words like backend table cells, and code blocks wrap between graphemes so every space is kept. HTML and code stay inert, images become alt text plus URL, and destinations are never fetched. This package does not render a DOM terminal or execute Markdown.

Code fences with a recognized language use Lowlight's common grammars for syntax highlighting. Unknown or omitted languages retain their code text without syntax coloring. Set highlight: false to disable highlighting. All three surfaces accept a Markdown theme with heading, code, marker styles and tokens keyed by Highlight.js token names such as keyword, string, number, and comment.

Backend inspection accepts { depth, showHidden }; browser inspection passes the original object to native console inspection. Portable inspection uses each host's normal object representation without platform-specific options.

Backend terminal options

import { SmartConsole } from '@push.rocks/smartconsole';

const out = new SmartConsole({
  colors: 'auto',
  interactive: 'auto',
  symbols: 'auto',
});

Backend options can inject Node-compatible input, output, and errorOutput streams. Defaults are stdin, stdout and stderr. Color modes are auto, always and never; interaction modes use the same names. Automatic rendering produces plain output and append-only task updates without a capable TTY. Forced color or interactive rendering on an incapable output throws. Automatic color respects NO_COLOR; automatic interaction respects CI and TERM=dumb. Task symbols can be auto, unicode, or ascii.

Consoles sharing an output stream share its session and must agree on explicit terminal settings. The session owns write ordering, stream backpressure, task redraws and prompt arbitration. Disposal releases its resources without closing caller-owned streams or terminating the application.

CLI commands

const out = new SmartConsole();
out.cli.configure({ name: 'accounts', version: '1.0.0', description: 'Manage accounts' });
out.cli.command({
  name: 'list', aliases: ['ls'], description: 'Show accounts',
  options: { limit: { type: 'number', default: 10, aliases: ['n'] } },
}, async ({ options, args }) => {
  await out.log(`Listing up to ${options.limit} accounts`, ...args);
});
out.cli.default({}, async () => { await out.log('Choose a command with --help.'); });

await out.cli.run(); // Runtime user arguments, including Deno compiled binaries.
await out.dispose();

run(argv) accepts user arguments only, without executable or script paths; runtimeArguments() returns them for Node.js, Bun, deno run and Deno compiled binaries. Command options follow the command. Leading options belong to the default command. help [command] and --help/-h print help for every command that is not a passthrough command. --version/-v print the configured version only without a command, so commands may declare version and v options; the default command may declare them only while no version is configured. Without a configured version they are ordinary option names.

Unknown commands, unknown options, missing required values and invalid values reject with CliUsageError: reason is unknown-command, missing-command, unknown-option, missing-option or invalid-option, with command, option and the registered commands. Handler failures reject with their own error. Registration mistakes throw TypeError.

Option types are string, number, boolean, and strings, with aliases, defaults, required flags and descriptions. Kebab-case and camel-case spellings name the same option: dryRun accepts --dry-run and --dryRun, and --no-dry-run negates a boolean; --dry_run is not a spelling of it and rejects with unknown-option (with allowUnknownOptions it is forwarded as an unknown key). Spellings keep their case: an alias F is only -F and an option URL is only --URL, so -f and --url are unknown options. A declared boolean accepts exactly these forms, through every spelling and alias: --dry-run (true), --no-dry-run (false), --dry-run=true and --dry-run=false (-d=true for a short alias), and --dry-run true or --dry-run false, where the following argument is consumed only when it is exactly true or false; any other following argument stays a positional argument or the next option. Values are case-sensitive. Any other value written into the option (--dry-run=yes, --dry-run=1, --dry-run=, --dry-run=TRUE, -d1, --no-dry-run=true) rejects with invalid-option. Repeated strings values and aliases accumulate. Declared string options keep values such as 0123 verbatim.

The handler receives typed options, positional args, and parsed argv (including the command in _[0]). allowUnknownOptions: true forwards undeclared options in argv, parsed with the yargs-parser defaults: values, booleans, numbers, --no- negation, short groups (-abc), repeated options as arrays, camel-case copies of kebab-case keys, and arguments after -- as positionals. Positional arguments always stay strings and dotted keys stay literal. passthrough: true hands every token after the command verbatim to args without parsing options, help or version; passthrough commands declare no options. Use it for commands that forward arguments to another program.

dispatch(command, argv) invokes a registered handler programmatically, for example from the default command. renderHelp(command?) returns the help text. configure({ helpText }) sets the complete help text that every help request prints instead of the generated help. Each command has one awaited handler. onEvent(listener) provides observational start/finish/error events and returns an unsubscribe function; observers never control command completion. takeObserverErrors() reports observer failures.

import { SmartConsole, CliUsageError } from '@push.rocks/smartconsole';

const out = new SmartConsole();
out.cli.configure({ version: '1.0.0', helpText: 'Usage: tool <build|compile>' });
out.cli.command({ name: 'build', options: { dryRun: { type: 'boolean' } } }, ({ options }) => build(options.dryRun));
out.cli.command({ name: 'compile', passthrough: true }, ({ args }) => runCompiler(args));
out.cli.default({}, async () => { await out.log(out.cli.renderHelp()); });
try { await out.cli.run(); }
catch (error) {
  await out.error(error instanceof Error ? error.message : String(error));
  if (error instanceof CliUsageError) await out.error(out.cli.renderHelp());
  process.exitCode = 1;
}
finally { await out.dispose(); }

Interactive questions

Prompts need the optional peer inquirer.

const account = await out.prompts.ask({
  type: 'list', name: 'account', message: 'Choose an account',
  choices: [{ name: 'Work', value: 'work' }, { name: 'Personal', value: 'personal' }],
});
const save = await out.prompts.ask({ type: 'confirm', name: 'save', message: 'Save this account?', default: true });

Supported types: input, confirm, list, rawlist, expand, checkbox, password, and editor. ask() returns the typed answer directly. Choices have name, value, optional disabled, and an expand-only unique key (h is reserved). Passwords accept mask; editors accept waitForUserInput. Editor prompts use the user's editor and a disposable temporary file; Deno requires read/write/env/run permissions for this operation.

Validators may return a boolean, an error message, or a promise of either. askAll(questions, options) returns answers keyed by question name. add(questions) and runQueue(options) support incrementally assembled queues. Names must be unique within a queue. A failed question rejects the queue.

Prompt options accept signal for cancellation and an explicit nonInteractive policy. The default is { mode: 'error' }. { mode: 'defaults' } uses only declared defaults; { mode: 'answers', answers: { save: true } } uses only supplied answers. Missing or invalid values fail; environment variables never silently approve a question. Prompts serialize across consoles sharing the terminal, pause live tasks, and reject with PromptCancelledError on cancellation. Disposal cancels active and pending prompts and restores input ownership.

Tasks and progress

const parent = out.tasks.create({ job: 'Synchronize' });
const child = parent.create({ job: 'Read accounts', showTimer: true });
await child.run(async task => {
  await task.setProgress(1, 2);
  await task.log('First account read');
  await task.setProgress(2, 2);
}, { successMessage: 'Accounts read' });
await parent.complete('Ready');
await out.dispose();

Tasks expose status, elapsed time, progress, logs and errors. Use log/update, setProgress, setTimerEnabled, setSpinnerEnabled, complete, fail, or attachError(error, { keepOpen: true }). run() awaits work, completes on success, and records and rethrows failures; errorKeepOpen keeps a failed operation visible as an active task. Complete children before completing their parent. Failing a parent fails its running children.

Options include rows, logLimit, showTimer, showSpinner, spinnerFrames, and spinnerIntervalMs. Progress requires 0 <= current <= total and a positive total. tasks.clear() stops rendering and invalidates existing handles. Finished or invalidated tasks reject further updates. Timers are stopped when unnecessary and on disposal; they do not keep a backend process alive by themselves.

Terminal user interfaces

The backend out.tui provides composable screens. /web and /iso expose no TUI members or types. Screens require interactive TTY input/output and raw input; starting a screen in a pipe, CI, or an incapable terminal throws.

const table = out.tui.table({
  rows: [{ id: 'work', usage: 25 }, { id: 'personal', usage: 80 }],
  rowKey: row => row.id,
  columns: [
    { key: 'id', title: 'Account', value: row => row.id },
    { key: 'usage', title: 'Usage', value: row => row.usage },
  ],
  onActivate: async (row, screen) => {
    if (await screen.confirm(`Use ${row.id}?`)) await activateAccount(row.id);
  },
});
await out.tui.run({
  view: out.tui.column([
    out.tui.text('Accounts', { height: 1 }),
    out.tui.panel('Saved accounts', table),
    out.tui.text('Tab: focus · Enter: activate · q: quit', { height: 1 }),
  ]),
  keys: { q: screen => screen.close() },
});
await out.dispose();

Create stateful widgets once and reuse them across renders. view can also be a function returning the current layout. Call screen.invalidate() after external updates such as table.setRows(rows) or text.setText(value); keyboard actions and terminal resizing redraw automatically. Changed terminal rows alone are written. Rows keep selection by their unique rowKey, including after refresh. Table options accept selectedKey to select an initial row without changing row order. It must identify an existing initial row; unknown keys throw. Omitting it selects the first row. Initial selection does not invoke onSelect.

Widget factories:

Factory Behavior
text(value, size?), viewer(value, size?) Styled text wrapped between words; viewer adds scrolling
markdown(source, options?, size?) Scrollable themed Markdown
table(options) Typed rows, rich columns, sorting, filtering, selection
row(children, size?), column(children, size?) Horizontal/vertical layout
panel(title, content, size?) Bordered titled panel
tabs([{title, content}], size?) Switchable content; arrows on the tab header
tree(nodes, onSelect?, size?) Expandable nodes with unique IDs and styled labels
input(options) Editable text/password, grapheme-aware cursor, validation
checkbox(label, checked?, size?) Boolean control
button(label, action, size?) Awaited action
form(fields, submit, label?, size?) Validates input fields before submission
progress(label, current, total, size?) Progress bar with setProgress()
logs Bounded scrollable log widget populated by console output

Sizes accept positive integer width (at least 2), height, and flex. Flexible children share remaining space. Terminal sizes that cannot fit the view show an explicit resize message and keep the screen available for resizing or quitting. TUI tables use single-line cells with truncation and keep the column header visible while scrolling. Use static out.table() for multiline cells and spans.

Tab/Shift-Tab moves focus. Tables support arrows, Home/End, PageUp/PageDown, Enter, / to edit a filter, s to cycle sort columns, and S to reverse sorting. Escape clears a filter; Enter keeps it. Tables expose selected, setRows, setFilter and sort(columnKey, descending?). A one-column table also serves as a selection list. Trees use Left/Right to collapse/expand and Enter to activate. Viewers scroll with arrows, Home/End and PageUp/PageDown.

run accepts theme (border, heading, selected, muted), signal, keys, and an awaited onReady(screen) callback. Shortcuts use keys such as q, ctrl+r, or alt+r; plain character shortcuts do not steal input while a text field or table filter is being edited. Ctrl-C closes the screen. The context provides close(), invalidate(), focus(widget), signal, and confirm(message, {confirmLabel?, cancelLabel?}). Confirmations default to Cancel; Left/Right or Tab changes the choice, Enter accepts it, and Escape cancels it.

Actions are awaited; further management keystrokes are ignored during an action. Confirmation dialogs keep accepting input while the action awaits their answer. Closing aborts the context signal and waits for running callbacks to finish, so applications can complete necessary cleanup. A callback must not await disposal of its own console. Errors reject run() after terminal restoration.

The terminal session arbitrates screens and prompts, pauses task redraws, and buffers ordinary output while a screen is open. Use TUI dialogs inside a screen; ordinary prompts are rejected. flush() is unavailable until the screen closes. The latest 1000 buffered log entries are replayed afterward, with an explicit discard count if exceeded; the logs widget retains 500 entries. Raw mode, input listeners and the alternate screen are restored on close, abort, input EOF and callback failure. Caller-owned streams remain open. Applications may pass an AbortSignal to integrate their own process signal handling.

Migration

This package owns the functionality previously in @push.rocks/smartcli, @push.rocks/smartinteract and @push.rocks/consolecolor; it has no runtime dependency on those packages or on smartlog. Logging transports remain the responsibility of smartlog.

Migration from @push.rocks/smartcli

The CLI needs no optional peer.

smartcli smartconsole (root entry, const out = new SmartConsole())
new Smartcli() out.cli
addCommand('name').subscribe(handler) out.cli.command({ name: 'name', options }, handler); one awaited handler per command
addCommand('a') and addCommand('b') with one handler out.cli.command({ name: 'a', aliases: ['b'] }, handler)
addCommandAlias('name', 'alias') aliases: ['alias'] on the command
standardCommand().subscribe(handler) out.cli.default({ options }, handler)
startParse() await out.cli.run() (uses runtimeArguments())
startParse(process.argv) (test argv) await out.cli.run(argv.slice(2)): user arguments only
parseCompleted the promise returned by run()
handler argument argvArg context.argv, plus typed context.options and positional context.args
argvArg._[0] command, argvArg._.slice(1) context.command, context.args
undeclared argvArg.someFlag declare the option (options: { someFlag: { type: 'string' } }) or allowUnknownOptions: true and read context.argv.someFlag
process.argv.slice(3) for forwarded arguments passthrough: true and context.args
addVersion(v), smartcli.version = v out.cli.configure({ version: v })
addHelp({ helpText }), addCommand('help') out.cli.configure({ helpText }); help is reserved
argvArg.help || argvArg.h checks built in: --help/-h print the help
triggerCommand('name', argv) await out.cli.dispatch('name', context.argv)
triggerCommand('help', argv) await out.log(out.cli.renderHelp())
unknownCommand().subscribe(...) catch CliUsageError with reason === 'unknown-command'
SmartcliUnknownCommandError (commandName, registeredCommands) CliUsageError (command, commands)
getRegisteredCommandNames() CliUsageError.commands
getCommandSubject(name) none: register the handler with command()
getUserArgs() runtimeArguments()
SmartcliTerminal / createTask(...) / task(job) out.tasks.create({ job, ... })
task log, update, setProgress, setTimerEnabled, setSpinnerEnabled, run, complete, fail, attachError same names on out.tasks handles; they return promises
SmartcliTerminal clear() out.tasks.clear()
task getLogLines(), getErrorLines(), getElapsedText(), status logs, errors, elapsedMs, status

Behavior differences:

  • Handlers are awaited. run() rejects with CliUsageError for unknown commands and options or missing or invalid values, and with the handler's error when it fails. smartcli printed unknown commands itself and set process.exitCode = 1; the caller now reports the error and sets the exit code.
  • Undeclared options reject unless the command sets allowUnknownOptions: true. Declared options parse by type, so a boolean consumes the next argument only when it is exactly true or false and rejects other explicit values such as --flag=yes, and a string keeps 0123.
  • Positional arguments are strings (smartcli turned 42 into a number) and dotted flags such as --a.b stay literal keys (smartcli built nested objects).
  • --help/-h are handled by smartconsole for every non-passthrough command. --version/-v are handled only without a command, as in smartcli.
  • smartcli ignored addCommandAlias; smartconsole aliases work.

Intentionally not ported, with no use in any active consumer: getOption(name) (reparsed process.argv outside a command), getCommandSubject(name) and observable subscriptions with multiple subscribers per command, and from SmartcliTerminal the createProcess alias, the timer/spinner option aliases, cleanup (process signal handlers) and nonInteractiveThrottleMs.

Migration from @push.rocks/smartinteract

smartinteract smartconsole
dependency @push.rocks/smartinteract dependency @push.rocks/smartconsole plus the optional peer inquirer
new SmartInteract() out.prompts
askQuestion(question) resolving { name, value } await out.prompts.ask(question) resolving the value
SmartInteract.getCliConfirmation(message, default) await out.prompts.ask({ type: 'confirm', name: 'confirm', message, default })
new SmartInteract(questions), addQuestions(questions) out.prompts.add(questions)
runQueue() resolving an AnswerBucket await out.prompts.runQueue() resolving { [name]: value }
answerBucket.getAnswerFor(name) (null when missing) answers[name] (undefined when missing)
answerBucket.getAllAnswers() Object.entries(answers)
new AnswerBucket(), addAnswer({ name, value }) a plain Record<string, unknown>
choices: ['a', 'b'] choices: [{ name: 'a', value: 'a' }, { name: 'b', value: 'b' }]
questionType, IQuestionObject, IChoiceObject, IAnswerObject TQuestion, the question interfaces, IPromptChoice, the answer value
no answers in CI (process.env.CI set): the default is used pass { nonInteractive: { mode: 'defaults' } }; the default policy rejects without a terminal

Question types, defaults (including checkbox default values), validators and names containing dots behave as before. smartinteract prompted on a piped standard input outside CI; smartconsole applies the noninteractive policy whenever input or output is not an interactive terminal. smartinteract logged prompt failures and never settled; smartconsole rejects, with PromptCancelledError on cancellation.

Migration from @push.rocks/consolecolor

/color needs no optional peer and imports no other module.

consolecolor smartconsole
import { coloredString } from '@push.rocks/consolecolor' import { color } from '@push.rocks/smartconsole/color'
coloredString(text, 'green') color.toAnsi(color.green(text))
coloredString(text, fg, bg) color.toAnsi(color.text(text, { foreground: fg, background: bg }))
coloring text that already contains escape sequences color.toTrustedAnsi(...) instead of color.toAnsi(...)
TColorName TColorName (same nine names)
IRGB IRgb with 0-255 channels (color.rgb(text, { r, g, b }))

Named colors produce the same xterm 256-color codes as consolecolor. The foreground and background codes are combined into one escape sequence, and toAnsi shows control characters inside the text visibly.

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
Typed console output, tables, Markdown and backend interaction.
Readme
966 KiB
Languages
TypeScript 100%