tui

js/tty/tui.ts

fino:tty/tui — terminal render target for fino:ui components.

The terminal is a retained-mode host: the fino:ui reconciler maintains a node tree here, a flexbox-subset layout engine measures and paints it into styled-cell frames (fino:tty/frame), and ANSI bytes exist only at the wire edge. Live rendering diffs frames row-wise, so screen writes stay proportional to what changed.

Primitives are host-neutral: Box (flexbox layout, padding, margin, borders), Text (styled runs, wrapping, caret), Spacer, Input, Button, List, Scroll, Layer (content painted above the normal flow), and Clickable (focusable pointer/key behavior without visuals). All accept style props (color, background, bold, …) resolved against fino:tty/style tokens during layout.

Layout model

Layout uses integer terminal cells rather than browser pixels. It implements row and column flex flow, optional row wrapping, fixed/min/max sizes, weighted growth and shrinkage, margins, padding, gaps, alignment, clipping, and out-of-flow layers. Grapheme clusters are never split across cells, and wide characters occupy two cells. Unlike HTML/CSS there is no cascade, intrinsic font metric, percentage sizing, grid, or automatic scrolling; overflow is clipped unless a box explicitly requests visible overflow, and scroll offsets are controlled by the application.

Live apps route mouse input to the topmost painted node and bubble toward its ancestors. Keys start at the focused node; Tab and Shift-Tab traverse enabled Clickable nodes, while active overlays may opt into unfocused key capture. Input reads preserve escape sequences and UTF-8 code points split across operating-system reads. Unless dimensions are pinned, render() reflows the retained tree after terminal resize notifications.

/** @jsxImportSource fino:ui *\/
import { Box, Text, renderFrame } from 'fino:tty/tui';

const frame = renderFrame(
  <Box border padding={1}><Text color="cyan">Hello</Text></Box>,
  { width: 20, height: 3 },
);

Interfaces

interface ButtonProps extends StyleProps, FlexChildProps, Props {

Props accepted by Button.

Properties

label?: string

Text rendered inside the button brackets.

focused?: boolean

Whether to draw the keyboard-focus marker.

interface ListProps extends StyleProps, FlexChildProps, Props {

Props accepted by List.

Properties

items: string[]

Rows rendered from top to bottom.

selectedIndex?: number

Zero-based row receiving the selection marker; defaults to zero.

interface TuiFocus {

Focus control surface exposed by a live TUI app.

Readonly Properties

readonly focusedId: { get(): string | null }

Signal carrying the focused node's id, for components to render focus.

Methods

next(): boolean

Move focus to the next enabled focusable node.

prev(): boolean

Move focus to the previous enabled focusable node.

focus(id: string): boolean

Focus the enabled node with id, returning whether it was found.

blur(): void

Clear the current focus.

interface RenderFrameOptions {

Options for deterministic terminal snapshot rendering.

Properties

width: number

Output width in terminal cells; negative and fractional values are normalized.

height: number

Output height in terminal rows; negative and fractional values are normalized.

interface TerminalSize {

Current terminal viewport size in character cells.

Properties

width: number

Viewport columns.

height: number

Viewport rows.

interface RenderOptions {

Options for live fullscreen terminal rendering.

Properties

width?: number

Fixed viewport width; defaults to the current terminal width.

height?: number

Fixed viewport height; defaults to the current terminal height.

input?: boolean

Whether to create a raw input reader. An event handler also enables input.

mouse?: boolean

Whether the input reader enables SGR mouse reporting; defaults to true.

onEvent?: (event: TuiEvent, app: TuiApp) => void | Promise<void>

Called sequentially for each decoded input event until the app stops.

interface TuiApp {

Handle returned by render() for updating or stopping a fullscreen app.

Methods

update(element: VNode): void

Replace the current tree and stop any reactive root previously supplied to render().

stop(): void

Stop rendering and restore terminal screen, cursor, mouse, and raw-mode state.

frame(): Frame | null

The most recently painted frame.

Properties

input?: TuiInput

Raw input reader when input was enabled.

focus: TuiFocus

Focus traversal and state for the retained tree.

interface TuiInputOptions {

Options for creating a raw terminal input reader.

Properties

mouse?: boolean

Whether to enable SGR mouse reporting while the reader is open; defaults to true.

Types

type TuiKeyEvent = UiKeyEvent

Keyboard event decoded from terminal input.

type TuiMouseEvent = UiMouseEvent

Mouse event decoded from SGR terminal mouse reporting.

type TuiEvent = TuiKeyEvent | TuiMouseEvent

Terminal input event consumed by TUI applications.

Functions

function Button(props: ButtonProps): VNode

Push button primitive rendered as bracketed terminal text.

function List(props: ListProps): VNode

Vertical list primitive with a selected row marker.

function measure(element: VNode, constraints: Constraints): Measured

Measure a terminal component tree under the supplied cell constraints.

function layout(element: VNode, constraints: Constraints): Frame

Lay out and paint a terminal component tree into a structured frame.

function decodeTuiInput(bytes: Uint8Array): TuiEvent[]

Decode one complete terminal input byte chunk into TUI input events.

The decoder understands printable UTF-8, common control keys, arrow/function CSI sequences, and SGR mouse reporting (CSI < code ; x ; y M/m). SGR mouse coordinates are converted to zero-based x/y values. A trailing partial sequence is treated literally; TuiInput reassembles partial live reads.

function createTuiInput(options: TuiInputOptions = {}): TuiInput

Create a raw terminal input reader for TUI applications.

The reader enables raw mode immediately. Call close() when the application exits so terminal state is restored.

function getTerminalSize(): TerminalSize

Return the current terminal viewport size.

The TUI host asks the terminal with ioctl(TIOCGWINSZ) when possible and falls back to environment dimensions in non-interactive contexts.

async function measureTerminalSize(): Promise<TerminalSize>

Measure the visible terminal viewport with an ANSI cursor-position query.

This is slower than getTerminalSize() but handles terminal panes where ioctl(TIOCGWINSZ) or environment dimensions are stale. The function enters raw mode briefly, moves the cursor to a very large coordinate, asks the terminal to report the clamped cursor position, restores the cursor, and returns the reported row/column. If the terminal does not answer quickly, it falls back to getTerminalSize().

function layoutFrame(element: VNode, options: RenderFrameOptions): Frame

Lay a tree out into a styled-cell frame without touching a terminal.

This is the one-shot path: the tree is laid out fresh with no retained state. Pass the result to frameToAnsi()/frameToScreen() from fino:tty/frame, or use renderFrame() for the padded-string form.

function renderFrame(element: VNode, options: RenderFrameOptions): string

Render an element tree to a deterministic terminal frame.

The returned string contains exactly height lines joined with \n, and each line is padded or clipped to width cells.

function frameSink(options: RenderFrameOptions): Sink<string>

Sink that turns each committed tree into a terminal frame string.

The frame is a complete screen image rather than a diff, so this is a snapshot sink like htmlSink(). Pair it with renderStatic() to capture one frame, or with createRoot() to drive a live screen from signals.

import { createRoot } from 'fino:ui';
import { frameSink } from 'fino:tty/tui';

const root = createRoot(App, frameSink({ width: 80, height: 24 }));

function terminalSink(options: RenderFrameOptions): Sink<Frame>

Sink that reconciles each committed tree into a retained terminal node tree and returns the laid-out Frame.

Unlike frameSink(), consecutive commits reuse the mounted tree, so unchanged subtrees keep their measurement caches. Use this when the caller needs the structured frame — cursor placement, hit regions — rather than encoded text.

function render(element: VNode | (() => VNode), options: RenderOptions = {}): TuiApp

Render a fullscreen terminal app and return a lifecycle handle.

This enters the alternate screen, hides the cursor, and paints frames through the retained host with row-level diffing — only rows whose encoded form changed are rewritten. When a frame places a caret (Text caret or a focused Input), the terminal cursor is shown there.

Passing a function instead of a tree makes the app reactive: every signal read while rendering becomes a dependency, and the screen repaints when one changes. update() remains available for callers that drive frames themselves, and takes over from the reactive root when used. Tree handlers receive input before onEvent; only unconsumed events reach the fallback callback. stop() is idempotent and cancels a blocked input read before restoring raw mode, mouse reporting, cursor state, auto-wrap, and the primary screen.

import { createSignal } from 'fino:ui';
import { Text, render } from 'fino:tty/tui';

const ticks = createSignal(0);
const app = render(() => Text({ children: [String(ticks.get())] }));
ticks.set(1);
app.stop();

Classes

class TuiInput {

Raw terminal input reader for keyboard and mouse events.

One reader owns stdin raw mode and its readability watch at a time. Calls to read() are sequential: each returns one decoded event, preserving partial escape sequences and UTF-8 between operating-system reads. close() is idempotent, cancels a blocked read, disables mouse reporting when enabled, and restores the terminal mode captured by the constructor.

Constructors

constructor(options: TuiInputOptions = {})

Enter raw mode and optionally enable mouse reporting immediately.

Methods

async read(): Promise<TuiEvent | null>

Read the next decoded keyboard or mouse event from stdin.

close(): void

Restore raw mode and mouse reporting.