@retrovm/terminal
v0.3.0
Published
Fluent ANSI terminal library — 24-bit color, cursor control and screen management for Bun and Node.js
Downloads
64
Maintainers
Readme
@retrovm/terminal
A fluent ANSI terminal library for Bun (and Node.js). Wraps any object with a write(string) method and exposes a chainable API for text output, 24-bit color, cursor control, and screen management.
Depends on @retrovm/color (bundled — no extra install needed).
Features
- Fluent API — every method returns
this, so calls chain naturally - 24-bit RGB color — foreground and background via hex strings,
rgb(), or@retrovm/colorinstances - Named color shortcuts —
term.red(...),term.bgBlue(...)etc., generated automatically from theColorregistry - Full cursor control — move, save/restore, show/hide
- Screen management — clear, alternate buffer, scroll region, auto-wrap
- Buffered output — coalesce thousands of small writes into a single
write()call withbuffer()/flush() - Synchronized output — wrap redraws in
sync()for tear-free animation via DEC mode 2026 - Pixel framebuffer — render a
Uint32Arraypixel buffer directly to the terminal using the half-block trick (framebuffer()) - Sink-agnostic — works with Bun's stdout/stderr writers, Node.js
Writables, xterm.js terminals, or any test double NO_COLORsupport — respects the no-color.org convention out of the box- TypeScript-first — strict types with full autocompletion for all color methods
Installation
bun add @retrovm/terminal # or: npm install @retrovm/terminalQuick start
Bun
import { Terminal } from '@retrovm/terminal'
const term = Terminal.out // pre-wired to Bun.stdout
term.red('Hello ').bgBlue(' world ').reset().println()Terminal.out and Terminal.err are ready-made instances wired to Bun.stdout and Bun.stderr. Both are ITerminal | undefined — use Terminal.out! if you know stdout is available.
Node.js
import { Terminal } from '@retrovm/terminal'
const term = Terminal.out // pre-wired to process.stdout
term.red('Hello ').bgBlue(' world ').reset().println()Terminal.out and Terminal.err are pre-wired to process.stdout and process.stderr via the "node" export condition — no configuration needed.
Any sink (xterm.js, custom, test)
import { createTerminal } from '@retrovm/terminal'
// xterm.js
import { Terminal as XTerm } from '@xterm/xterm'
const xterm = new XTerm()
xterm.open(document.getElementById('term')!)
const term = createTerminal(xterm)
// Custom / test
const lines: string[] = []
const term = createTerminal({ write: s => lines.push(s) })The only requirement is an object satisfying ITerminalWriter — { write(data: string): void }.
API reference
All methods return this (the ITerminal instance) unless noted otherwise.
Text output
print(fmt?, ...args)
Writes formatted text. Uses util.format semantics (%s, %d, %o, …).
term.print('Value: %d', 42) // "Value: 42"
term.print('Hello, %s!', 'world') // "Hello, world!"println(fmt?, ...args)
Same as print but appends \r\n.
term.println('Line 1').println('Line 2')Color — foreground
ink(color, fmt?, ...args)
Sets the foreground color and optionally writes formatted text. Resets nothing — the color persists until changed or reset.
term.ink('#ff6600', 'Orange text')
term.ink('rgb(100, 200, 50)', 'Custom green')
term.ink(new Color('#3399ff'), 'Color instance')
term.ink('#aaaaaa') // set color only, write nothingresetText(fmt?, ...args)
Resets all text attributes (color, background, bold, dim, italic, underline, …) without touching the cursor or alternate screen.
term.red('colored').resetText(' — plain again')reset(fmt?, ...args)
Full teardown: shows the cursor, exits the alternate screen buffer, and resets all text attributes. Use this as the last call in a TUI lifecycle.
term.red('warning').reset(' — back to normal')resetInk(fmt?, ...args)
Resets only the foreground color (leaves background intact).
Named color shortcuts
Every named color in the Color registry gets an auto-generated method. Call them with or without text:
term.red('error')
term.lime('success')
term.cyan() // set color only
term.white('value: ').yellow('%d', n).reset()The full list depends on the @retrovm/color version. All are fully typed and appear in autocomplete.
Color — background
paper(color, fmt?, ...args)
Sets the background color and optionally writes formatted text.
term.paper('#1a1a2e', ' dark bg ')
term.paper('rgb(30, 30, 30)') // set onlyresetPaper(fmt?, ...args)
Resets only the background color.
Named background shortcuts
Same as foreground but prefixed with bg:
term.bgRed(' error ')
term.bgBlue(' info ').white(' message ').reset()Cursor movement
All movement methods accept an optional n (default 1).
| Method | ANSI | Description |
|---|---|---|
| up(n?) | ESC[nA | Move cursor up n rows |
| down(n?) | ESC[nB | Move cursor down n rows |
| right(n?) | ESC[nC | Move cursor right n columns |
| left(n?) | ESC[nD | Move cursor left n columns |
| column(n?) | ESC[nG | Move to column n (1-based) |
| row(n?) | ESC[nd | Move to row n (1-based) |
| moveTo(row, col) | ESC[row;colH | Move to absolute position (both 1-based) |
| saveCursor() | ESC[s | Save current cursor position |
| restoreCursor() | ESC[u | Restore previously saved position |
term.moveTo(5, 1).print('Row 5, col 1')
term.saveCursor().moveTo(1, 1).print('top').restoreCursor()
term.column(1).print('back to start of line')cursor(visible?)
Shows or hides the cursor.
term.cursor(false) // hide
term.cursor(true) // show (default)Screen
cls()
Clears the entire screen and moves the cursor to the home position (row 1, col 1).
term.cls()clearLine()
Clears from the cursor to the end of the current line.
term.column(20).clearLine() // clear everything after col 20alt(enable?)
Enables or disables the alternate screen buffer. The alternate buffer is a separate screen area — the original content is preserved and restored when you switch back. Essential for full-screen TUI applications.
term.alt(true).cursor(false).cls()
// ... full-screen UI ...
term.cursor(true).alt(false)autoWrap(enable?)
Enables or disables DECAWM (auto-wrap mode). When disabled, output past the last column stays at the last column instead of wrapping to the next line.
term.autoWrap(false)scrollRegion(top, bottom)
Sets the scrolling region to rows top–bottom (both 1-based, inclusive). Only that region scrolls; content above and below is pinned.
term.scrollRegion(3, 20) // only rows 3–20 scroll
term.scrollRegion(1, process.stdout.rows) // reset to full screenBuffered output
buffer()
Enters buffered mode. All subsequent emissions accumulate in memory instead of being written immediately. Calling buffer() while already buffered is a no-op.
flush()
Commits the buffer in a single write() call and exits buffered mode. Calling flush() when not buffered is a no-op.
term.buffer()
for (let i = 0; i < 1000; i++) term.moveTo(i % 24 + 1, 1).print('row %d', i)
term.flush() // one syscall instead of thousandsraw(data)
Writes a pre-built string directly, bypassing util.format. Useful in hot loops where the caller has already constructed the exact bytes to emit.
const seq = '\x1b[38;2;255;128;0m' // precomputed orange
term.raw(seq).raw('hot loop text').resetText()Synchronized output
sync(fn)
Runs fn inside a synchronized-output block (DEC private mode 2026). The terminal accumulates all changes and presents them atomically when the block ends, eliminating tearing in TUIs and animations. Terminals that don't support mode 2026 ignore the escape silently.
Automatically enables buffered mode for the duration of the block. Re-entrant: nested sync() calls share the outermost block.
out.sync(() => {
for (let row = 0; row < H; row++) {
for (let col = 0; col < W; col++) {
out.moveTo(row + 1, col + 1).paper(bg).ink(fg).raw('▄')
}
}
})Pixel graphics
framebuffer(fb, w, h)
Renders a raw pixel buffer to the terminal using the half-block trick. Each pair of vertically adjacent pixels is encoded as a single ▄ character: the upper pixel becomes the background color and the lower pixel the foreground color, doubling the effective vertical resolution.
fb—Uint32Arrayof packed pixels. Format: little-endian RGBA (0xAABBGGRR), alpha ignored. MatchesCanvas.getImageDataoutput on little-endian systems.w— Width in pixels (terminal columns).h— Height in pixels. Must be even.
The entire frame is assembled into one string before writing; the write is wrapped in sync().
const W = out.width ?? 80
const H = (out.height ?? 24) * 2 // 2 pixel rows per character row
const fb = new Uint32Array(W * H)
// fill fb with 0xAABBGGRR pixels...
fb[0] = 0xFF0000FF // red pixel at (0, 0)
out.framebuffer(fb, W, H)Dimensions
width (getter)
Current width of the terminal in columns, as reported by the writer. Returns -1 if the writer does not implement width(). Always reflects the current size — read on every access, so it stays correct after a resize.
height (getter)
Current height of the terminal in rows, as reported by the writer. Returns -1 if the writer does not implement height(). Same live semantics as width.
The built-in Terminal.out and Terminal.err instances always provide both — wired to process.stdout.columns / process.stdout.rows in both Bun and Node.
Custom sinks can opt in by implementing the optional width and height functions on ITerminalWriter:
const term = createTerminal({
write: s => myCanvas.write(s),
width: () => myCanvas.cols,
height: () => myCanvas.rows,
})
term.width // myCanvas.cols, live
term.height // myCanvas.rows, liveOptions
plain
Boolean property. When true, all ANSI escape sequences are suppressed and only plain text is emitted. Automatically set to true if NO_COLOR is present in the environment.
const term = createTerminal(writer, { plain: true })
term.plain = false // can be toggled at runtimeColors (@retrovm/color)
@retrovm/color is a bundled dependency — it ships with this package, nothing extra to install. The Color class integrates directly with ink() and paper():
import { Color } from '@retrovm/color'
// Named colors (also available as term.red, term.bgBlue, etc.)
term.ink(Color.red, 'red text')
// HSV — cycle through the color wheel
term.ink(Color.fromHSV(0.6, 1, 1), 'blue') // hue 0=red, 0.33=green, 0.66=blue
// Gradient across a string
const str = 'Hello, world!'
for (let i = 0; i < str.length; i++) {
term.ink(Color.fromHSV(i / str.length, 1, 1), str[i])
}
term.reset()
// Interpolate between two hues
const H1 = 0.6, H2 = 0.38 // blue → cyan → green
for (let i = 0; i < 40; i++) {
term.ink(Color.fromHSV(H1 + (H2 - H1) * (i / 40), 1, 1), '█')
}Named shortcuts (term.red, term.bgBlue, …) are built from the static Color properties at construction time, so adding a color to the Color class automatically exposes it as a method — no changes needed here.
Package exports
{
"exports": {
".": {
"bun": "./src/bun.ts",
"node": { "types": "./dist/node.d.ts", "default": "./dist/node.js" },
"types": "./dist/node.d.ts",
"import": "./dist/terminal.js"
}
}
}| Condition | Resolved to | Used by |
|---|---|---|
| bun | src/bun.ts | Bun runtime — TS source, Terminal.out/err via Bun.stdout/stderr |
| node | dist/node.js + dist/node.d.ts | Node.js — Terminal.out/err via process.stdout/stderr |
| types | dist/node.d.ts | TypeScript language server (VSCode, tsc) in other environments |
| import | dist/terminal.js | Bundlers, ESM environments without a specific condition |
Building
The package distributes TypeScript source for Bun and compiled output for Node.js. To build:
bun run buildThis runs two steps:
bun build— compilesterminal.tsandnode.tstodist/targeting Node.jstsc— generatesdist/terminal.d.tsanddist/node.d.tsviatsconfig.build.json
prepublishOnly runs the build automatically before bun publish / npm publish.
Samples
Run any sample with bun run sample/<name>.ts.
basic.ts
Minimal hello-world in lime green.
import { Terminal } from '@retrovm/terminal'
Terminal.out!.lime('Hello, world!\n').reset()rainbow.ts
Cycles the full HSV hue wheel across a string, one color per character.
bun run sample/rainbow.tsprogress.ts
Animated multi-stage progress bar. Uses column(1) to rewrite the line in place — no alternate screen, no flicker. The filled blocks carry an HSV gradient from blue through cyan to green; the percentage label tracks the gradient front.
bun run sample/progress.tsmatrix.ts
Full-screen Matrix rain in the alternate buffer. Katakana + ASCII glyphs fall in columns with randomized speed and trail length. The head of each column is white; the trail fades from bright green to dark green. Exit with any key or after 15 seconds.
bun run sample/matrix.tsfire.ts
Procedural fire simulation using the half-block trick: the ▄ character has its foreground and background set to different colors, doubling the effective vertical resolution. Heat propagates upward from a seeded bottom row, cooling as it rises. Color palette: black → dark red → orange → yellow → white.
bun run sample/fire.tssnake.ts
Fully playable Snake game running in the alternate buffer. Uses sync() for tear-free rendering and updates only the cells that actually changed each tick. The snake body carries a smooth cyan-to-teal gradient; food pulses with an orange-yellow glow. Controls: WASD or arrow keys, Q to quit.
bun run sample/snake.tsrotozoom.ts
Real-time rotating and zooming texture effect. Each frame the CPU computes a rotated/scaled UV map over the terminal dimensions and calls out.framebuffer() to push the result as a pixel buffer — demonstrating that framebuffer() is fast enough for 60 fps animation. Exit with any key.
bun run sample/rotozoom.tssysmon.ts
Live system monitor that refreshes every second inside the alternate buffer. Two-panel layout drawn with box-drawing characters (┌┬┐│├┤└┴┘─). Left panel shows overall CPU average and per-core usage bars; right panel shows system RAM and process heap. All bars are color-coded: green below 70%, orange 70–90%, red above 90%.
bun run sample/sysmon.tsWriting a custom sink
Any object satisfying ITerminalWriter ({ write(data: string): void }) works:
// Collect output for testing
const chunks: string[] = []
const term = createTerminal({ write: s => chunks.push(s) })
term.red('error').reset()
// chunks contains raw ANSI bytes
// Write to a file
import { createWriteStream } from 'node:fs'
const stream = createWriteStream('out.ans')
const term = createTerminal({ write: s => stream.write(s) })NO_COLOR
If the environment variable NO_COLOR is set (to any value), plain defaults to true and all escape sequences are suppressed. The API stays identical — only the output changes.
NO_COLOR=1 bun run sample/rainbow.ts # plain text, no colorLicense
MIT License
Copyright (c) 2026 Juan Carlos González Amestoy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.