@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 withCliUsageErrorfor unknown commands and options or missing or invalid values, and with the handler's error when it fails. smartcli printed unknown commands itself and setprocess.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 exactlytrueorfalseand rejects other explicit values such as--flag=yes, and a string keeps0123. - Positional arguments are strings (smartcli turned
42into a number) and dotted flags such as--a.bstay literal keys (smartcli built nested objects). --help/-hare handled by smartconsole for every non-passthrough command.--version/-vare 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.
License and Legal Information
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.