@flyingrobots/bijou-tui
v3.0.0
Published
TEA runtime for terminal UIs — model/update/view with keyboard input, alt screen, and layout helpers.
Downloads
2,990
Maintainers
Readme
@flyingrobots/bijou-tui
The high-fidelity TEA runtime for Bijou.
bijou-tui provides the application loop, layout primitives, motion, and orchestration needed to build complex interactive terminal apps on top of the Bijou core.
V3.0.0 Evolution
The TUI package has been completely overhauled in v3.0.0 to operate as a true graphics engine.
🌟 What's New
- Honest view contract:
App.viewand framed pane renderers now speakViewOutput(string | Surface | LayoutNode). Strings still work, but they are the legacy compatibility path. - Programmable Rendering Pipeline: The TEA
viewoutput is now processed through a 5-stage middleware pipeline (Layout -> Paint -> PostProcess -> Diff -> Output). Add custom fragment shaders or logging middleware effortlessly. - Fractal TEA (Sub-Apps): Compose nested apps with
initSubApp(),updateSubApp(),mount(), andmapCmds()instead of flattening everything into one update loop. - Bijou CSS (BCSS): Style supported V3 surface components and frame shell regions with type/class/id selectors,
var()token lookups, and terminal-aware media queries (@media (width < 80)). This is not yet a global cascade across arbitrary layout nodes. - Declarative Motion: Wrap any component in
motion({ key: 'id' }, ...)and watch it smoothly interpolate layout changes (move, resize) using physics-based springs. - Unified Heartbeat: All animations and physics calculations are now synchronized to a single
PulseMsg, eliminating timer jitter and saving CPU.
Installation
npm install @flyingrobots/[email protected] @flyingrobots/[email protected] @flyingrobots/[email protected]If you are upgrading an existing app, see ../../docs/MIGRATING_TO_V3.md.
Quick Start (V3 Sub-App Composition)
import { initDefaultContext } from '@flyingrobots/bijou-node';
import { run, mount, mapCmds, type App } from '@flyingrobots/bijou-tui';
import { createSurface, type Surface } from '@flyingrobots/bijou';
initDefaultContext();
// Minimal child apps
const childApp: App<{ count: number }, any> = {
init: () => [{ count: 0 }, []],
update: (msg, model) => [model, []],
view: (model) => {
const s = createSurface(20, 5);
s.fill({ char: '.' });
return s;
}
};
interface Model {
left: { count: number };
right: { count: number };
}
// Parent App mounting two independent Sub-Apps
const app: App<Model, any> = {
init: () => [{ left: { count: 0 }, right: { count: 0 } }, []],
update: (msg, model) => [model, []],
view: (model) => {
// Render the children (they return Surfaces!)
const [leftSurface] = mount(childApp, { model: model.left, onMsg: m => m });
const [rightSurface] = mount(childApp, { model: model.right, onMsg: m => m });
// Composite them onto the main screen
const screen = createSurface(80, 24);
screen.blit(leftSurface, 0, 0);
screen.blit(rightSurface, 40, 0);
return screen;
}
};
run(app);Quick Start (Basic)
import { initDefaultContext } from '@flyingrobots/bijou-node';
import { run, quit, type App, isKeyMsg } from '@flyingrobots/bijou-tui';
initDefaultContext();
type Model = { count: number };
const app: App<Model> = {
init: () => [{ count: 0 }, []],
update: (msg, model) => {
if (isKeyMsg(msg)) {
if (msg.key === 'q') return [model, [quit()]];
if (msg.key === '+') return [{ count: model.count + 1 }, []];
if (msg.key === '-') return [{ count: model.count - 1 }, []];
}
return [model, []];
},
view: (model) => `Count: ${model.count}\n\nPress +/- to change, q to quit`,
};
run(app);Runtime Behavior Note
run() behaves differently by output mode:
interactive: full TEA loop (event bus, key/resize/mouse handling, command-driven updates).pipe/static/accessible: renderview(initModel)once and return immediately.
In non-interactive modes, there is no normal interactive event loop.
Features Breakdown
- TEA runtime core: deterministic model/update/view loop with command-driven side effects.
- Motion system: spring physics, tweens, and timeline sequencing for orchestrated terminal animation.
- Layout engine: flexbox helpers, stacks, split panes, named-area grids, viewport scrolling, and resize-aware rendering.
- Input architecture: keymaps, grouped bindings, generated help views, and layered input stack for modal flows.
- Overlay composition: modal, toast, drawer, tooltip, and painter-style compositing primitives (including panel-scoped drawers).
- App shell:
createFramedApp()for tabs/help/chrome/pane-focus boilerplate with optional command palette. - Stateful building blocks: navigable table, browsable list, file picker, focus area, and DAG pane with vim-friendly keymaps.
Animation
Spring Physics
import { animate, SPRING_PRESETS } from '@flyingrobots/bijou-tui';
// Physics-based (default) — runs until the spring settles
const cmd = animate({
from: 0,
to: 100,
spring: 'wobbly', // or 'default', 'gentle', 'stiff', 'slow', 'molasses'
onFrame: (v) => ({ type: 'scroll', y: v }),
});
// Duration-based with easing
const fade = animate({
type: 'tween',
from: 0,
to: 1,
duration: 300,
ease: EASINGS.easeOutCubic,
onFrame: (v) => ({ type: 'fade', opacity: v }),
});
// Skip animation (reduced motion)
const jump = animate({
from: 0, to: 100,
immediate: true,
onFrame: (v) => ({ type: 'scroll', y: v }),
});Timeline
GSAP-style orchestration — pure state machine, no timers:
import { timeline } from '@flyingrobots/bijou-tui';
const tl = timeline()
.add('slideIn', { type: 'tween', from: -100, to: 0, duration: 300 })
.add('fadeIn', { type: 'tween', from: 0, to: 1, duration: 200 }, '-=100')
.label('settled')
.add('bounce', { from: 0, to: 10, spring: 'wobbly' }, 'settled')
.call('onReady', 'settled+=50')
.build();
// Drive from TEA update:
let tlState = tl.init();
// on each frame:
tlState = tl.step(tlState, 1/60);
const { slideIn, fadeIn, bounce } = tl.values(tlState);
const fired = tl.firedCallbacks(prev, tlState); // ['onReady']Position syntax: '<' (parallel), '+=N' (gap), '-=N' (overlap), '<+=N' (offset from previous start), absolute ms, 'label', 'label+=N'.
Layout
Flexbox
import { flex } from '@flyingrobots/bijou-tui';
// Sidebar + main content, responsive to terminal width
flex({ direction: 'row', width: cols, height: rows, gap: 1 },
{ basis: 20, content: sidebarText },
{ flex: 1, content: (w, h) => renderMain(w, h) },
);
// Header + body + footer
flex({ direction: 'column', width: cols, height: rows },
{ basis: 1, content: headerLine },
{ flex: 1, content: (w, h) => renderBody(w, h) },
{ basis: 1, content: statusLine },
);Children can be render functions (width, height) => string — they receive their allocated space and reflow automatically when the terminal resizes.
Viewport
import { viewport, createScrollState, scrollBy, pageDown } from '@flyingrobots/bijou-tui';
let scroll = createScrollState(content, viewportHeight);
// Render visible window with scrollbar
const view = viewport({ width: 60, height: 20, content, scrollY: scroll.y });
// Handle scroll keys
scroll = scrollBy(scroll, 1); // down one line
scroll = pageDown(scroll); // down one pageBasic Layout
import { vstack, hstack } from '@flyingrobots/bijou-tui';
vstack(header, content, footer); // vertical stack
hstack(2, leftPanel, rightPanel); // side-by-side with gapSplit Pane
import {
createSplitPaneState, splitPane, splitPaneResizeBy, splitPaneFocusNext,
} from '@flyingrobots/bijou-tui';
let state = createSplitPaneState({ ratio: 0.35 });
// in update:
state = splitPaneResizeBy(state, 2, { total: cols, minA: 16, minB: 16 });
state = splitPaneFocusNext(state);
// in view:
const output = splitPane(state, {
direction: 'row',
width: cols,
height: rows,
minA: 16,
minB: 16,
paneA: (w, h) => renderSidebar(w, h),
paneB: (w, h) => renderMain(w, h),
});Grid
import { grid } from '@flyingrobots/bijou-tui';
const output = grid({
width: cols,
height: rows,
columns: [24, '1fr'],
rows: [3, '1fr', 8],
areas: [
'header header',
'nav main',
'logs main',
],
gap: 1,
cells: {
header: (w, h) => renderHeader(w, h),
nav: (w, h) => renderNav(w, h),
logs: (w, h) => renderLogs(w, h),
main: (w, h) => renderMain(w, h),
},
});Resize Handling
Terminal resize events are dispatched automatically as ResizeMsg:
update(msg, model) {
if (msg.type === 'resize') {
return [{ ...model, cols: msg.columns, rows: msg.rows }, []];
}
// ...
}
view(model) {
return flex(
{ direction: 'row', width: model.cols, height: model.rows },
{ basis: 20, content: sidebar },
{ flex: 1, content: (w, h) => mainContent(w, h) },
);
}Event Bus
The runtime uses an EventBus internally. You can also create your own for custom event sources:
import { createEventBus } from '@flyingrobots/bijou-tui';
const bus = createEventBus<MyMsg>();
bus.connectIO(ctx.io); // keyboard + resize
bus.on((msg) => { /* ... */ }); // single subscription
bus.emit(customMsg); // synthetic events
bus.runCmd(someCommand); // command results re-emitted
bus.dispose(); // clean shutdownSee ARCHITECTURE.md for the full event flow and GUIDE.md for detailed usage patterns.
Keybinding Manager
Declarative key binding with modifier support, named groups, and runtime enable/disable:
import { createKeyMap, type KeyMsg } from '@flyingrobots/bijou-tui';
type Msg = { type: 'quit' } | { type: 'help' } | { type: 'move'; dir: string };
const kb = createKeyMap<Msg>()
.bind('q', 'Quit', { type: 'quit' })
.bind('?', 'Help', { type: 'help' })
.bind('ctrl+c', 'Force quit', { type: 'quit' })
.group('Navigation', (g) => g
.bind('j', 'Down', { type: 'move', dir: 'down' })
.bind('k', 'Up', { type: 'move', dir: 'up' })
);
// In TEA update:
const action = kb.handle(keyMsg);
if (action !== undefined) return [model, [/* ... */]];
// Runtime enable/disable
kb.disableGroup('Navigation');
kb.enable('Quit');Help Generation
Auto-generate help text from registered bindings:
import { helpView, helpShort, helpFor } from '@flyingrobots/bijou-tui';
helpView(kb); // full grouped multi-line help
helpShort(kb); // "q Quit • ? Help • Ctrl+c Force quit • j Down • k Up"
helpFor(kb, 'Nav'); // only Navigation groupInput Stack
Layered input dispatch for modal UIs — push/pop handlers with opaque or passthrough behavior:
import { createInputStack, type KeyMsg } from '@flyingrobots/bijou-tui';
const stack = createInputStack<KeyMsg, Msg>();
// Base layer — global keys, lets unmatched events fall through
stack.push(appKeys, { passthrough: true });
// Modal opens — captures all input (opaque by default)
const modalId = stack.push(modalKeys);
// Dispatch returns first matched action, top-down
const action = stack.dispatch(keyMsg);
// Modal closes
stack.remove(modalId);KeyMap implements InputHandler, so it plugs directly into the input stack.
Overlay Compositing
Paint overlays (modals, toasts) on top of existing content:
import { composite, modal, toast } from '@flyingrobots/bijou-tui';
// Create a centered dialog
const dialog = modal({
title: 'Confirm',
body: 'Delete this item?',
hint: 'y/n',
screenWidth: 80,
screenHeight: 24,
});
// Create a toast notification
const notification = toast({
message: 'Saved successfully',
variant: 'success', // 'success' | 'error' | 'info'
anchor: 'bottom-right', // 'top-right' | 'bottom-right' | 'bottom-left' | 'top-left'
screenWidth: 80,
screenHeight: 24,
});
// Paint overlays onto background content
const output = composite(backgroundView, [dialog, notification], { dim: true });Each overlay is a { content, row, col } object. composite() splices them onto the background using painter's algorithm (last overlay wins on overlap). The dim option fades the background with ANSI dim.
drawer() now supports left/right/top/bottom anchors and optional region mounting for panel-scoped overlays.
App Frame
createFramedApp() wraps page-level TEA logic in a shared shell:
- tabs + page switching
- pane focus and per-pane scroll isolation
- frame help (
?) and optional command palette (ctrl+p/:) - overlay factory with pane rects for panel-scoped drawers/modals
Pane renderers may return a legacy string, a Surface, or a LayoutNode. The shell normalizes those outputs into the framed scroll/focus path for you.
See examples/release-workbench/main.ts for the canonical shell demo and examples/app-frame/main.ts for a compact focused example.
Building Blocks
Reusable stateful components that follow the TEA state + pure transformers + sync render + convenience keymap pattern:
Navigable Table
import {
createNavigableTableState, navigableTable, navTableFocusNext,
navTableKeyMap, helpShort,
} from '@flyingrobots/bijou-tui';
const state = createNavigableTableState({ columns, rows, height: 10 });
const output = navigableTable(state, { ctx });
const next = navTableFocusNext(state);Browsable List
import {
createBrowsableListState, browsableList, listFocusNext,
browsableListKeyMap,
} from '@flyingrobots/bijou-tui';
const state = createBrowsableListState({ items, height: 10 });
const output = browsableList(state);File Picker
import {
createFilePickerState, filePicker, fpFocusNext, fpEnter, fpBack,
filePickerKeyMap,
} from '@flyingrobots/bijou-tui';
import { nodeIO } from '@flyingrobots/bijou-node';
const io = nodeIO();
const state = createFilePickerState({ cwd: process.cwd(), io, height: 15 });
const output = filePicker(state);Focus Area
import {
createFocusAreaState, focusArea, focusAreaScrollBy,
focusAreaKeyMap,
} from '@flyingrobots/bijou-tui';
const state = createFocusAreaState({ content, width: 60, height: 20, overflowX: 'scroll' });
const output = focusArea(state, { focused: true, ctx });DAG Pane
import {
createDagPaneState, dagPane, dagPaneSelectChild,
dagPaneSelectParent, dagPaneKeyMap,
} from '@flyingrobots/bijou-tui';
const state = createDagPaneState({ source: nodes, width: 80, height: 24, ctx });
const output = dagPane(state, { focused: true, ctx });
const next = dagPaneSelectChild(state, ctx); // arrow-key navigationAll building blocks include *KeyMap() factories for preconfigured vim-style keybindings.
Related Packages
@flyingrobots/bijou— Zero-dependency core with all components and theme engine@flyingrobots/bijou-node— Node.js runtime adapter (chalk, readline, process)
License
MIT
.-:::::'::: .-:. ::-.::::::. :::. .,-:::::/
;;;'''' ;;; ';;. ;;;;';;;`;;;;, `;;;,;;-'````'
[[[,,== [[[ '[[,[[[' [[[ [[[[[. '[[[[[ [[[[[[/
`$$$"`` $$' c$$" $$$ $$$ "Y$c$$"$$c. "$$
888 o88oo,.__ ,8P"` 888 888 Y88 `Y8bo,,,o88o
"MM, """"YUMMMmM" MMM MMM YM `'YMUP"YMM
:::::::.. ... :::::::. ... :::::::::::: .::::::.
;;;;``;;;; .;;;;;;;. ;;;'';;' .;;;;;;;.;;;;;;;;'''';;;` `
[[[,/[[[' ,[[ \[[, [[[__[[\.,[[ \[[, [[ '[==/[[[[,
$$$$$$c $$$, $$$ $$""""Y$$$$$, $$$ $$ ''' $
888b "88bo,"888,_ _,88P_88o,,od8P"888,_ _,88P 88, 88b dP
MMMM "W" "YMMMMMP" ""YUMMMP" "YMMMMMP" MMM "YMmMY"