Files
oh-my-pi/docs/tui-runtime-internals.md
T
2026-08-03 16:39:23 +02:00

14 KiB

TUI runtime internals

This document maps the non-theme runtime path from terminal input to rendered output in interactive mode. It focuses on behavior in packages/tui and its integration from packages/coding-agent controllers.

Editing the rendering engine itself? Read tui-core-renderer.md first — it documents the failure modes (yank / corruption / flash / width crashes) and the invariants the render planner, native-scrollback bookkeeping, and capability detection must not violate.

Runtime layers and ownership

  • packages/tui engine: terminal lifecycle, stdin normalization, focus routing, render scheduling, differential painting, overlay composition, hardware cursor placement.
  • packages/coding-agent interactive mode: builds component tree, binds editor callbacks and keymaps, reacts to agent/session events, and translates domain state (streaming, tool execution, retries, plan mode) into UI components.

Boundary rule: the TUI engine is message-agnostic. It only knows Component.render(width), handleInput(data), focus, and overlays. Agent semantics stay in interactive controllers.

Implementation files

Boot and component tree assembly

InteractiveMode constructs TUI(new ProcessTerminal(), settings.get("showHardwareCursor")), applies tui.maxInlineImages and Kitty text-sizing settings, then creates persistent containers:

  • chatContainer
  • pendingMessagesContainer
  • statusContainer
  • todoContainer
  • subagentContainer
  • btwContainer
  • omfgContainer
  • errorBannerContainer
  • modelCycleContainer (ctrl+p model-role cycle chip track)
  • statusLine
  • hookWidgetContainerAbove
  • editorContainer (holds CustomEditor)
  • hookWidgetContainerBelow

init() wires the tree in that order after any startup warnings/welcome/changelog, focuses the editor, registers input handlers via InputController, starts TUI, pushes terminal title state, updates the editor border, and requests a forced render. A forced render (requestRender(true)) queues a viewport repaint or explicit session replacement; it does not throw away previous-line history by default.

Terminal lifecycle and stdin normalization

ProcessTerminal.start():

  1. Enables raw mode and bracketed paste.
  2. Attaches resize handler and refreshes dimensions.
  3. Enables Windows VT input mode when running on win32.
  4. Creates a StdinBuffer to split partial escape chunks into complete sequences.
  5. Queries Kitty keyboard protocol support (CSI ? u), then enables protocol flags if supported; otherwise enables modifyOtherKeys fallback after a short timeout.
  6. Queries OSC 11 background color and Mode 2031 appearance notifications for dark/light theme detection.
  7. Queries OSC 99 notification capabilities.
  8. Starts periodic OSC 11 polling only where safe, then probes DEC private modes 2026/2048/2031 via DECRQM.

StdinBuffer behavior:

  • Buffers fragmented escape sequences (CSI/OSC/DCS/APC/SS3).
  • Emits data only when a sequence is complete or timeout-flushed.
  • Detects bracketed paste and emits a paste event with raw pasted text.

This prevents partial escape chunks from being misinterpreted as normal keypresses.

Input routing and focus model

Input path:

stdin -> ProcessTerminal -> StdinBuffer -> TUI.#handleInput -> focusedComponent.handleInput

Routing details:

  1. TUI runs registered input listeners first (addInputListener), allowing consume/transform behavior.
  2. TUI handles global debug shortcut (shift+ctrl+d) before component dispatch.
  3. If focused component belongs to an overlay that is now hidden/invisible, TUI reassigns focus to next visible overlay or saved pre-overlay focus.
  4. Key release events are filtered unless focused component sets wantsKeyRelease = true.
  5. After dispatch, TUI schedules render.

setFocus() also toggles Focusable.focused, which controls whether components emit CURSOR_MARKER for hardware cursor placement.

Key handling split: editor vs controller

CustomEditor intercepts high-priority combos first (escape, ctrl-c/d/z, ctrl-v, ctrl-p variants, ctrl-t, alt-up, extension custom keys) and delegates the rest to base Editor behavior (text editing, history, autocomplete, cursor movement).

InputController.setupKeyHandlers() then binds editor callbacks to mode actions:

  • cancellation / mode exits on Escape
  • shutdown on double Ctrl+C or empty-editor Ctrl+D
  • suspend/resume on Ctrl+Z
  • slash-command and selector hotkeys
  • follow-up/dequeue toggles and expansion toggles

This keeps key parsing/editor mechanics in packages/tui and mode semantics in coding-agent controllers.

Render loop and the append-only contract

TUI.requestRender() coalesces render requests and rate-limits ordinary frames:

  • forced renders (requestRender(true, ...)) schedule an immediate full-window rewrite; clearScrollback requests the destructive replay path
  • ordinary renders use a 30fps base cadence plus adaptive backpressure derived from the previous frame's cost
  • repeated requests while a render is pending collapse into the same scheduled frame
  • requestComponentRender(component) scopes composition to affected root subtrees when geometry and renderer state are safe; otherwise it downgrades to a full compose
  • requestDirectWrite(component) can rewrite one quiet, visible, fixed-height component segment immediately (used by loader-style animation); overlays, images, cursor markers, geometry changes, committed segments, or other unsafe state fall back to requestComponentRender

#doRender() pipeline:

  1. Render the root component tree, collecting the first NativeScrollbackLiveRegion boundary and its optional pinned policy.
  2. Audit the already committed raw prefix for structural shifts; an insertion/deletion re-anchors commits at the first changed row so stale history may duplicate but new content is not lost.
  3. Advance the append-only ledger. Rows before the live boundary are exact/final; mutable rows that scroll above the window normally commit as frozen snapshots, while a pinned live region stays viewport-local.
  4. Extract and strip CURSOR_MARKER, normalize lines, slice the visible window, and composite overlays into that screen-coordinate window slice (overlays freeze commits).
  5. Emit one of: gesture-driven full paint, scroll-append, in-window row diff, or seam rewrite.

Native scrollback is append-only: committed frame rows are never rewritten. Exact rows enter history after the component seam declares them final; an unpinned mutable row that scrolls off is recorded as the snapshot that was visible at commit time. There are no viewport-position probes or deferred reconciliation; see tui-core-renderer.md.

Render writes use synchronized output mode (CSI ? 2026 h/l) when enabled; capability detection, DECRQM, or PI_NO_SYNC_OUTPUT can disable the wrappers while leaving autowrap discipline on.

Render safety constraints

Critical safety checks in TUI:

  • Non-image rendered lines are expected to fit terminal width; the differential path truncates overwide lines as a last-resort guard and can write debug diagnostics when redraw debugging is enabled.
  • Overlay compositing includes defensive truncation and post-composite width guarding.
  • Width changes force repaint/rebuild planning because wrapping semantics change.
  • Cursor position is clamped before movement.

These constraints are runtime guards plus component conventions; renderers should still return width-safe lines rather than rely on truncation.

The deeper reasons these guards exist — why the renderer cannot observe scroll position, why ED3 (CSI 3 J) is confined to one path, and why the hot path clamps instead of throwing — are documented in tui-core-renderer.md.

Resize handling

Resize events are event-driven from ProcessTerminal to TUI.requestRender().

Effects:

  • A resize is an explicit user gesture: outside multiplexers the engine erases and replays (ED3 + full paint) so history rewraps at the new geometry; the commit ledger restarts from the replayed frame.
  • Inside terminal multiplexers, resize repaints the visible window in place after a settle debounce (issue #2088); pane history keeps its old wrap, like any shell output, because pane scrollback cannot be erased safely.
  • Terminals that re-report their size when the alternate screen buffer is toggled (Warp reports a height one row different for the alt buffer) take the in-place path too. The non-multiplexer fast path borrows the alternate screen for drag frames, so on these terminals each alt enter/leave emits a fresh resize event, which re-enters the fast path — a self-sustaining loop that floods ED3 full repaints with stable geometry. resizeRepaintsInPlace() (covering multiplexers and these terminals; overridable via PI_TUI_RESIZE_IN_PLACE) routes them through the in-place repaint, which never touches the alt buffer.
  • Overlay visibility can depend on terminal dimensions (OverlayOptions.visible); focus is corrected when overlays become non-visible after resize.

Streaming and incremental UI updates

EventController subscribes to AgentSessionEvent and updates UI incrementally:

  • agent_start: starts loader in statusContainer.
  • message_start assistant: creates streamingComponent and mounts it.
  • message_update: updates streaming assistant content; creates/updates tool execution components as tool calls appear.
  • tool_execution_update/end: updates tool result components and completion state.
  • message_end: finalizes assistant stream, handles aborted/error annotations, marks pending tool args complete on normal stop.
  • agent_end: stops loaders, clears transient stream state, flushes deferred model switch, issues completion notification if backgrounded.

Read-tool grouping is intentionally stateful (#lastReadGroup) to coalesce consecutive read tool calls into one visual block until a non-read break occurs.

Status and loader orchestration

Status lane ownership:

  • statusContainer holds transient loaders (loadingAnimation, autoCompactionLoader, retryLoader).
  • statusLine renders persistent status/hooks/plan indicators and drives editor top border updates.

Loader behavior:

  • Loader advances its spinner every 80ms (animated message colorizers redraw at ~30fps) and uses the direct-write path for quiet fixed-height frames, with automatic fallback to a component-scoped render when direct rewriting is unsafe.
  • Escape cancels an in-progress auto-compaction, handoff generation, or auto-retry: the editor's single onEscape handler dispatches on live session state (isCompacting/isGeneratingHandoff/isRetrying) and calls the matching abort method, rather than swapping the handler.
  • On end/cancel paths, controllers stop/clear the loader components.

Mode transitions and backgrounding

Bash/Python input modes

Input text prefixes toggle editor border mode flags:

  • ! -> bash mode
  • $ (non-template literal prefix) -> python mode

Escape exits inactive mode by clearing editor text and restoring border color; when execution is active, escape aborts the running task instead.

Plan mode

InteractiveMode tracks plan mode flags, status-line state, active tools, and model switching. Enter/exit updates session mode entries and status/UI state, including deferred model switch if streaming is active.

Suspend/resume (Ctrl+Z)

InputController.handleCtrlZ():

  1. Registers one-shot SIGCONT handler to restart TUI and force render.
  2. Stops TUI before suspend.
  3. Sends SIGTSTP to process group.

Cancellation paths

Primary cancellation inputs:

  • Escape during active stream loader: restores queued messages to editor and aborts agent.
  • Escape during bash/python execution: aborts running command.
  • Escape during auto-compaction, handoff generation, or auto-retry: the editor's onEscape dispatches on live session state (isCompacting/isGeneratingHandoff/isRetrying) and calls the matching abort method (abortCompaction/abortHandoff/abortRetry).
  • Ctrl+C single press: clear editor; double press within 500ms: shutdown.

Cancellation is state-conditional; same key can mean abort, mode-exit, selector trigger, or no-op depending on runtime state.

Event-driven vs throttled behavior

Event-driven updates:

  • Agent session events (EventController)
  • Key input callbacks (InputController)
  • terminal resize callback
  • terminal appearance callbacks, SIGWINCH theme reevaluation, and git branch watchers in InteractiveMode

Throttled/debounced paths:

  • TUI rendering uses a 30fps base cadence, coalescing, and adaptive backpressure from render cost.
  • Loader animation is interval-driven (80ms spinner advance; ~30fps when the message colorizer is animated), using direct writes when safe and component-scoped renders otherwise.
  • Editor autocomplete updates (inside Editor) use debounce timers, reducing recompute churn during typing.

The runtime therefore mixes event-driven state transitions with bounded render cadence to keep interactivity responsive without repaint storms.