easy-tui
v1.1.1
Published
Terminal UI Framework
Readme
easy-tui
A declarative, component-based Terminal UI framework for Node.js.
Build interactive terminal applications with a familiar React-like component model, flexbox layout, and rich input components.
Features
- Declarative Components -- class-based components with
render(), lifecycle hooks, and reconciliation - Flexbox Layout -- row/column directions, flex weights, percentage/fixed/auto sizing, borders & dividers
- Rich Input -- single/multiline text input with prompt, placeholder, scrolling, line numbers; single/multiple select
- Conditional & List Rendering --
IF/ELSEIF/ELSE/ITER/LAMBDAfor dynamic UIs - Theme & Style -- cascading themes, inline styles (fg/bg/bold/dim/italic/underline/inverse/strikethrough), ANSI passthrough
- Focus Management -- Tab/Shift+Tab cycling, programmatic focus, key event bubbling
- Full Unicode -- CJK wide characters, emoji, ZWJ sequences, grapheme-aware cursor movement
- stdout Interception --
console.logworks normally above the TUI panel - TypeScript -- written in strict TypeScript with full type definitions
Installation
npm install easy-tuiQuick Start
import { Box, CustomComponent, Input, Select, SelectItem, Text, TUI } from 'easy-tui'
const ui = new TUI({
stdin: process.stdin,
stdout: process.stdout,
})
class App extends CustomComponent {
name = ''
render() {
return Box(
{ showBorder: true, flexDirection: 'col' },
Text({ style: { bold: true } }, 'Welcome!'),
Text(''),
Input({
prompt: 'Your name:',
placeholder: 'Type here...',
}).setOnSubmit((value) => {
this.name = value
this.markDirty()
}),
Text(''),
this.name
? Text({ style: { fg: 'green' } }, `Hello, ${this.name}!`)
: Text({ style: { dim: true } }, 'Enter your name above'),
Text(''),
Text({ style: { dim: true } }, 'Ctrl+C to exit'),
)
}
}
ui.show(new App())Core Concepts
Components
All components extend BaseComponent. Use CustomComponent to build your own:
class MyComponent extends CustomComponent {
// State
count = 0
// Render the UI tree
render() {
return Text(`Count: ${this.count}`)
}
// Handle keyboard events
onKey(key: KeyEvent): boolean {
if (key.name === 'space') {
this.count++
this.markDirty() // Trigger re-render
return true
}
return false
}
// Lifecycle hooks
onMount() { /* called when component appears */ }
onUnmount() { /* called when component disappears */ }
onFocus() { /* called when focused */ }
onBlur() { /* called when losing focus */ }
// Make focusable (optional)
canFocus() { return true }
}Layout
Use Box to build layouts. It supports flexDirection: 'col' (default) and 'row':
Box(
{ flexDirection: 'col', showBorder: true, showDivider: true },
// Fixed height
Text({ height: 1 }, 'Header'),
// Flex children fill remaining space
Box({ flex: 1 }, Text('Main content')),
// Auto height
Text('Footer'),
)Sizing properties:
| Property | Type | Description |
|----------|------|-------------|
| width | number \| \${number}%` | 'auto'| Fixed columns, percentage, or'auto'(default, fills parent) |
|height|number | 'auto'| Fixed rows or'auto'(default, fits content) |
|flex|number` | Flex weight for distributing remaining space among siblings |
Conditional Rendering
import { IF, ELSEIF, ELSE } from 'easy-tui'
Box(
IF(condition)(
Text('Shown when true')
),
ELSEIF(otherCondition)(
Text('Shown when other is true')
),
ELSE(
Text('Fallback')
),
)Conditions can be values or functions (evaluated each render):
IF(() => this.items.length > 0)(...)List Rendering
import { ITER } from 'easy-tui'
Box(
ITER(this.items, (index, item) =>
Text(`${index + 1}. ${item.name}`)
),
)ITER accepts arrays, Maps, Sets, objects, numbers (0..n), and strings.
Styling
Text(
{ style: { fg: 'red', bg: '#1a1a1a', bold: true, underline: true } },
'Styled text'
)Style properties: fg, bg, bold, dim, italic, underline, inverse, strikethrough
Color formats: named colors (red, cyan, brightGreen), hex (#ff0000), ANSI 256 (ansi256:196)
Theming
Themes cascade from parent to children:
Box(
{ theme: { border: { fg: 'cyan' }, text: { fg: 'white' } } },
Text('This text is white'),
Box(
{ theme: { text: { fg: 'green' } } },
Text('This text is green'),
),
)Theme slots: prompt, border, divider, highlight, selection, lineNo, focused, text, placeholder
Triggering Re-renders
Call this.markDirty() after state changes to schedule a re-render:
onKey(key: KeyEvent): boolean {
if (key.name === 'space') {
this.count++
this.markDirty()
return true
}
return false
}Built-in methods show() and hide() also trigger re-renders automatically.
Component Refs
Use ref and refs to access child component instances from a parent:
class App extends CustomComponent {
render() {
return Box(
Input({
ref: this.refTo('nameInput'), // Bind child to refs['nameInput']
prompt: 'Name:',
}).setOnSubmit(() => {
// Access the child component via refs
const input = this.refs['nameInput'] as InputComponent
console.log('Value:', input.value)
input.value = [] // Clear the input
this.markDirty()
}),
)
}
}this.refTo(key)-- returns a ref binding function; pass it as therefoption on a child componentthis.refs[key]-- returns the child component instance bound to that key- Refs are re-bound on every render, so they always point to the current instance
Components
Box
Container component for layout.
Box(options?, ...children)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| flexDirection | 'col' \| 'row' | 'col' | Layout direction |
| showBorder | boolean | false | Draw border around the box |
| showDivider | boolean | false | Draw dividers between children |
| width | number \| \${number}%` | 'auto'|'auto'| Box width |
|height|number | 'auto'|'auto'| Box height |
|flex|number| - | Flex weight |
|style|Style| - | Box style (e.g., background) |
|theme|Theme` | - | Theme overrides for children |
Text
Static text display.
Text(options?, ...lines)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| prompt | string | - | Prompt text (displayed in theme prompt color) |
| style | Style | - | Text style |
| width | number \| \${number}%` | 'auto'|'auto'| Text width |
|height|number | 'auto'|'auto'` | Fixed height (rows) |
Input
Text input component. Focusable.
Input(options?)
.setOnSubmit((value: string) => void)
.setOnCancel((value: string) => void)
.setOnChange((value: string) => void)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| prompt | string | - | Prompt text |
| value | string[] | [] | Initial value (array of lines) |
| placeholder | string | - | Placeholder text |
| multiline | boolean | false | Enable multiline input |
| maxHeight | number | - | Max visible rows (scrollable) |
| maxLength | number | - | Max character limit |
| lineNo | boolean | false | Show line numbers (multiline) |
| newline | boolean | false | Prompt on its own line |
| cancelable | boolean | false | Allow Escape to cancel |
Keyboard shortcuts:
| Key | Action |
|-----|--------|
| Enter | Submit (single-line) / Newline (multiline) |
| Escape | Cancel (if cancelable) |
| Left / Right | Move cursor (grapheme-aware) |
| Up / Down | Move between lines |
| Home / End | Jump to line start / end |
| Backspace / Delete | Delete character |
Select
List selector component. Focusable.
Select(
options?,
SelectItem({ title: 'Option 1', value: 'opt1' }),
SelectItem({ title: 'Option 2', value: 'opt2' }),
)
.setOnSubmit((items: SelectItemData[]) => void)
.setOnChange((items: SelectItemData[]) => void)
.setOnCancel((items: SelectItemData[]) => void)| Option | Type | Default | Description |
|--------|------|---------|-------------|
| prompt | string | - | Prompt text |
| multiple | boolean | false | Enable multi-select |
| cancelable | boolean | false | Allow Escape to cancel |
SelectItem options:
| Option | Type | Description |
|--------|------|-------------|
| title | string | Display text |
| value | any | Associated value |
| disabled | boolean | Disable selection |
| selected | boolean | Initial selection state |
Keyboard shortcuts:
| Key | Single Select | Multi Select |
|-----|---------------|--------------|
| Up / Down | Move highlight | Move highlight |
| Space | Select & submit | Toggle selection |
| Enter | Submit highlighted | Submit selected |
| Escape | Cancel | Cancel |
Logic Components
Logic components control rendering flow but don't produce visual output themselves.
IF / ELSEIF / ELSE
Conditional rendering:
Box(
IF(this.showHeader)(
Text('Header')
),
ELSEIF(this.showSubheader)(
Text('Subheader')
),
ELSE(
Text('Default')
),
)ITER
List rendering:
Box(
ITER(this.items, (key, item) =>
Text(`${key}: ${item}`)
),
)LAMBDA
Dynamic computed children:
Box(
LAMBDA(() => {
const result = computeExpensiveResult()
return Text(result)
}),
)API Reference
TUI
const ui = new TUI({
stdin: process.stdin,
stdout: process.stdout,
fps: 30, // Render frame rate (default: 30)
exitOnCtrlC: true, // Exit on Ctrl+C (default: true)
})| Method | Description |
|--------|-------------|
| ui.show(component?) | Activate TUI, start render loop |
| ui.hide() | Deactivate TUI, restore terminal |
| ui.setComponent(comp) | Set/replace root component |
| ui.requestRender() | Schedule a re-render |
| ui.comps | Component registry by key |
BaseComponent
All components inherit from BaseComponent:
| Property / Method | Type | Description |
|-------------------|------|-------------|
| key | string | Component identifier, registered in ui.comps |
| hidden | boolean | Hide component (not rendered) |
| style | Style | Component style |
| theme | Theme | Theme overrides |
| parent | BaseComponent | Parent component |
| children | BaseComponent[] | Child components |
| focused | boolean | Whether component is focused |
| markDirty() | void | Schedule re-render |
| show() | void | Show component (sets hidden = false) |
| hide() | void | Hide component (sets hidden = true) |
| render() | LayoutNode \| BaseComponent | Return UI tree |
| onKey(key) | boolean | Handle key event, return true if handled |
| canFocus() | boolean | Whether component can receive focus |
| onMount() | void | Called when component appears |
| onUnmount() | void | Called when component disappears |
| onFocus() | void | Called when focused |
| onBlur() | void | Called when losing focus |
| ref | (comp) => void | Ref callback for external access |
| refs | Record<string, BaseComponent> | Access child components by ref key |
| refTo(key) | (comp) => void | Create ref binding for child |
Examples
Run the included examples:
# Todo app with filtering
npx ts-node examples/todo.ts
# Multi-step form with validation
npx ts-node examples/form.ts
# Live dashboard with real-time updates
npx ts-node examples/dashboard.ts
# Interactive menu with submenus
npx ts-node examples/menu.ts
# Log viewer with filtering
npx ts-node examples/log-viewer.tsSee the examples/ directory for more.
Key Handling
Key events are dispatched to the focused component, then bubble up through parents.
onKey(key: KeyEvent): boolean {
if (key.name === 'enter' && key.ctrl) {
// Handle Ctrl+Enter
return true
}
if (key.name === 's' && key.meta) {
// Handle Alt/Meta+S
return true
}
return false
}KeyEvent properties:
| Property | Type | Description |
|----------|------|-------------|
| name | string | Key name ('enter', 'escape', 'tab', 'up', 'a', 'f1', etc.) |
| ctrl | boolean | Ctrl modifier |
| shift | boolean | Shift modifier |
| meta | boolean | Alt/Meta modifier |
Set bubblingKeyEvent: true on a component to allow events to continue bubbling after handling.
License
MIT
