Part II Chapter 6: The Dispatch Desk and the Workstations — pi-tui

Chapter guide: Stop ⑤ on the source route (product layer). We leave the core layer and switch to
pi-tui, a package in the foundation layer, to readtui-main-screen.ts,stdin-buffer.ts, andutils.ts. This stop steps away from models and loops to look at why the terminal, this "canvas", cannot be painted on freely: differential rendering, display columns, and incomplete control sequences — each governs something.
Xiao A wrote a few lines of rendering code in his own little terminal tool, and found the output jumping around, then flickering, with the cursor parked in strange places. He stared at the screen. "Isn't the terminal just a canvas? Why can't I draw on it however I like?"
"That is exactly the problem: the terminal is not a canvas you can overwrite at will," Lao Z said. "Width is counted in display columns, input can arrive carrying an incomplete control sequence, and older terminals may not support newer protocols. If you want your own interface to stay stable, first look at how pi-tui faces these constraints."
This chapter reads those constraints along pi-tui's rendering and input paths. All source evidence comes from the upstream pi tag v1.0.0 (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32, package version 1.0.0, 2026-10-01); it does not cover every built-in component, theme, or image protocol.
Components never touch stdin directly
"Let's enter through the public interface." Lao Z opened packages/tui/src/index.ts, which exports the components, the TUI / Component interfaces, TuiMainScreen, TuiAltScreen, and ProcessTerminal. The core contract is Component in packages/tui/src/tui.ts: a component returns terminal lines via render(width) and may optionally implement handleInput; TuiBase owns focus, overlays, input listeners, and scheduling the render.
Metaphor: the dispatch desk and the workstations
Components are workstations, and
TuiBaseis the dispatch desk: a workstation only states what it wants to be drawn as (it returns an array of lines); when to refresh is decided by the dispatch desk.
The normal path is the simplified call chain below:
ProcessTerminal.start(onInput, onResize)
→ TuiBase.start() / requestRender()
→ TuiMainScreen.doRender() or TuiAltScreen.doRender()
→ render(width) across the component tree
→ terminal.write(...ANSI output...)This is a summary of the source structure; overlays and debug branches are omitted. TuiBase.start() registers the two callbacks into ProcessTerminal.start(): data goes to handleTerminalInput, resize goes to requestRender(). One link on this chain is easy to misread: the resize callback itself does not repaint, it only calls requestRender(). Whether the update is full or differential is only settled later, in doRender(). requestRender() has two paths, matching two call strengths:
requestRender(force=false) requestRender(force=true)
→ already requested? return (merge) → resetRenderState() (clear old-frame ledger)
→ renderRequested = true → drop any pending render timer
→ process.nextTick(scheduleRender) → process.nextTick(doRender)
scheduleRender():
compute delay = max(0, 16ms - time since the last render)
→ when setTimeout(delay) fires:
renderRequested = false
doRender() ← full or differential is decided here
if a request arrived during doRender → scheduleRender() again"I'd guess the difference between the two paths is force: it skips the merge and repaints directly?"
"Only half right — force also clears the old ledger," Lao Z said. "It calls resetRenderState() first, which drops the old-frame references such as previousLines. It is usually used for a forced full rebuild after a theme change or a size-related event. An ordinary request only sets renderRequested and relies on the MIN_RENDER_INTERVAL_MS = 16 minimum interval to merge: ten requests inside a 16ms window still count as one render. Also, force still goes through process.nextTick — it is not a synchronous screen flush."
"What about input? How does the focused component get the data?"
"handleTerminalInput first lets the registered listeners run — a listener can return { consume: true } to swallow the input or { data } to rewrite it — then hands what is left to the focused component, and calls requestRender() along the way," Lao Z said. "Two details: first, key release sequences are filtered out unless the focused component declares wantsKeyRelease; second, if the focused component is an overlay, it first confirms the overlay is still visible, and if it is not, focus is reassigned. So components do not manage stdin or the global cursor directly — they share this one lifecycle."
"What if I just want to print a log line inside a component for debugging?"
"You can write to stdout, but once you bypass the renderer, the old-frame and cursor ledgers are no longer trustworthy, and the screen will show residue you cannot control," Lao Z said.
"This ledger — what exactly does it refer to?"
"TuiMainScreen keeps a chain of state: previousLines, cursorRow, hardwareCursorRow, maxLinesRendered, previousViewportTop. The next differential update infers whether to move the cursor up or down entirely from these fields," Lao Z said. "If you console.log one line, the terminal cursor moves but these fields do not move at all. The next differential frame computes lineDiff against the old ledger, the cursor lands on a wrong row, and then come the flicker and the misalignment."
"So debugging has to go through the rendering layer."
"Right," Lao Z said. "PI_TUI_DEBUG_REDRAW=1 appends the reason for each fullRender to pi-tui-debug.log in the log directory — provided the host passed logDirectory when constructing the TUI; if it did not, nothing is recorded. PI_TUI_DEBUG=1 dumps the context of every differential render to /tmp/tui. Both are observation points inside the rendering layer; they do not corrupt the ledger."
The main screen references the previous frame
Xiao A: "Why not just clear the screen and redraw every time?"
"A full repaint is intuitive to implement, but it produces more output, and scrollback and visual continuity both get worse," Lao Z said. "TuiMainScreen's answer is to use the previous frame as the reference: identical lines are not rewritten; only changed lines are updated through cursor movement, erasing, and writing."
// packages/tui/src/tui-main-screen.ts, TuiMainScreen.doRender() (v1.0.0, structured pseudocode, not line-by-line source)
let newLines = this.render(width);
if (this.previousLines.length === 0 || widthChanged || heightChanged) {
fullRender(/* whether to clear the screen depends on the branch */);
} else {
// find the changed range between previousLines and newLines, move the cursor, erase and write the changed lines
}
this.previousLines = newLines;"The point is not 'comparing strings' by itself. It is maintaining width and ANSI state before the comparison, and updating previousLines after it." Lao Z pointed at the pseudocode. "The resize callback only calls requestRender(). Whether a size change requires a full repaint is decided by doRender(), after it compares the current and the previous width and height. Do not attribute that decision to the callback's arguments."
"Why must a width change force a full repaint? Is a height change the same?"
"Width and height are handled differently," Lao Z said. "widthChanged always goes to fullRender(true), because the wrap width changed and every line may wrap into a different shape — a diff cannot compute that. heightChanged also defaults to a full repaint, with one exception: when isTermuxSession() returns true it skips the full render. Termux changes the height repeatedly as the soft keyboard opens and closes; a full repaint each time would replay the entire history, and the experience collapses."
"What if the content gets shorter?"
"Residual old lines and trailing-line cleanup are handled in this same function," Lao Z said. "When getClearOnShrink() is true, and newLines.length < maxLinesRendered, and there is no overlay, it triggers fullRender(true) to clear the blank lines. maxLinesRendered is a high-water mark: it only grows, never shrinks, until the screen is cleared, so a shortening is recognized."
"That boundary about 'throw if a line is over width' — what is it guarding?"
"It is guarding against a broken component contract," Lao Z said. "In the differential-update branch, a non-image line with visibleWidth(line) > width writes pi-tui-crash.log (or the system temp directory when there is no log directory), calls stop(), and then throws. The reason is that the differential update infers cursor movement from hardwareCursorRow and computeLineDiff. Once one line actually occupies two rows, every line number after it is wrong, and rather than keep emitting misaligned content it stops early. This check exists only in the differential-update branch: the fullRender() path does not check it, because that path is itself a full rebuild."
"Is the differential update a promise made to every terminal?"
"It is a confirmed implementation fact; it does not guarantee that no terminal will show a visual flicker," Lao Z added. "The same file also sends \x1b[?2026h / \x1b[?2026l in pairs, wrapping one batch of updates inside a single synchronous interval. v1.0.0 no longer assembles the whole frame into one string and writes it out in a single call; instead it writes in 1 MiB chunks through BoundedTerminalWriter, and when it splits a chunk it does not break a UTF-16 surrogate pair. A comment says this is to avoid producing a string over V8's limit during a full repaint. The code also keeps special handling for the terminal environment and for image lines — terminal output is not always plain text lines."
Spreading out the branches of doRender() shows more clearly which decision belongs to whom:
Branch order in doRender() (v1.0.0, TuiMainScreen):
| condition | action | reason |
| --- | --- | --- |
| first render (previousLines empty) | fullRender(false), no screen clear | assume the screen is clean on entry |
| widthChanged | fullRender(true) | the wrap width changed, so every line may wrap differently |
| heightChanged and not Termux | fullRender(true) | the viewport must realign; Termux keyboard open/close excluded |
| clearOnShrink and content shrank and no overlay | fullRender(true) | clear residue from old lines when the high-water mark falls |
| firstChanged >= newLines.length | only erase the extra lines, rewrite nothing | every change is a deletion |
| firstChanged < prevViewportTop | fullRender(true) | the diff may only touch lines inside the current viewport |
| everything else | diff: erase lines, write the changed lines | minimize output and flicker |"Why does firstChanged < prevViewportTop also force a full repaint?" Xiao A asked.
"Because the differential update assumes it can 'start writing from some line and have the cursor follow the arithmetic all the way'. Once a line to change sits above the viewport, moving the cursor upward scrolls the previous screen back into view — rather than guess, rebuild," Lao Z said. "It is the same kind of decision as 'throw on an over-wide line': the diff is a shortcut with preconditions. When a precondition fails, fall back to a full render or stop. Do not force the arithmetic on a broken premise."
The alt screen is a viewport, not another switch
Xiao A: "What about the alt screen? Is that just another switch for clear-and-redraw?"
"It isn't." Lao Z opened packages/tui/src/tui-alt-screen.ts:
override render(width: number): string[] {
return this.layoutRoot?.render(width) ?? super.render(width);
}
setLayoutRoot(component: Component | undefined): void {
this.layoutRoot = component;
this.requestRender();
}"TuiAltScreen implements ViewportTUI. It first picks the layout root or the ordinary component tree, and then doRender() clips and lays out against the current terminal height. setLayoutRoot changes rendering state; it does not write to the screen directly. The refresh entry point is the same one TuiBase uses."
"Is its diff logic the same as the main screen's?"
"Not entirely," Lao Z said. "The main screen diffs over the whole line buffer, which can be taller than the terminal and push lines into scrollback. TuiAltScreen's doRender() first computes the layout with renderLayoutFrame, then, if screen.length > height, applies slice(screen.length - height) to clip down to viewport size and draws only the height lines on screen, using absolute positioning \x1b[${row+1};1H instead of relative cursor moves \x1b[B/A. This is also why the alt screen needs ENTER_ALT_SCREEN plus DISABLE_AUTOWRAP: it assumes it has full control of the whole screen."
"So as I hear it, the main screen continues drawing after existing content, and the alt screen takes over a full screen of its own."
"You can put it that way. The main screen tries hard to preserve what a normal terminal already has; the alt screen treats the interface as a viewport bounded by the terminal size. The two implementations target different presentation boundaries, but focus and input scheduling both go through TuiBase."
TuiMainScreen TuiAltScreen
reference: previousLines (whole line buffer) reference: previousScreen (current viewport)
may push lines into scrollback draws only height lines; slice clips the rest
relative cursor moves \x1b[B / \x1b[A absolute positioning \x1b[${row+1};1H
over-wide line: the diff branch throws over-wide line: sliceByColumn(..., strict) truncates
no enter/exit protocol enter ?1049h + disable autowrap; exit ?1049l
input: keyboard + optional listeners input: keyboard + mouse + wheel + selection + scrollbar"Why can the alt screen truncate instead of throwing?"
"Over-wide means the component and the viewport width are not aligned. Rather than let absolute positioning scribble over the screen, clip the line into the viewport width," Lao Z said. "The main screen's diff infers movement from the cursor ledger, and an over-wide line breaks every line number after it, so it chooses to stop. One constraint, two implementations, different fault-tolerance strategies."
"Are the alt screen's mouse, selection, and scrollbar also inside TuiAltScreen?"
"They are. doRender() lays out with renderLayoutFrame first, then composites overlays, clips to the viewport, layers the selection on top, layers the flash on top, and only then writes the screen; on the input side handleViewportInput handles focus in/out, the scroll wheel, scrollbar dragging, and selection," Lao Z said. "The price is that beforeTerminalStop / afterTerminalStop have to clean up by hand: turn off search, selection, and scrollbar dragging, delete Kitty images, turn off mouse reporting, re-enable autowrap; and after leaving the alt screen, truncate the last document to the terminal width and dump it back into the main screen's scrollback. v1.0.0 added a preserveScreen option, which skips that dump when another TUI takes over the same terminal. If this exit sequence is interrupted, the main screen keeps half-finished content."
Width is not string length
Xiao A: "The width that render(width) receives — is that the string length?"
"It is display columns, not a character count," Lao Z said. "visibleWidth(), sliceByColumn(), truncateToWidth(), and wrapTextWithAnsi() in packages/tui/src/utils.ts process text by display columns when ANSI sequences, wide characters, combining characters, or hyperlink control sequences are present. packages/tui/src/layout.ts uses these functions to measure, clip, and draw layout boxes; for example renderLayoutFrame() computes the visible region for a scroll view and draws the scrollbar."
"Give an example of the difference between display columns and a character count."
"The Chinese character '中' is one character, one UTF-16 code unit, and occupies two display columns; the emoji '🦀' is two UTF-16 code units and occupies two display columns; a combining sequence with a variation selector can be several code units and occupy only one column," Lao Z said. "Compute width from string length and treating '🦀' as 2 columns happens to be right; treating \x1b[31m红\x1b[0m with its ANSI color codes as 10 columns is entirely wrong, because those 9 control code units are zero-width. visibleWidth strips the ANSI sequences and measures graphemes, giving 2 columns."
input UTF-16 units columns
"abc" 3 3 (the ASCII fast path returns directly)
"\x1b[31m红\x1b[0m" 10 2 (the 9 control code units are zero-width)
"中" (UTF-16) 1 2 (East Asian Width is 2)
"🦀" 2 2 (surrogate pair, width 2)
"\t" 1 3 (the implementation normalizes a tab to 3 spaces)"So visibleWidth is not simply str.length?"
"It isn't. It first takes an ASCII fast path: each printable ASCII character counts as 1 column, a tab as 3 columns, ANSI sequences are skipped as whole segments, and it gives up only on some other character. A comment explains that this way, when a theme change repaints colored pure-ASCII lines, no grapheme segmentation is needed. If the fast path misses, it checks a width cache; if that misses, it does three things: normalize tabs into 3 spaces, use extractAnsiCode to skip ANSI/OSC/APC sequences as whole segments, then split the remaining text by grapheme and accumulate widths," Lao Z said. "The result goes into a bounded cache. This 'fast first, then slow, degrading layer by layer' order is also a common trade-off on the rendering hot path."
"What if a Chinese character gets cut in the middle?"
"sliceByColumn uses getGraphemeCellRange to map a column number back onto a grapheme boundary before cutting, so it never splits a wide character in half," Lao Z said. "That is why the hard boundary 'throw on an over-wide line' exists in the differential update: a component is supposed to use these functions to clip itself to width. If a line still exceeds it, the component did not keep the contract, and the cursor movement the diff computes is no longer trustworthy."
Key point:
visibleWidth()ANSI sequences are zero-width and wide characters occupy the correct number of columns, so "character index" and "terminal column" are never conflated.
On the boundary path the alt screen differs in one more place: an over-wide line is truncated directly with sliceByColumn(line, 0, width, true) inside afterTerminalStop, without throwing.
Input must first restore message boundaries
Xiao A: "Keyboard presses always arrive one at a time, right?"
"Not necessarily." Lao Z opened packages/tui/src/stdin-buffer.ts. "A single keypress can be split across several chunks, and one chunk can carry half a control sequence. StdinBuffer recognizes whether control sequences such as CSI, OSC, DCS, and APC are complete, and emits only complete fragments. An incomplete sequence stays in the buffer and is not handed to the editor, which avoids treating one keypress or terminal reply that arrived in pieces as several inputs."
"What if a complete sequence never arrives? Does it just hang?"
"It doesn't," Lao Z said. "While residue remains in the buffer, process() sets a timer; when it fires, flush() pushes the remaining content out forcibly as one sequence. v1.0.0 uses two wait times: if the residue is only a bare ESC, it uses escapeTimeout, 10ms by default, and on expiry treats it as the Escape key; for other incomplete sequences, such as a CSI or a mouse sequence, it uses timeout, 50ms by default. A comment recommends raising escapeTimeout in high-latency environments like SSH, so Alt+key is not split into Escape plus a plain character. This is a timeout fallback: when the terminal sends half a sequence and never follows up, the user at least sees the input rather than having input die completely."
"Are there other boundaries?"
"Several worth recording," Lao Z said. "One is bracketed paste: on \x1b[200~ it enters paste mode and accumulates content into pasteBuffer until \x1b[201~, then emits the whole thing from a paste event, so control codes in the middle are not treated as keypresses. Two is high-byte compatibility: a single byte > 127 is converted into ESC + (byte - 128), matching the legacy meta-key encoding. Three is WezTerm's Kitty keyboard quirk: it sends Escape-press as a bare \x1b and only sends the full CSI-u on release; when the buffer detects \x1b\x1b and the next character is one of [/]/O/P/_, it emits only the first ESC and restarts from the second, so the release sequence is not typed out as ordinary text."
"The focused component gets complete input and only then interprets it," Lao Z continued. "components/input.ts uses grapheme segmentation and display columns to move the cursor; Editor in components/editor.ts handles multiline text, paste markers, undo, completion, wrapping, and input events. They reuse visibleWidth(), so a character index and a terminal column always stay separate."
"StdinBuffer decides 'complete' separately per sequence family — which families?"
"Five families by prefix, plus ordinary characters," Lao Z said, and expanded the decision table:
sequence family how "complete" is decided
CSI (ESC [ …) last byte in 0x40–0x7E; an SGR mouse <n;n;n[Mm] needs a pattern match
OSC (ESC ] …) ends with ST (ESC \) or BEL (\x07)
DCS (ESC P …) ends with ST (an XTVersion-style reply)
APC (ESC _ …) ends with ST (a Kitty graphics reply)
SS3 (ESC O x) one character after ESC O
meta key (ESC x) one character after ESC
legacy mouse (ESC[M…) ESC[M plus exactly 3 bytes"CSI has an exception: the last byte being in range is not enough. An SGR mouse sequence starting with < must match \d+;\d+;\d+[Mm] exactly, otherwise it keeps waiting," Lao Z said. "Because if a half sequence like \x1b[<35;20;5 is let through as an ordinary CSI, its three numbers get broken into scattered input. The decision logic separates 'looks complete' from 'is actually complete', and would rather wait for one more chunk."
The responsibility table for input, terminal, and width
| state or responsibility | owning symbol | normal semantics | failure or boundary semantics |
|---|---|---|---|
| raw stdin and resize | ProcessTerminal.start() | register callbacks and enter terminal mode | stop() must restore terminal state |
| split control sequences | StdinBuffer | emit only complete sequences | incomplete sequences stay in the buffer, not handed to the editor |
| focused input | TuiBase.handleInput() | hand to the focused component after the listeners | release events can be filtered per component |
| display columns | visibleWidth() | ANSI zero-width, wide characters occupy the correct columns | over-wide lines trigger a diagnostic rather than silent wrapping |
| optional capabilities | terminal/image helpers | use the enhanced protocol when available | fall back to text or a degraded path when not |
Terminal in packages/tui/src/terminal.ts is the dependency-inversion boundary: the TUI depends only on minimal terminal capabilities, and ProcessTerminal is one implementation over the process's stdin/stdout. Whether terminal colors, the Kitty keyboard, images, or synchronous output are available has to be detected separately; you cannot infer from the Terminal interface that a given terminal supports them.
Design costs and what is not covered
"This layering separates terminal I/O, scheduling, layout, and component editing behavior, which makes it easy to replace or test one layer," Lao Z said. "The cost is more interfaces and tightly coupled state: defects involving overlays, the mouse, or ANSI state are not easy to find from a string snapshot alone."
"Why can't a string snapshot find them?"
"Because the output of a differential update depends on the runtime ledger: hardwareCursorRow, maxLinesRendered, and previousViewportTop roll forward frame by frame. Looking at one frame's buffer string alone, you do not know why it started writing at line 5 or why it moved the cursor to line 12," Lao Z said. "PI_TUI_DEBUG_REDRAW and PI_TUI_DEBUG exist to close exactly this gap: one records the reason for a fullRender, the other dumps each frame's ledger, the old and new lines, and the number of characters written. Image capabilities, keybinding configuration, Markdown rendering, and the details of every component are outside this chapter's coverage; readers can keep locating things from the export table in index.ts."
"So what does this layering actually save, and what does it make expensive?"
"What it saves is interface stability: terminal I/O, scheduling, layout, and component editing behavior each own one segment, so replacing ProcessTerminal or injecting a test double terminal touches only one boundary. What gets expensive is the cost of reasoning: one change to a widthChanged check inside doRender() has the whole frame's output in its blast radius, and the old-frame state it depends on is spread across several private fields. That is also why the debug switches exist: when there is so much state that you cannot compute the outcome by reading the code with your eyes alone, you need observation points that explicitly emit the intermediate state," Lao Z said. "If you build a terminal interface yourself, what is worth copying is not a particular ANSI sequence, but the three-piece set — previous-frame reference, changed range, cursor ledger — plus the observation switches that go with it."
Summary
A terminal interface looks like nothing more than printing a few lines of text, but pi-tui's stable refresh rests on a set of state machines that have to face terminal differences. Its stability comes from four layers of design. The component contract says each component is only responsible for returning an array of lines from render(width), never touching stdin or the global cursor directly, and the scheduling layer decides when to refresh. The main screen does differential updates against the previous frame: identical lines are not rewritten, and only changed lines are updated through cursor movement and erasing, which reduces flicker and output. The alt screen treats the interface as a viewport bounded by the terminal size and renders it with a layout root and clipping. Width handling always counts display columns rather than characters: ANSI control sequences are zero-width, wide characters occupy two columns, and visibleWidth handles these differences uniformly, so the cursor position does not drift from confusing "character count" with "terminal column".
The trouble on the input side comes from network framing: a single keypress can be split across several byte chunks, and one chunk can carry half a control sequence. StdinBuffer recognizes whether sequences such as CSI and OSC are complete and gives only complete fragments to the focused component, so one keypress arriving in pieces is not mistaken for several inputs. "Is it complete" itself also splits into several families: CSI looks at the last byte plus a strict pattern for SGR mouse, OSC/DCS/APC look at whether they end with ST, SS3 and meta keys look at the suffix length; the timeout fallback guarantees an incomplete sequence is never buffered indefinitely.
The main screen's differential update depends on the previous frame's content, the cursor, and the viewport ledger. When the first change starts above the previous viewport's top, the main screen does a full repaint; new lines landing below the viewport can be scrolled and then written differentially. If a plain line actually written by the diff branch is over-wide, the main screen records a crash log and throws; the alt screen first clips to the terminal height, then truncates over-wide lines by column. The two strategies differ, and defects involving overlays, the mouse, or ANSI state are hard to discover fully from a string snapshot — PI_TUI_DEBUG_REDRAW and PI_TUI_DEBUG are the observation points that exist for exactly that.
Source Walkthrough
In the fixed snapshot, open Component, TuiBase.start(), and the input dispatch in packages/tui/src/tui.ts in order; then read TuiMainScreen.doRender() in tui-main-screen.ts and confirm how the old frame, the width check, and the terminal write connect. Finally trace one input containing a wide character and a paste control sequence through visibleWidth(), StdinBuffer, and Editor.handleInput(), distinguishing bytes, characters, and display columns.
