npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 / LAMBDA for 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.log works normally above the TUI panel
  • TypeScript -- written in strict TypeScript with full type definitions

Installation

npm install easy-tui

Quick 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 the ref option on a child component
  • this.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.ts

See 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