# pi-tui: Why Build a Terminal UI Library from Scratch?

> packages/tui implements its own terminal rendering engine with differential output, an undo stack, a kill ring, Emacs-style key bindings, fuzzy search, and inline image display (Kitty/Sixel). This page asks what constraints made off-the-shelf libraries insufficient, how the virtual terminal model in terminal.ts avoids screen-flicker, and what stdin-buffer.ts does to handle raw key events. The regression tests expose the edge cases that forced custom code.

- Repository: earendil-works/pi
- GitHub: https://github.com/earendil-works/pi
- Human wiki: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234
- Complete Markdown: https://grok-wiki.com/public/wiki/earendil-works-pi-8b87608fc234/llms-full.txt

## Source Files

- `packages/tui/src/tui.ts`
- `packages/tui/src/terminal.ts`
- `packages/tui/src/stdin-buffer.ts`
- `packages/tui/src/kill-ring.ts`
- `packages/tui/src/terminal-image.ts`
- `packages/tui/test/tui-render.test.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [packages/tui/src/tui.ts](packages/tui/src/tui.ts)
- [packages/tui/src/terminal.ts](packages/tui/src/terminal.ts)
- [packages/tui/src/stdin-buffer.ts](packages/tui/src/stdin-buffer.ts)
- [packages/tui/src/kill-ring.ts](packages/tui/src/kill-ring.ts)
- [packages/tui/src/terminal-image.ts](packages/tui/src/terminal-image.ts)
- [packages/tui/src/undo-stack.ts](packages/tui/src/undo-stack.ts)
- [packages/tui/src/editor-component.ts](packages/tui/src/editor-component.ts)
- [packages/tui/src/fuzzy.ts](packages/tui/src/fuzzy.ts)
- [packages/tui/src/keybindings.ts](packages/tui/src/keybindings.ts)
- [packages/tui/test/tui-render.test.ts](packages/tui/test/tui-render.test.ts)
- [packages/tui/test/virtual-terminal.ts](packages/tui/test/virtual-terminal.ts)
</details>

# pi-tui: Why Build a Terminal UI Library from Scratch?

`packages/tui` is a self-contained terminal UI engine written in TypeScript for Node.js. Rather than wrapping an existing library like `blessed`, `ink`, or `xterm.js`, the package provides its own virtual line buffer, differential renderer, raw input parser, kill ring, undo stack, fuzzy search, and inline image support. This page examines the constraints that drove each custom piece, how the rendering model avoids screen flicker, and how the stdin buffer turns a stream of raw bytes into discrete, typed key events.

Understanding the design is useful when extending the TUI (adding components, integrating image protocols, or writing editor widgets), when debugging rendering artifacts on unusual terminals, or when evaluating whether to reuse this package in a separate application.

---

## Why not use an off-the-shelf library?

The first question to ask is: what would an existing library have needed to do that it could not?

**Differential output at line granularity.** Most high-level TUI libraries redraw entire regions on every frame. This package tracks `previousLines: string[]` per render cycle and only rewrites the lines that actually changed, wrapping the minimal set of cursor moves and line clears in a single synchronized-output block (`\x1b[?2026h` / `\x1b[?2026l`). A spinner animation that changes one middle line in a 20-line layout only writes that one line.

**Kitty and iTerm2 inline image protocols.** Bitmap images rendered via the Kitty APC graphics protocol or the iTerm2 `ESC]1337` protocol cannot be treated as text: they occupy physical cell rows that the terminal controls, not the application. Off-the-shelf libraries generally ignore these protocols entirely. This package tracks `previousKittyImageIds: Set<number>` across frames and issues explicit delete sequences (`Ga=d,d=I,i=<id>`) before redrawing positions that overlap an existing image placement. Tests verify the ordering is correct: delete must precede redraw.

**Emacs-style keybindings + kill ring.** The `Keybindings` interface in `keybindings.ts` declares 30+ named actions — `tui.editor.yank`, `tui.editor.yankPop`, `tui.editor.deleteToLineStart`, `tui.editor.undo`, and so on — that map to multiple physical key chords. This is a richer binding model than most TUI libraries expose. The kill ring itself (`kill-ring.ts`) supports consecutive-kill accumulation (both forward and backward), which requires the `accumulate`/`prepend` flag on `push()`. No off-the-shelf kill ring with those semantics was available as an isolated module.

**Bracketed paste and Kitty keyboard protocol negotiation.** Input from `stdin` in raw mode is not a stream of "key events"; it is a byte stream where `\x1b[<35;20;5m` (an SGR mouse event) might arrive split across three separate `data` callbacks. The Kitty keyboard protocol further adds key-release events that most components must filter out, and WezTerm emits a double-ESC concatenation (`\x1b\x1b[27;...u`) that needs careful disambiguation. None of this is handled correctly by most general-purpose input parsers.

Sources: [packages/tui/src/tui.ts:241-261](), [packages/tui/src/kill-ring.ts:1-46](), [packages/tui/src/keybindings.ts:1-42](), [packages/tui/src/stdin-buffer.ts:1-18]()

---

## The virtual terminal model in `terminal.ts`

### What is the simplest abstraction needed?

The minimal contract between the render engine and the physical terminal is: write bytes out, read dimensions, handle resize events. The `Terminal` interface in `terminal.ts` captures exactly this:

```typescript
// packages/tui/src/terminal.ts:17-58
export interface Terminal {
  start(onInput: (data: string) => void, onResize: () => void): void;
  stop(): void;
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
  write(data: string): void;
  get columns(): number;
  get rows(): number;
  get kittyProtocolActive(): boolean;
  moveBy(lines: number): void;
  hideCursor(): void;
  showCursor(): void;
  clearLine(): void;
  clearFromCursor(): void;
  clearScreen(): void;
  setTitle(title: string): void;
  setProgress(active: boolean): void;
}
```

This interface separates concerns: `ProcessTerminal` wires up `process.stdin`/`process.stdout`, while `VirtualTerminal` in the test suite wraps `@xterm/headless` for deterministic test assertions. Tests never need a real TTY.

### Where does complexity become necessary?

The `ProcessTerminal.start()` method does more than enable raw mode:

1. **Bracketed paste mode** is enabled immediately: `\x1b[?2004h`. This causes the terminal to wrap paste content in sentinel sequences, allowing the input parser to reassemble multi-line pastes without treating each newline as a submit event.

2. **Kitty protocol negotiation** runs asynchronously. A query (`\x1b[?u`) is sent; if the terminal responds with `\x1b[?<flags>u`, the Kitty protocol is activated with flags 1+2+4 (disambiguate escape codes, report event types, report alternate keys). If no response arrives within 150 ms, `xterm modifyOtherKeys` mode 2 is activated as a fallback — needed for tmux forwarding.

3. **Windows VT input** requires loading a native `.node` module that calls `SetConsoleMode` to add `ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200)` to the console handle. Without this, Shift+Tab arrives as plain `\t` on Windows.

4. **Termux height-change suppression**: a resize that changes only height triggers a full redraw everywhere except Termux, where the software keyboard raising and lowering causes constant height thrash. The environment variable `TERMUX_VERSION` gates this path.

Sources: [packages/tui/src/terminal.ts:92-239]()

### The synchronized output idiom

Every write that touches more than one line is wrapped in `\x1b[?2026h` / `\x1b[?2026l` — the "synchronized output" mode supported by Kitty, WezTerm, and newer xterm builds. The terminal holds the frame until the closing marker arrives, preventing the user from seeing intermediate cursor-movement states. This is the primary mechanism by which the differential renderer avoids visible flicker.

Sources: [packages/tui/src/tui.ts:985](), [packages/tui/src/tui.ts:1144-1145](), [packages/tui/src/tui.ts:1230]()

---

## Differential rendering in `tui.ts`

### How does the virtual line buffer work?

`TUI` extends `Container`, which recursively calls `render(width: number): string[]` on all children. The result is an array of strings, each representing one terminal row. After overlays are composited, the current array is compared against `this.previousLines` line by line:

```
for (let i = 0; i < maxLines; i++) {
    if (previousLines[i] !== newLines[i]) {
        track firstChanged / lastChanged
    }
}
```

Only the range `[firstChanged, lastChanged]` is rewritten. The cursor is moved from its tracked position (`hardwareCursorRow`) to `firstChanged`, then each changed line is cleared with `\x1b[2K` and the new content written. Lines outside the changed range are untouched.

```text
Frame N:   ["Header", "Working |", "Footer"]
Frame N+1: ["Header", "Working /", "Footer"]

Changed range: [1, 1]
Actions:  move to row 1 → clear line → write "Working /"
Untouched: rows 0 and 2
```

Sources: [packages/tui/src/tui.ts:953-1087](), [packages/tui/src/tui.ts:1171-1209]()

### When does the engine fall back to a full redraw?

The differential path has several guards that escalate to `fullRender(clear: true)`:

| Trigger | Reason |
|---|---|
| Width changed | Text wrapping changes; all line lengths are invalid |
| Height changed (non-Termux) | Visible viewport alignment changes |
| `firstChanged < prevViewportTop` | Changed lines are above the currently visible scroll window |
| Deleted lines shift the viewport up | Cursor tracking breaks when content moves out of the viewport |
| Content shrinks past `maxLinesRendered` (opt-in) | Stale lines below new content must be erased |

The test `"full re-renders when deleted lines move the viewport upward"` pins the specific scenario where 12 lines shrink to 7 in a 5-row terminal: the viewport anchor changes, so a full redraw is required.

Sources: [packages/tui/src/tui.ts:1028-1141](), [packages/tui/test/tui-render.test.ts:484-503]()

### Hardware cursor positioning for IME

When a focused component emits `CURSOR_MARKER` (`\x1b_pi:c\x07` — an APC sequence that terminals ignore) at its cursor position, `TUI` scans the bottom `height` rendered lines for this marker, calculates the visual column from the text before it using `visibleWidth()`, strips the marker from the line, then moves the hardware cursor to that exact cell. This ensures the IME candidate window appears at the text insertion point even though the TUI normally hides the hardware cursor.

Sources: [packages/tui/src/tui.ts:88-90](), [packages/tui/src/tui.ts:933-951](), [packages/tui/src/tui.ts:1287-1318]()

### Kitty image lifecycle

Images add a lifecycle dimension that pure text does not have. The Kitty protocol places a bitmap into the terminal's image store identified by a 32-bit `imageId`. If the same ID is placed again at a different position (e.g., because the TUI content reflows), the old placement remains until explicitly deleted. The TUI therefore:

1. Collects all `imageId` values from `previousLines` and `newLines`.
2. Before writing changed lines, issues `deleteKittyImage(id)` for every ID that appears in the changed range of `previousLines`.
3. After a full redraw, issues delete sequences for all IDs from `previousKittyImageIds`.

Test `"deletes changed image ids before drawing moved placements"` verifies the ordering: `deleteIndex < drawIndex` in the raw write stream.

Sources: [packages/tui/src/tui.ts:832-872](), [packages/tui/test/tui-render.test.ts:68-145]()

---

## `stdin-buffer.ts`: parsing raw key events

### What problem does it solve?

Node.js delivers stdin in `data` events that can split any escape sequence across multiple callbacks. The SGR mouse sequence `\x1b[<35;20;5m` might arrive as three separate events. If forwarded directly to a key handler, `\x1b` alone looks like the Escape key, breaking all subsequent input until the terminal recovers.

`StdinBuffer` accumulates incoming bytes in a string buffer and runs `extractCompleteSequences()` to identify sequence boundaries before forwarding events.

### How does sequence detection work?

The state machine in `isCompleteSequence()` classifies the sequence type from the second byte after ESC:

```text
ESC [  → CSI sequence — ends at byte in 0x40–0x7E range
ESC ]  → OSC sequence — ends at ESC \ or BEL (\x07)
ESC P  → DCS sequence — ends at ESC \
ESC _  → APC sequence — ends at ESC \
ESC O  → SS3 — ESC O + one byte
ESC <single char> → Meta key — complete
```

SGR mouse sequences (`<digits;digits;digits[Mm]`) require a more specific pattern match because the final byte `M` or `m` alone is not a reliable terminator — the parser waits for the full three-field numeric pattern.

```typescript
// packages/tui/src/stdin-buffer.ts:104-119
if (payload.startsWith("<")) {
    const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
    if (mouseMatch) return "complete";
    // partial SGR mouse: wait for more bytes
    if (lastChar === "M" || lastChar === "m") { ... }
    return "incomplete";
}
```

Sources: [packages/tui/src/stdin-buffer.ts:29-126]()

### The WezTerm double-ESC edge case

WezTerm with `enable_kitty_keyboard` sends the Escape key press as a raw `\x1b` byte (simple text path) and the key release as a full CSI-u sequence, concatenated: `\x1b\x1b[27;...u`. The naive parser sees `\x1b\x1b` as a complete meta-key sequence (ESC + ESC) and leaves `[27;...u` as stray text. The buffer handles this explicitly:

```typescript
// packages/tui/src/stdin-buffer.ts:216-229
if (candidate === "\x1b\x1b") {
    const nextChar = remaining[seqEnd];
    if (nextChar === "[" || nextChar === "]" || nextChar === "O"
        || nextChar === "P" || nextChar === "_") {
        sequences.push(ESC);  // emit first ESC alone
        pos += 1;
        break;  // restart from second ESC
    }
}
```

Sources: [packages/tui/src/stdin-buffer.ts:207-232]()

### Bracketed paste reassembly

Paste content wrapped in `\x1b[200~`…`\x1b[201~` is extracted from the byte stream and re-emitted as a `paste` event rather than individual keystrokes. `ProcessTerminal` re-wraps the content in the bracketed markers before forwarding to the input handler, ensuring existing editor handling that recognizes those sentinels continues to work.

Sources: [packages/tui/src/stdin-buffer.ts:315-369](), [packages/tui/src/terminal.ts:167-170]()

### Kitty printable codepoint deduplication

The Kitty protocol reports a printable character as both a raw Unicode codepoint (e.g., `a`) and a CSI-u sequence (`\x1b[97u`). The buffer detects the CSI-u form, extracts its codepoint via `parseUnmodifiedKittyPrintableCodepoint()`, and suppresses the immediately following raw codepoint if it matches. This prevents every keystroke from being delivered twice.

Sources: [packages/tui/src/stdin-buffer.ts:183-397]()

---

## Undo stack and kill ring

### `UndoStack<S>`

The undo stack uses `structuredClone` to snapshot arbitrary state objects. The caller pushes the entire editor state before each mutation; undo pops the most recent snapshot and restores it. Because `structuredClone` is used at push time, pops return already-detached objects with no additional copying.

```typescript
// packages/tui/src/undo-stack.ts:11-13
push(state: S): void {
    this.stack.push(structuredClone(state));
}
```

Sources: [packages/tui/src/undo-stack.ts:1-28]()

### `KillRing`

The kill ring stores deleted text strings in a ring array and supports two behaviors that Emacs users expect:

- **Consecutive kill accumulation**: if `opts.accumulate` is true on successive kills, the new text is merged into the existing top entry rather than pushed as a new entry. `opts.prepend` controls direction — backward deletion (`Ctrl+Backspace`) prepends; forward deletion (`Ctrl+Delete`) appends.
- **Yank-pop cycling**: `rotate()` moves the last entry to the front, allowing repeated `yank-pop` (typically `Alt+Y`) to cycle through earlier kills.

Sources: [packages/tui/src/kill-ring.ts:1-46]()

---

## Inline image display

`terminal-image.ts` provides capability detection, encoding, and cell-size calculation for two image protocols:

| Protocol | Detection | Sequence prefix |
|---|---|---|
| Kitty graphics | `KITTY_WINDOW_ID`, `TERM_PROGRAM=kitty`, `ghostty`, `wezterm` | `\x1b_G` (APC) |
| iTerm2 inline | `ITERM_SESSION_ID`, `TERM_PROGRAM=iterm.app` | `\x1b]1337;File=` |
| None (fallback) | tmux/screen, vscode, alacritty, unknown | — |

tmux and screen always receive `images: null` because they swallow OSC sequences by default.

Cell size for aspect-ratio-correct scaling is obtained at runtime by sending `CSI 16 t` and parsing the response `CSI 6 ; height ; width t`. Until the response arrives, a default of 9×18 pixels per cell is used. When the response arrives, all components are invalidated and a re-render is triggered.

Large images are chunked into 4096-byte base64 segments with Kitty's `m=1` (more chunks follow) / `m=0` (final chunk) framing.

Sources: [packages/tui/src/terminal-image.ts:42-86](), [packages/tui/src/terminal-image.ts:126-170](), [packages/tui/src/tui.ts:463-471]()

---

## The test harness

The test suite avoids real TTYs by implementing `VirtualTerminal` backed by `@xterm/headless`. This gives tests a real terminal emulator with accurate cursor tracking and escape-sequence interpretation, so assertions can query cell contents (`getViewport()`, `getCursorPosition()`, `isItalic()`) rather than parsing raw escape sequences.

`waitForRender()` accounts for the TUI's throttled render pipeline (minimum 16 ms between frames, `process.nextTick` scheduling):

```typescript
// packages/tui/test/virtual-terminal.ts:213-217
async waitForRender(): Promise<void> {
    await new Promise<void>((resolve) => process.nextTick(resolve));
    await new Promise<void>((resolve) => setTimeout(resolve, 20));
    await this.flush();
}
```

The regression tests expose specific edge cases that motivated custom code:

- **Style isolation** (`"resets styles after each rendered line"`): an italic span in line 0 must not bleed into line 1. The render path appends `\x1b[0m\x1b]8;;\x07` after every non-image line.
- **Stale content from transient components**: a selector overlay that temporarily inflates `maxLinesRendered` must be fully cleared when it disappears, even if the component below it did not change.
- **Termux height-change suppression**: height resize must not trigger `\x1b[2J` (screen clear) on Termux because the software keyboard hides and shows constantly.
- **Image delete-before-draw ordering**: a Kitty image placement that moves between frames must have its old placement deleted before the new one is written, or two copies appear on screen.

Sources: [packages/tui/test/tui-render.test.ts:366-590](), [packages/tui/test/virtual-terminal.ts:11-218]()

---

## Summary

`packages/tui` is not a "nice to have" abstraction over ncurses. It exists because the application required a specific combination that no existing library delivered: differential line-level rendering with synchronized output, Kitty/iTerm2 inline image lifecycle management, raw Kitty keyboard protocol negotiation with WezTerm-specific quirks, bracketed paste reassembly from split `stdin` chunks, and an Emacs kill ring with directional accumulation. The virtual terminal interface (`Terminal`) keeps every non-trivial behavior testable against an `@xterm/headless` emulator without a real TTY. The regression suite in `tui-render.test.ts` is the clearest record of which edge cases were hard enough to require custom code rather than a general-purpose solution.

Sources: [packages/tui/src/tui.ts:239-280](), [packages/tui/src/stdin-buffer.ts:274-435](), [packages/tui/test/tui-render.test.ts:535-590]()
