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(), showHardwareCursor) and creates persistent containers:
chatContainerpendingMessagesContainerstatusContainertodoContainerstatusLineeditorContainer(holdsCustomEditor)
init() wires the tree in that order, focuses the editor, registers input handlers via InputController, 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. - On Windows, attempts VT input enablement via
kernel32mode flags.
StdinBuffer behavior:
- 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() is debounced to one render per tick using process.nextTick. Multiple state changes in the same turn coalesce.
#doRender() pipeline:
- Render root component tree to
newLines. - Composite visible overlays (if any).
- Extract and strip
CURSOR_MARKERfrom visible viewport lines. - Append segment reset suffixes for non-image lines.
- Choose full repaint vs differential patch:
- first frame
- width change
- shrink with
clearOnShrinkenabled and no overlays - edits above previous viewport
- For differential updates, patch only changed line range and clear stale trailing lines when needed.
- Reposition hardware cursor for IME support.
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 must not exceed terminal width; overflow throws and writes crash diagnostics.
- Overlay compositing includes defensive truncation and post-composite width verification.
- Width changes force full redraw because wrapping semantics change.
- Cursor position is clamped before movement.
These constraints are runtime enforcement, not just conventions.
Resize handling
Resize events are event-driven from ProcessTerminal to TUI.requestRender().
Effects:
- Any width change triggers full redraw.
- Viewport/top tracking (
#previousViewportTop,#maxLinesRendered) avoids invalid relative cursor math when content or terminal size changes. - 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
- theme/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.