Files
oh-my-pi/docs/tui-core-renderer.md
T
roboomp 67e6324ace fix(tui): reduced row flicker while typing
Changed text row repaints to overwrite first and clear only stale suffixes so non-synchronized WSL/Windows Terminal paints do not visibly blank already-rendered rows. Kept full-line pre-clears for image protocol rows and preserved exact-width row handling.\n\nFixes #2011
2026-06-06 20:28:15 +00:00

21 KiB
Raw Blame History

TUI core renderer — invariants & failure modes

What you are dealing with before you touch the rendering engine. This is the companion to tui-runtime-internals.md: that doc maps the flow (input → component tree → render); this doc explains what does not work, why it keeps breaking, and the invariants you must not violate. Scope is the core engine only:

Application-layer renderers (transcript, tool calls, session tree, editor, widgets) are out of scope — they live in packages/coding-agent.


1. The one thing to understand first

The renderer cannot observe the terminal's scroll position on most hosts it runs on. Every decision about rewriting native scrollback is therefore a guess, and the guess has two opposite failure modes that cannot both be avoided by a single policy.

We keep our transcript on the normal screen. We deliberately have not moved the engine to the alternate screen: alt-screen would make the terminal handle viewport isolation, but the transcript/resume affordances would disappear with the alternate buffer. Keeping the normal screen means we own native scrollback, which means we must decide, per frame, whether it is safe to rebuild it. To rebuild history we emit xterm ED3 (CSI 3 J, erase saved lines). Deciding when ED3 is safe requires knowing whether the user has scrolled up — and we usually can't:

  • ConPTY hosts (Windows Terminal, Tabby, Hyper, VS Code, conhost): the pseudo-console buffer is pinned to the visible grid, so any "am I at the bottom?" console query answers "yes" even when the reader scrolled up. The probe lies.
  • POSIX terminals: there is no scroll-position API at all. The probe is absent.

So Terminal.isNativeViewportAtBottom() returns true / false / undefined, and undefined ("unknown") is the common case. The whole renderer is built around not trusting undefined.

The two-way bind

If you guess… …and you're wrong Symptom
Eager (rebuild now → emit CSI 3 J) reader was scrolled up YANK to top + FLASH on terminals that snap scroll on ED3
Defer (emit nothing, reconcile later) viewport really was at the bottom CORRUPTION (stale/duplicated rows) + invisible-until-resize

Yank, flash, and buffer corruption are the same bug wearing three masks. Historically, every fix that suppressed one mask for one terminal class re-enabled the opposite mask for a neighbouring class, and the follow-on complaint landed within a day. If you "fix flashing" by making rebuilds more eager, you will reintroduce yank. If you "fix yank" by deferring more, you will reintroduce corruption / invisibility. Do not move this lever without the fidelity harness (§9) green.


2. The render-intent planner (what you are editing)

#doRender is split into a planner (#planRender) that classifies a frame into exactly one RenderIntent, and one #emit* method per intent that owns the bytes written and the state update. All state flows through a single #commit checkpoint at the end of every emitter. The intent union (tui.ts, search type RenderIntent):

Intent Emits When
noop cursor only nothing visible changed
initial clear viewport, paint transcript, keep prior shell scrollback first paint after start()
sessionReplace clear viewport + ED3 (outside multiplexers) caller forced { clearScrollback: true } (switch/branch/reload/resume)
historyRebuild clear viewport + ED3 (outside multiplexers) geometry change rewrapped history, or a proven-at-tail rebuild
overlayRebuild rebuild viewport with overlay composite overlay visibility changed
liveRegionPinned relative moves + per-row rewrite/suffix-clear + \r\n foreground streaming on an ED3-risk host, commit-as-you-go
viewportRepaint rewrite the visible viewport in place (optional appendFrom tail first) safe non-destructive repaint
deferredShrink padded viewport repaint, history left dirty bottom-anchored shrink, viewport unobservable
deferredMutation zero bytes, history left dirty row-reindexing edit while possibly scrolled
shrink / diff trailing-row clear / changed-line diff ordinary in-place updates

ED3 (CSI 3 J) is emitted in exactly one place — #emitFullPaint when clearScrollback: true (\x1b[2J\x1b[H\x1b[3J). The ordinary clear is non-destructive: \x1b[22J (copy-screen-to-scrollback, only when TERMINAL.supportsScreenToScrollback) then \x1b[2J\x1b[H, no 3J. ED3 is reached only by sessionReplace/historyRebuild/overlayRebuild, and those suppress the scrollback clear inside multiplexers (isMultiplexerSession() = TMUX || STY || ZELLIJ).

The predicate gates

Three private predicates encode the guessing policy. Do not "simplify" them — each branch is load-bearing:

  • #canReplayNativeScrollbackAtCheckpoint(atBottom) → atBottom === true. A rebuild at a keystroke checkpoint (prompt submit) is allowed only with a positive at-tail proof. A prompt submit is no longer treated as implicit proof for an unobservable host.
  • #canRebuildNativeScrollbackLive(atBottom, allowUnknown) → true iff atBottom === true, or (atBottom === undefined && allowUnknown && platform !== "win32"). i.e. live ED3 during streaming requires either proof or an explicit direct-user-input opt-in, and never on win32.
  • #nativeViewportIsScrolled(atBottom, allowUnknown) → true if atBottom === false, or (undefined && win32 && !allowUnknown). Used to decide deferral.

allowUnknownViewportMutation is the direct-user-input opt-in (autocomplete / IME / a keystroke the user just typed). A keystroke pins the host viewport to the bottom, so it is safe to repaint live then. It is not set by passive streaming. setEagerNativeScrollbackRebuild(true) is the streaming opt-in; on ED3-risk hosts it is downgraded so it never promotes to a live ED3 clear.

Deferral + checkpoint discipline

When the viewport is unobservable during passive streaming, the planner defers (deferredMutation/deferredShrink/viewportRepaint) and marks native scrollback dirty (#markNativeScrollbackDirty()). Reconciliation happens later at a checkpoint via refreshNativeScrollbackIfDirty() — and only if #canReplayNativeScrollbackAtCheckpoint proves at-tail. The streaming-defer + live-region-pin seam (NativeScrollbackLiveRegion, getNativeScrollbackLiveRegionStart / getNativeScrollbackCommitSafeEnd) is the actively-churning part of the engine; if you change how transient rows are committed, every structural-mutation branch (shrink and grow/offscreen-edit) must defer symmetrically, or you reopen the corruption family.


3. The five fault families

YANK — viewport snapped to top — NOT fully converged

  • Mechanism: a live historyRebuild fires CSI 3 J while the reader is scrolled up; ED3-snap terminals reset the visible viewport to the top of the (now-erased) scrollback.
  • Trigger to avoid: treating an unobservable probe as "at bottom" during passive streaming, or OR-ing an eager-streaming flag into the live ED3 path.
  • Current stance: never emit ED3 on an unobservable host during passive streaming; defer and reconcile at a keystroke checkpoint. ConPTY/win32 never trust the probe at all.

CORRUPTION — duplicated / stale rows — NOT fully converged

  • Mechanism: the flip side of the yank fix. A deferred/repainted frame leaves rows already committed to native scrollback out of sync with the live viewport; the scrollback↔viewport seam duplicates (e.g. a 2-row dup, a streaming-tail dup, or an async-expansion dup).
  • Trigger to avoid: repainting the viewport over scrollback that still holds the old copy; a frozen/deferred block whose snapshot no longer matches after the region above it reflowed; one mutation branch deferring while its mirror branch repaints.
  • Current stance: commit only the stable prefix line-count to native history; keep unstable rows out; reconcile drift at the checkpoint; park the hardware cursor at real content bottom, not padded bottom.

FLASH (and invisible-until-resize) — NOT fully converged

  • Two distinct causes, one symptom:
    • Flash = eager ED3 rebuild wrapped in DEC 2026 BSU/ESU fired per streaming frame on a terminal that clamps scroll on ED3 (VTE/GNOME family).
    • Invisible-until-resize = the defer fix over-firing, so a structural frame emits zero bytes (deferredMutation returns nothing) until a resize forces a repaint.
  • Trigger to avoid: env-detection that misses a flashing terminal (SSH strips VTE_VERSION; some hosts set no distinguishing var); collapsing an undefined probe into a definite scrolled/at-bottom verdict.
  • Current stance: confine ED3 to the destructive path; auto-disable DEC 2026 at runtime when the terminal reports it unsupported (DECRQM), with PI_NO_SYNC_OUTPUT as a manual hatch; keep autowrap discipline regardless.

WIDTH — measurement crashes / fidelity — crash class dead, accuracy unproven

  • Mechanism: the measured column width of a line disagreed with the terminal's painted cells (emoji, wide graphemes, combining marks, Hangul jamo), and the old render loop threw on any mismatch — a 1-cell cosmetic error became a fatal whole-agent crash.
  • Current stance: never throw in the render hot path — clamp. The loop truncates over-wide lines with truncateToWidth/sliceByColumn and logs (under debug) instead of dying. Width is owned end-to-end by one native UAX#11 engine shared by measure/slice/wrap (see §6). Accuracy across all scripts (e.g. RTL/combining marks) is still not proven by a green gate.

PROBE — stray bytes injected as keystrokes — RESOLVED

  • Mechanism: a private-CSI probe reply (DA1 / kitty / mode 2031) split across a stdin flush; the unmatched prefix was dropped and the continuation bytes were forwarded as keystrokes.
  • Current stance: buffer-and-reassemble partial CSI responses; give each probe a typed sentinel owner. This is the one cleanly-closed family — because its contract is bounded and observable (bytes in = bytes out), unlike the unobservable-viewport families. See §7.

4. Invariants — MUST / NEVER

These are the rules the recurrence taught us. Treat them as load-bearing.

  1. NEVER add a new CSI 3 J (ED3) callsite. ED3 must flow only through #emitFullPaint({ clearScrollback: true }), for the existing destructive intents (sessionReplace, proven/safe historyRebuild, overlayRebuild). Ordinary redraws use the non-destructive \x1b[22J + \x1b[2J\x1b[H clear.
  2. NEVER trust an unobservable viewport probe (undefined) for passive streaming. Only a positive at-tail proof, or a direct-user-input opt-in (allowUnknownViewportMutation), authorizes a live rebuild — and never on win32/ConPTY.
  3. NEVER throw in the render hot path. Clamp over-wide lines; a width mismatch is cosmetic, not fatal.
  4. NEVER let a defer path emit a structurally-changed frame as zero bytes while at the bottom — that is invisible-until-resize. deferredMutation/ deferredShrink are only safe when the viewport is (or may be) scrolled.
  5. Defer symmetrically. If one structural-mutation branch (shrink) defers on an unobservable ED3-risk host, the mirror branch (grow / offscreen-edit) must too. Asymmetry reopens corruption.
  6. Commit only the stable prefix to native history. Transient/unsettled rows stay out of scrollback until a checkpoint; reconcile drift at the checkpoint.
  7. Park the hardware cursor at real content bottom, not the padded viewport bottom, or height shrinks scroll live rows into scrollback and duplicate them per resize step.
  8. Cursor writes live inside the synchronized-output frame, before ESU — never as a second frame after it (that teleports/blinks the caret).
  9. Detect terminal risk, not terminal brand, and default unknown to risky. Env sniffing is necessarily incomplete (see §5); never assume an un-enumerated host is safe.
  10. Multiplexers (tmux/screen/zellij) get no destructive scrollback clear and no viewport probe. ED3 is a no-op there and a full replay duplicates the transcript; repaint in place and rely on the pinned/commit-as-you-go path.
  11. Any change to the eager/defer lever, the predicates, or the live-region seam must be validated by the render-stress fidelity harness (§9) across {win32, POSIX} × {unknown, scrolled, at-bottom}, not by a single-terminal smoke test.

5. Terminal capability detection (and why it is fragile)

TERMINAL (terminal-capabilities.ts) is resolved once at import from TERMINAL_ID plus environment sniffing. The detection helpers are pure and parameterized over (env, platform) so they are unit-testable:

  • detectTerminalEagerEraseScrollbackRisk(env, platform) → is a live ED3 rebuild unsafe here? Current policy: false on win32 (dedicated ConPTY deferral paths handle it) and when PI_TUI_ED3_SAFE=1; otherwise true for WT_SESSION (WT fronting WSL), SSH/tmux/screen/zellij, known ED3-snap/scrollback-clearing terminals (WezTerm, kitty, ghostty, alacritty, VTE, iTerm2, Apple Terminal, GNOME Terminal, Ptyxis, xfce4-terminal), Linux truecolor, and every other unknown POSIX terminal. The default is risky on purpose.
  • shouldEnableSynchronizedOutputByDefault(env, platform, id) → DEC 2026 on by default only for kitty/ghostty/wezterm/iterm2, off for win32 / SSH / multiplexers / VTE-family. Layered with a runtime DECRQM auto-disable.
  • detectRectangularSgrSupport(id, env) → DECCARA fills: kitty only (ghostty does not implement the SGR-background extension), off in multiplexers and under PI_NO_DECCARA.

Why this keeps leaking: terminal class is inferred from env vars that are not durable. VTE_VERSION is stripped by sshd (default AcceptEnv); COLORTERM is also not in default AcceptEnv; some hosts (Tabby) set no distinguishing var; WSL-fronting-WT is neither pure win32 nor pure POSIX. Every missed env var is a missed terminal class is a new complaint. The mitigations are: (a) default unknown to risky rather than safe, and (b) detect by behavior/handshake (DECRQM) where possible rather than a host allow-list. When you add a terminal, add it to the pure detector and add the SSH-stripped env shape to the test, not just the env-present shape.


6. Width model

visibleWidth / truncateToWidth / sliceByColumn / wrapTextWithAnsi (utils.ts) all route through one native UAX#11 engine (@oh-my-pi/pi-natives, Rust unicode-width). We deliberately dropped Bun.stringWidth because it disagreed with the engine on combining marks and jamo, and mixing two width models in measure-vs-slice produced the crashes.

  • Fast path: printable ASCII is one cell per code unit.
  • ZWJ pictographic emoji take the visibleWidthByGrapheme override (ANSI spans excised first, then Intl.Segmenter), because the native scanner double-counts SGR bytes when a sequence is split by the segmenter.
  • OSC 66 sized text (\x1b]66;…) takes the native path.

Rule: if you add a code path that measures width, route it through these helpers. Never reintroduce Bun.stringWidth or a parallel width table — the measure model and the slice/wrap model must agree, or you get over-wide lines that the hot-path clamp silently truncates (cosmetic loss) or, worse, seam duplication.


7. Capability probes & stdin reassembly

ProcessTerminal fuses capability queries with a bare DA1 (CSI c) sentinel so a non-answering terminal is detected when DA1 returns first. Replies can arrive split across a stdin flush, so:

  • #privateCsiResponseBuffer accumulates \x1b[?… partials while a sentinel is outstanding, rejoins on the terminator byte (0x40–0x7e), then runs the DA1/kitty/mode-2031 handlers on the complete reply. A new \x1b mid-reassembly or >256 bytes abandons the partial so real keys (e.g. arrow \x1b[A) still reach input.
  • #da1SentinelOwners is a typed FIFO discriminated by kind (keyboard, osc11, privateMode, kittyGraphicsProbe, osc99Probe) so a keyboard DA1 cannot be mistaken for an OSC 11 / DECRQM / graphics-probe sentinel.
  • DECRQM probes (#queryPrivateMode(2026/2048/2031)) record support via DECRPM and drive runtime feature gating (e.g. auto-disabling DEC 2026 sync output).

Rule: any new probe must own a typed sentinel and survive a split reply. The contract is bytes-in = bytes-out; it is testable, so test it (feed the reply byte-by-byte and assert nothing leaks to the input handler).


8. Inline images & memory

Kitty images are transmit-once, place-many (kitty-graphics.ts): encodeKittyTransmit (a=t, keyed by a stable i=) writes the base64 a single time; repaints emit only encodeKittyPlacement (a=p). Text clears (CSI 2 J / CSI 3 J) do not purge the terminal's image store — only encodeKittyDeleteImage (a=d,d=I) does. ImageBudget (components/image.ts) keeps only the most-recent N images live; demoted images render their text fallback and are explicitly purged.

Rule: never re-emit full base64 per frame (it pegged RAM and pinned the UI thread). Kitty Unicode placeholders are default-on only for kitty/ghostty (PI_NO_KITTY_PLACEHOLDERS / PI_KITTY_PLACEHOLDERS); other Kitty-protocol hosts render placeholder cells as literal PUA glyphs, so they fall back to direct a=p placement.


9. The fidelity gate (use it)

packages/tui/test/render-stress-harness.ts renders the renderer's real emitted ANSI into a ghostty-web VirtualTerminal and asserts viewport fidelity (a scrolled reader stays put), background-column fidelity, and scrollback-buffer fidelity, across parameterized terminal shapes and randomized op sequences.

This harness is the structural fix for the whole recurrence: every guess-flip and sniffing-gap regression historically shipped blind and was caught by a user, because no automated "a scrolled-up reader stays pinned across kitty/WT/WSL/ ConPTY" assertion gated CI. Before you change the eager/defer lever, a predicate, the live-region seam, or width math, run the stress harness and the targeted repro tests (packages/tui/test/render-regressions.test.ts, packages/tui/test/streaming-scrollback-defer.test.ts, the issue-*-repro.test.ts files). A change that passes one terminal and one seed is not verified.


10. Escape hatches (env vars)

Var Effect
PI_NO_SYNC_OUTPUT=1 Disable DEC 2026 BSU/ESU wrappers (autowrap discipline stays on). For terminals that advertise but mishandle mode 2026.
PI_TUI_SYNC_OUTPUT=0|1 / PI_FORCE_SYNC_OUTPUT=1 Force sync output off / on.
PI_TUI_ED3_SAFE=1 Declare the terminal safe for live ED3 (disables eagerEraseScrollbackRisk).
PI_NO_DECCARA Disable Kitty DECCARA rectangular-fill optimization (force padded-string fills).
PI_FORCE_IMAGE_PROTOCOL=kitty|iterm2|sixel|off Override image protocol detection.
PI_NO_KITTY_PLACEHOLDERS=1 / PI_KITTY_PLACEHOLDERS=1 Force Kitty Unicode placeholders off / on.
PI_CLEAR_ON_SHRINK=1 Clear empty rows when content shrinks (default off).
PI_HARDWARE_CURSOR=1 Show the real hardware cursor instead of a rendered one.
PI_NOTIFICATIONS=off|0|false Suppress terminal notifications.
PI_DEBUG_REDRAW=1 Log the chosen render intent per frame to the debug log.
PI_TUI_DEBUG=1 Dump per-render diff state under /tmp/tui.

11. Before you touch the render core — checklist

  • Are you about to emit CSI 3 J anywhere other than the destructive clearScrollback path? Stop.
  • Does your change trust isNativeViewportAtBottom() === undefined as "at bottom" during passive streaming? Stop.
  • Did you change one structural-mutation branch without mirroring its sibling (shrink ↔ grow)? Defer symmetrically.
  • Could any frame now emit zero bytes while the viewport is at the bottom? That's invisible-until-resize.
  • Did you add a terminal by brand instead of by behavior, or skip the SSH-stripped env shape in the test?
  • Did you run packages/tui/test/render-stress-harness.ts + the repro suite across win32/POSIX × unknown/scrolled/at-bottom — not just one terminal?
  • New probe? Typed sentinel owner + split-reply test.
  • New width path? Routed through the shared native engine, clamped (never thrown) in the hot path.