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.
Runtime layers and ownership
packages/tuiengine: terminal lifecycle, stdin normalization, focus routing, render scheduling, differential painting, overlay composition, hardware cursor placement.packages/coding-agentinteractive 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
../src/modes/interactive-mode.ts../src/modes/controllers/event-controller.ts../src/modes/controllers/input-controller.ts../src/modes/components/custom-editor.ts../../tui/src/tui.ts../../tui/src/terminal.ts../../tui/src/editor-component.ts../../tui/src/stdin-buffer.ts../../tui/src/components/loader.ts
Boot and component tree assembly
InteractiveMode constructs TUI(new ProcessTerminal(), settings.get("showHardwareCursor")), applies settings.get("clearOnShrink"), and creates persistent containers:
chatContainerpendingMessagesContainerstatusContainertodoContainerbtwContainerstatusLinehookWidgetContainerAboveeditorContainer(holdsCustomEditor)hookWidgetContainerBelow
init() wires the tree in that order, focuses the editor, registers input handlers via InputController, subscribes terminal appearance changes into theme auto-detection, starts TUI, and requests a forced render.
A forced render (requestRender(true)) resets previous-line caches and cursor bookkeeping before repainting.
Terminal lifecycle and stdin normalization
ProcessTerminal.start():
- Enables raw mode and bracketed paste.
- Attaches resize handler.
- Creates a
StdinBufferto split partial escape chunks into complete sequences. - Queries Kitty keyboard protocol support (
CSI ? u), then enables protocol flags if supported; otherwise enables modifyOtherKeys fallback after a short timeout. - Queries OSC 11 background color and enables Mode 2031 appearance notifications for dark/light theme detection.
- On Windows, attempts VT input enablement via
kernel32mode flags.StdinBufferbehavior:
- Buffers fragmented escape sequences (CSI/OSC/DCS/APC/SS3).
- Emits
dataonly when a sequence is complete or timeout-flushed. - Detects bracketed paste and emits a
pasteevent 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:
- TUI runs registered input listeners first (
addInputListener), allowing consume/transform behavior. - TUI handles global debug shortcut (
shift+ctrl+d) before component dispatch. - If focused component belongs to an overlay that is now hidden/invisible, TUI reassigns focus to next visible overlay or saved pre-overlay focus.
- Key release events are filtered unless focused component sets
wantsKeyRelease = true. - 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+Cor empty-editorCtrl+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 diffing strategy
TUI.requestRender() coalesces render requests and rate-limits ordinary frames:
- forced renders (
requestRender(true, ...)) reset cached frame/viewport state and run onprocess.nextTick - ordinary renders schedule through
#scheduleRender()and respectTUI.#MIN_RENDER_INTERVAL_MS - repeated requests while a render is pending collapse into the same scheduled frame
#doRender() pipeline:
- Render root component tree to
newLines. - Composite visible overlays (if any).
- Extract and strip
CURSOR_MARKERfrom the visible viewport. - Normalize non-image lines and append reset/hyperlink terminators.
- Classify the frame into a render intent:
- initial paint / forced viewport repaint
- explicit session replacement or native scrollback rebuild
- viewport repaint for width/height/offscreen mutations
- deferred mutation/shrink when native scrollback is scrolled
- trailing shrink
- changed-line diff
- noop
- Emit only the bytes required by the intent and commit cached frame/cursor/viewport state.
Render writes use synchronized output mode (CSI ? 2026 h/l) to reduce flicker/tearing.
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.
Resize handling
Resize events are event-driven from ProcessTerminal to TUI.requestRender().
Effects:
- Width changes repaint or rebuild because wrapping semantics change.
- Height-only changes repaint the viewport when needed, but skip repaint in Termux and terminal multiplexers where replays are scrollback-hostile.
- Viewport/top tracking (
#viewportTopRow,#maxLinesRendered, scrollback high-water state) avoids invalid relative cursor math and defers destructive native scrollback rewrites while the user is scrolled into history. - 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 instatusContainer.message_startassistant: createsstreamingComponentand 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:
statusContainerholds transient loaders (loadingAnimation,autoCompactionLoader,retryLoader).statusLinerenders persistent status/hooks/plan indicators and drives editor top border updates.
Loader behavior:
Loaderupdates every 80ms via interval and requests render each frame.- Escape handlers are temporarily overridden during auto-compaction and auto-retry to cancel those operations.
- On end/cancel paths, controllers restore prior escape handlers and stop/clear 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():
- Registers one-shot
SIGCONThandler to restart TUI and force render. - Stops TUI before suspend.
- Sends
SIGTSTPto process group.
Background mode (/background or /bg)
handleBackgroundCommand():
- Rejects when idle.
- Switches tool UI context to non-interactive (
hasUI=false) so interactive UI tools fail fast. - Stops loaders/status line and unsubscribes foreground event handler.
- Subscribes background event handler (primarily waits for
agent_end). - Stops TUI and sends
SIGTSTP(POSIX job control path).
On agent_end in background with no queued work, controller sends completion notification and shuts down.
Cancellation paths
Primary cancellation inputs:
Escapeduring active stream loader: restores queued messages to editor and aborts agent.Escapeduring bash/python execution: aborts running command.Escapeduring auto-compaction/retry: invokes dedicated abort methods through temporary escape handlers.Ctrl+Csingle 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 is tick-debounced (
requestRendercoalescing). - Loader animation is fixed-interval (80ms), each frame requesting render.
- 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.