laqu
laqu is a strict TypeScript runtime for reliable terminal progress and live CLI rendering on Node.js 24+.
It treats stdout as the caller-owned data channel and sends progress, status, logs, human rendering,
and JSON/NDJSON progress events to stderr by default. The runtime keeps output format, status-stream
capability, output target, and progress policy as separate decisions instead of hiding them behind
one mode enum.
Install
bun add @0disoft/laqu
The published package targets Node.js 24+ and does not require Bun, Deno, Rust, native addons, WASM, or C++ bindings at runtime.
Scoped Tasks
import { createLaqu } from "@0disoft/laqu";
const progress = createLaqu();
await progress.task("download", { total: 100 }, async (task) => {
task.setMessage("starting");
task.advance(25);
task.setDetail("chunk 1/4");
task.advance(75);
});
await progress.close();
Scoped tasks mark themselves as succeeded when the callback resolves. If the callback throws, the task is marked failed and the original error is rethrown. If the task receives an aborted AbortSignal, it is marked cancelled and cleanup still runs.
Manual Tasks
import { createProgressRuntime } from "@0disoft/laqu";
const progress = createProgressRuntime();
const build = progress.createTask("build", { total: 3 });
build.advance(1);
build.setMessage("typecheck");
build.advance(1);
build.setMessage("bundle");
build.advance(1);
build.succeed("done");
const optional = progress.createTask("optional cache warmup");
optional.skip("already warm");
await progress.close();
The API avoids ambiguous calls such as update(42). Use setCompleted(42) for absolute progress and advance(42) for a delta.
Manual tasks also honor TaskOptions.signal; aborting the signal marks the task cancelled with the message aborted. Use task.skip(message) for intentionally skipped work such as cache hits, disabled feature branches, or already up-to-date steps.
After progress.close() starts, the runtime stops accepting new root tasks, caller logs, and manual
task handle updates. Scoped task callbacks that were already running may finish their own task tree;
unfinished manual tasks are cancelled before the final summary. Create a new runtime for later
progress output.
Logs
const progress = createLaqu();
progress.log("cache hit");
await progress.close();
Logs are separate scrollback records. They are not rendered as task rows and they pass through the same output coordinator as progress frames so live regions and log lines do not corrupt each other.
Process Lifecycle
laqu does not install process-level signal or exception handlers by default. Applications that
already own shutdown should keep the default and call progress.close() from their own cleanup
path.
Short-lived CLI commands that want laqu to flush progress output during SIGINT, SIGTERM,
uncaughtException, or unhandledRejection can opt in:
const progress = createLaqu({
manageProcessLifecycle: true,
});
Fatal process events do not wait for application work to finish. The runtime cancels unfinished
tasks, makes a best-effort terminal cleanup for up to 250 milliseconds, and then re-delivers the
original signal or exception.
Public Imports
The root import exposes the stable runtime API and common helpers:
import { createLaqu, displayWidth } from "@0disoft/laqu";
Focused subpath exports are available for narrower consumers:
import { LAQU_EVENT_SCHEMA_VERSION } from "@0disoft/laqu/events";
import { compileTheme } from "@0disoft/laqu/theme";
import { displayWidth } from "@0disoft/laqu/width";
Output Contract
By default:
- stdout is reserved for user data such as JSON, NDJSON, CSV, file lists, or binary output.
- stderr is used for progress, status, logs, and machine-readable progress events.
- human live rendering is enabled only when the status stream is a TTY and the environment is not CI.
- only one runtime owns live rendering for a stream at a time; concurrent runtimes on that same stream fall back to plain append rendering until the live owner closes.
- CI, pipe, dumb terminal, and non-TTY output fall back to plain append rendering unless a different policy is requested.
- plain append rendering preserves every task state transition and full sanitized log text;
maxRows and terminal-width truncation apply only to live rendering. Live rendering prioritizes active tasks, never exceeds the configured or available terminal rows, and redraws from current terminal dimensions when the status stream emits resize.
- JSON/NDJSON progress events do not go to stdout unless the caller explicitly passes a separate status stream that points there.
const progress = createLaqu({
format: "ndjson",
progressPolicy: "jsonl",
retention: { maxLogs: 1000, maxTerminalTasks: 1000 },
stderr: process.stderr,
});
Machine-readable progress events use a versioned schema. format: "json" writes one parseable JSON array when the runtime closes; format: "ndjson" and progressPolicy: "jsonl" write newline-delimited event objects as work progresses.
Plain and machine-readable frames are queued in order while the status stream is backpressured.
The queue is bounded to 4096 pending frames. A write exception, stream termination, drain timeout,
unsupported backpressure contract, or buffer overflow becomes a sticky LaquOutputError returned
by flush() and close(); output is never reported as successfully flushed after such a failure.
Live rendering keeps only the latest screen frame while preserving queued scrollback.
import { LaquOutputError } from "@0disoft/laqu";
try {
await progress.close();
} catch (error) {
if (error instanceof LaquOutputError) {
console.error(error.code);
}
throw error;
}
The runtime retains the newest 1000 log records and 1000 terminal task records by default so long-running commands do not keep unbounded output buffers. Set retention.maxLogs or retention.maxTerminalTasks to smaller non-negative integers when only the latest output window should be rendered or emitted. Terminal task pruning affects retained task rows and task events only after the terminal task has been snapshotted for rendering; summary events keep lifecycle counts for all tasks created by the runtime.
Task event fields such as parentId, message, and detail are omitted when they are absent.
{
"schema": "laqu.event",
"version": 1,
"type": "task",
"task": {
"id": "task-1",
"title": "download",
"status": "running",
"depth": 0,
"progress": {
"kind": "ratio",
"ratio": 0.5,
"overrun": false
}
}
}
Event schema version 1 is exported as LAQU_EVENT_SCHEMA_VERSION.
The selection axes are independent:
format: human, json, or ndjson
streamCapability: tty, ci, pipe, or dumb
- output target: stderr by default or an explicit
statusStream
progressPolicy: auto, always, never, plain, jsonl, or silent
Themes
Themes are token-first:
const progress = createLaqu({
theme: {
successSymbol: "ok",
runningSymbol: ">",
progressComplete: "=",
overflowMarker: "...",
},
});
Theme tokens are semantic: success symbols, running symbols, progress glyphs, indentation, gaps, and overflow markers. Slot-level formatting should return safe renderable segments rather than raw strings with cursor movement.
dangerouslyRawAnsi() exists as an escape hatch for callers that need raw ANSI. It can break width measurement, fallback rendering, and reset guarantees if used incorrectly, so keep it isolated.
Width And ANSI
laqu includes a pure TypeScript width engine:
import { displayWidth, truncateToColumns, wrapToColumns } from "@0disoft/laqu";
displayWidth("\u001b[31m한글\u001b[0m");
truncateToColumns("👩💻 building", 8, { overflowMarker: "..." });
wrapToColumns("abcd한글", 4);
ANSI/control sequences are tokenized as zero-width. Text is segmented by grapheme; static East Asian Width ranges handle wide/fullwidth and ambiguous characters, while Node's Unicode properties distinguish marks, default emoji presentation, VS15/VS16, keycaps, flags, and ZWJ clusters. Ambiguous width defaults to one column unless overridden.
Child Process Output
Do not mix child process output with live rendering through stdio: "inherit". Pipe child output through the parent process and write it with runtime.log(), close the runtime before handing the terminal directly to the child process, or run progress output in plain/log mode.
Development
bun install
bun run check
bun run pack:check
bun run example:basic
bun run check runs strict typecheck, OXC lint, OXC format check, Node.js built-in tests, and build output generation.
bun run pack:check builds a real package tarball, installs it into a new temporary project without registry access or lifecycle scripts, executes ESM imports, and compiles a strict TypeScript consumer with dependency declaration checks enabled.
CI runs both checks on Ubuntu, Windows, and macOS. The real PTY resize harness runs on Unix hosts; Windows runs the full stream, process-signal, renderer, and package suite without claiming ConPTY coverage.
bun run example:basic builds the package and runs a small live progress demo. Terminal scrollback keeps the final frame; watch the command while it runs to see the bar animate in place.
Release
GitHub Actions publishes npm releases from maintainer-created version tags. The tag must match package.json exactly, for example v1.1.8 for version 1.1.8.
git tag -a v1.1.8 -m "v1.1.8"
git push origin main v1.1.8
The npm package must define a Trusted Publisher connection for GitHub Actions with organization/user 0disoft, repository laqu, workflow filename release.yml, environment name npm, and npm publish allowed. The GitHub repository must also define an npm environment with required reviewers and a deployment tag rule that allows only v*.*.* tags.
On a matching tag push, the workflow first verifies the tag, package metadata, tests, build, and installed-tarball consumer checks with read-only repository permissions. The publish job then waits on the npm environment gate, repeats package verification on the tagged commit, packs the release tarball, uploads that exact tarball as a retained workflow artifact, publishes the same tarball to npm with provenance through OIDC, and creates a GitHub Release with generated notes.