The pi-tui Engine
Differential rendering with zero dependencies
“Synchronized output for atomic frames; paste markers survive as atomic graphemes”
Deep Dive
Differential rendering, zero dependencies — synchronous output creates flicker-free frames
Learning Objectives
After this chapter you will be able to:
- Explain how differential rendering + DECSET 2026 synchronized output achieves zero-flicker frames
- Describe pi-tui's zero-dependency strategy and its significance for Bun compilation
- Explain how the editor handles IME, paste markers, and KillRing
- Understand the advantages of the Kitty keyboard protocol over traditional terminal input
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/tui/src/tui.ts | 1719 | Core engine: render loop, diffing, component tree |
packages/tui/src/components/editor.ts | 2351 | Multi-line editor: wrap, IME, autocomplete, UndoStack |
packages/tui/src/keys.ts | 1401 | Kitty keyboard protocol parsing |
packages/tui/src/terminal.ts | 531 | Terminal abstraction: size, capability detection |
packages/tui/src/autocomplete.ts | 786 | Autocomplete: slash commands + file paths |
Design Motivation: Why Not Ink/Blessed?
Problem: The Node.js ecosystem has Ink (React for CLI) and Blessed. But pi's requirements are special:
- Extremely high update frequency (streaming tokens at 50+ per second)
- Zero dependencies (Bun single-file compilation)
- Precise cursor control (multi-line editor + IME)
- Synchronous output (no flicker allowed)
Ink's issue: Built on the React reconciler — diffs the entire virtual DOM each frame. Too slow for 50fps streaming. Blessed's issue: Depends on ncurses native bindings — Bun does not support native addons.
pi's solution: Build pi-tui from scratch, depending only on marked (Markdown parsing) and get-east-asian-width (CJK character width).
Analogy: Like GPU VSync — the frame buffer swap is atomic; the user never sees a half-drawn frame.
Differential Rendering + Synchronized Output
// tui.ts render loop (conceptual)
function render(root: Component) {
const newFrame = compose(root) // component tree → line array
// Decide rendering strategy
if (isFirstFrame || widthChanged || root.invalidated) {
drawFull(newFrame) // full redraw
} else {
drawDiff(prevFrame, newFrame) // only changed lines
}
prevFrame = newFrame
}
function drawDiff(prev: string[], next: string[]) {
// ① Begin synchronized region (DECSET 2026)
write("\x1b[?2026h")
// ② Emit only changed lines
for (let i = 0; i < next.length; i++) {
if (next[i] !== prev[i]) {
write(`\x1b[${i + 1};1H`) // cursor positioning
write(next[i]) // write that line
}
}
// ③ End synchronized region → terminal flushes all at once
write("\x1b[?2026l")
}
What is DECSET 2026? A terminal "double buffer" — all output between \x1b[?2026h and \x1b[?2026l is buffered by the terminal, displayed atomically at the end marker. Users see a complete frame, not a line-by-line tearing refresh.
Editor: 2351 Lines of Complexity
editor.ts feature matrix:
├── Multi-line input + word-wrap
├── IME support (CJK input methods)
├── Autocomplete
│ ├── /slash command completion
│ └── File path completion (Tab trigger)
├── KillRing (Emacs-style Ctrl+K/Y)
├── UndoStack (Ctrl+Z / Ctrl+Shift+Z)
└── Paste markers
└── Large paste → [paste #1 +50 lines]
Treated as atomic grapheme — never split
Paste markers: When a user pastes 50 lines, the editor does not insert character-by-character (too slow). Instead it generates a [paste #1 +50 lines] marker. Internally this marker is an atomic grapheme — backspace deletes the entire marker, cursor jumps over it as one unit.
Kitty Keyboard Protocol
// keys.ts parses the Kitty protocol
type Key =
| { type: "enter"; shift: boolean; alt: boolean }
| { type: "char"; char: string; ctrl: boolean; alt: boolean }
| { type: "named"; name: "tab" | "escape" | ...; shift: boolean }
// Traditional terminal: Ctrl+Shift+C and Ctrl+C are indistinguishable
// Kitty protocol: every keypress carries full modifier information
function parseKittySequence(seq: string): Key {
// CSI 57345;2;1u → char='c', ctrl=true, shift=true
}
Why Kitty protocol? Traditional terminal key encoding is ambiguous — Tab and Ctrl+I produce the same byte. The Kitty protocol uses CSI u sequences to disambiguate, letting pi distinguish Ctrl+Enter (steer) from Alt+Enter (followUp).
Engineering Insights
-
tui.ts is 1719 lines but the core render loop is only ~200 lines. The rest is component layout (flex-like), scroll region management, and focus system. Far simpler than React — no hooks, no fiber, no scheduler.
-
The cost of zero dependencies: Everything is hand-written — ANSI escape sequence parsing, Unicode width calculation, terminal capability detection. But the payoff is: Bun compilation with no native dependencies, startup <50ms.
-
Component caching: Each component caches its render result and only redraws after
invalidate(). During streaming, only the "current message" component invalidates — all others have zero overhead.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch16 Interactive TUI | interactive-mode uses pi-tui for rendering |
| Follow-up | Ch18 Settings & Trust | output-guard cooperates with the render cycle |
| Follow-up | Ch13 Extensions | Extensions can register custom render components |
| Related | Ch09 Streaming | Streaming tokens drive high-frequency invalidate |
Check Your Understanding
- If DECSET 2026 synchronized output were removed, what would users see during fast streaming output?
- Why must paste be treated as an "atomic grapheme"? What would backspace behavior look like otherwise?
- What are the trade-offs of pi-tui's zero-dependency choice? In what scenarios might this choice be inappropriate?
Takeaways
- Differential rendering + DECSET 2026 synchronized output = zero-flicker frames
- Zero dependencies (only marked + east-asian-width) — Bun compilation friendly
- Paste as atomic grapheme; IME/KillRing/Undo fully supported
- Kitty keyboard protocol eliminates key ambiguity
This chapter is based on the main-branch source of earendil-works/pi.