What a terminal shows a person, with a plain fallback off a terminal: colour, status marks, the time something took, next steps and a progress line for anything slow. Colour is on only for a terminal; NO_COLOR always turns it off, and FORCE_COLOR turns it on for a capture. Off a terminal, the marks are words and nothing is redrawn, so a pipe, a log or an agent gets plain text with no escape codes.
npx shadcn@latest add ourostack/ouro-ui/ax-terminal#v1.3.10It installs lib/ouro/ax/terminal.ts, with ax-outcome, ax-voice. Its tests are ax-terminal-tests. Every item runs on Node 22.18 or later as it is copied: the files import each other as ./<file>.ts. The Ouro AX guide shows how they fit together.
Exports
createProgress
Progress for anything slow, on stderr. On a terminal, a spinner line with the step, its place and the time so far, drawn only once a step has run past a short delay, redrawn in place and wiped when it ends, with spaces rather than escape codes. Off a terminal or in CI, one plain line for a step still running after the slow mark, and nothing else. With off, nothing.
function createProgress(stream: ProgressStream, options: ProgressOptions): ProgressFRAMES
The spinner's frames, on a terminal.
const FRAMES: readonly string[]hasAnsi
Whether text holds an escape character: what the X-ray looks for in piped output.
function hasAnsi(text: string): booleanMark
type Mark = "passed" | "failed" | "errored" | "warned" | "skipped" | "planned";markOf
A state's mark: its symbol in its colour on a terminal, else its word, so a reader without colour still tells a failure from a pass.
function markOf(state: Mark, style: TerminalStyle): stringMARKS
A state's mark, as a symbol on a terminal and as a word elsewhere, and its colour.
const MARKS: Readonly<Record<Mark, { symbol: string; word: string; color: keyof Paint }>>nextStepsText
The next steps, under a Next heading: each step's reason, then its command on the line below, indented and alone, so it can be copied whole. An empty string for an empty list.
function nextStepsText(steps: readonly NextStep[], style: TerminalStyle): stringpaint
The colours, as functions that wrap text in their escape codes when the style has colour, and return it unchanged when it doesn't.
function paint(style: Pick<TerminalStyle, "color">): PaintPaint
type Paint = Record<"bold" | "dim" | "red" | "green" | "yellow" | "cyan", (text: string) => string>;Progress
One running step: start it, then stop it when it ends.
interface Progress {
start(label: string, at?: { index: number; total: number }): void;
stop(): void;
}ProgressOptions
interface ProgressOptions {
env: Readonly<Record<string, string | undefined>>;
off?: boolean;
slowMs?: number;
delayMs?: number;
frameMs?: number;
timers?: Timers;
}ProgressStream
Where progress goes: stderr, so stdout keeps only the result.
interface ProgressStream {
write(text: string): unknown;
isTTY?: boolean;
}stripAnsi
The text with its ANSI escape sequences taken out.
function stripAnsi(text: string): stringterminalStyle
The style for one stream: a terminal gets colour and symbols; a pipe, or a terminal in CI (the CI variable set), gets words and no colour. NO_COLOR (set and not empty) always turns colour off, and FORCE_COLOR or CLICOLOR_FORCE turns colour and symbols on off a terminal and in CI, for a screenshot or a capture.
function terminalStyle(stream: { isTTY?: boolean | undefined; }, env: Readonly<Record<string, string | undefined>>): TerminalStyleTerminalStyle
What a terminal shows a person, with a plain fallback off a terminal: colour, status marks, the time something took, next steps and a progress line for anything slow. Colour is on only for a terminal; NO_COLOR always turns it off, and FORCE_COLOR turns it on for a capture. Off a terminal, the marks are words and nothing is redrawn, so a pipe, a log or an agent gets plain text with no escape codes.
interface TerminalStyle {
tty: boolean;
color: boolean;
symbols: boolean;
}Timers
The clock and timers progress uses; tests pass held ones.
interface Timers {
now(): number;
setTimeout(fn: () => void, ms: number): unknown;
clearTimeout(handle: unknown): void;
setInterval(fn: () => void, ms: number): unknown;
clearInterval(handle: unknown): void;
}timeText
How long something took, for a person: tenths of a second under a minute (0.4s, 14.2s), then minutes and seconds (1m 05s).
function timeText(ms: number): string