Interactive TUI
Six thousand lines to make the loop feel instant
“Enter→steer, Alt+Enter→follow-up; dialogs, themes, footer telemetry”
Deep Dive
Six thousand lines make the loop feel instant — steer/follow-up, dialogs, theme hot-reload
Learning Objectives
After this chapter you will be able to:
- Explain why interactive-mode.ts is a 6,046-line "interaction layer" not a "business layer"
- Describe how steer/followUp queues achieve "append without interrupting" chat experience
- Explain how output-guard guarantees TUI frames are never corrupted by subprocess output
- Understand how footer telemetry displays token/cost/context metrics in real time
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/coding-agent/src/modes/interactive/interactive-mode.ts | 6046 | Interactive mode: message queue, dialogs, rendering |
packages/coding-agent/src/modes/interactive/components/tree-selector.ts | 1427 | /tree branch navigation component |
packages/coding-agent/src/modes/interactive/theme/theme.ts | 1294 | Theme management and hot-reload |
Design Motivation: Why Is the Interaction Layer So Large?
Problem: agent loop + AgentSession handle "logic" — calling models, executing tools, writing sessions. But users need "experience" — seeing streaming output in real time, interrupting anytime, keyboard shortcuts, beautiful interface.
Analogy: Agent loop is the engine, AgentSession is the chassis, interactive-mode is the body + interior + dashboard. Users sit in the body, never touching the engine directly.
6,046 lines breakdown:
- ~2000 lines: message rendering (Markdown, code highlighting, diff display)
- ~1500 lines: dialog system (/model, /settings, /tree, /fork...)
- ~1000 lines: input handling (steer/followUp, shortcuts, paste)
- ~800 lines: footer telemetry + status bar
- ~700 lines: theme, output-guard, misc
steer and followUp: Append Without Interrupting
// User presses Enter → steer (inject into current turn)
editor.on("enter", (text) => {
if (agent.isRunning) {
agent.steer(text) // current turn sees new instruction immediately
} else {
session.prompt(text) // no running turn → new prompt
}
})
// User presses Alt+Enter → followUp (queue for next turn)
editor.on("alt+enter", (text) => {
agent.followUp(text) // auto-executes after current turn ends
})
Difference:
- steer: "Interrupt" while model is generating — "wait, don't edit that file, edit this one"
- followUp: Queue — "after you're done with the current task, also run the tests"
Analogy: steer is like navigation rerouting (immediate effect); followUp is like adding the next destination (go there after current one).
Dialog System
InteractiveMode
├── editor (pi-tui Editor)
│ └── Enter=steer / Alt+Enter=followUp
├── dialogs:
│ ├── /model → model picker (Ctrl+P also triggers)
│ ├── /settings → settings editor
│ ├── /login → OAuth login flow
│ ├── /tree → session branch navigation (tree-selector 1427 lines)
│ ├── /fork → fork current session
│ ├── /compact → manual compaction
│ ├── /export → export session
│ ├── /theme → theme switch (hot-reload)
│ └── ... (13+ total)
├── footer: ↑12K ↓3K R8 W2 CH60% opus $0.42
└── output-guard: stdout takeover
Each dialog is an independent component — opening/closing doesn't affect the main render loop. tree-selector (1427 lines) is the most complex — it renders the JSONL session tree (→ Chapter 4) with keyboard navigation.
Footer Telemetry
┌─────────────────────────────────────────────────────┐
│ ↑ 12.3K ↓ 3.1K R 8 W 2 CH 60% opus ctx 45% │
└─────────────────────────────────────────────────────┘
│ │ │ │ │ │ │
│ │ │ │ │ │ └── context window usage
│ │ │ │ │ └── current model
│ │ │ │ └── cache hit rate
│ │ │ └── file write count
│ │ └── file read count
│ └── output tokens
└── input tokens
Real-time updates: Every AgentEvent triggers footer recalculation. Uses differential rendering (→ Chapter 17) to update only changed characters — no full-line redraw.
output-guard: Frame Integrity Guardian
// output-guard takes over stdout
class OutputGuard {
private buffer: string[] = []
install() {
process.stdout.write = (chunk) => {
// Don't write to terminal directly — buffer
this.buffer.push(chunk.toString())
return true
}
}
// Flush at TUI render safe point
flush(renderer: Renderer) {
if (this.buffer.length) {
renderer.writeToPanel(this.buffer.join(""))
this.buffer = []
}
}
}
Why needed? Models may call bash tools; subprocesses write stdout directly. Without interception, subprocess output tears TUI frames — users see garbled text. output-guard buffers all output, flushing at safe points in the TUI render cycle.
Engineering Insights
-
6046 lines but logically simple: Mostly "rendering" and "event dispatch." Real business logic (model calls, tool execution) lives in AgentSession (→ Chapter 11). interactive-mode only "draws events out, passes input in."
-
Theme hot-reload: /theme switch doesn't restart — theme.ts (1294 lines) defines a complete color system; switching invalidates all components → next frame auto-uses new colors.
-
tree-selector 1427 lines: Session trees can be deep (dozens of forks). Needs virtual scrolling, lazy loading, keyboard navigation — essentially a file manager in the terminal.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch01 Agent Loop | steer/followUp ultimately drive agentLoop |
| Prerequisite | Ch11 AgentSession | Business logic delegated to AgentSession |
| Prerequisite | Ch10 CLI Modes | interactive mode dispatched by main.ts |
| Follow-up | Ch17 TUI Engine | pi-tui provides rendering infrastructure |
| Follow-up | Ch18 Settings & Trust | output-guard and settings hot-reload |
| Follow-up | Ch04 Session Tree | /tree navigation based on JSONL session tree |
Check Your Understanding
- Without output-guard, how would subprocess output corrupt the TUI? What would it look like?
- In what user scenarios is the difference between steer and followUp most apparent?
- Why does the footer use differential rendering instead of full redraw each frame? Performance impact?
Takeaways
- 6046-line interaction layer — rendering + event dispatch; business logic in AgentSession
- steer/followUp: append without interrupting, interaction feels like chat
- output-guard takes over stdout — frames never corrupted by subprocesses
- Footer real-time telemetry: token/cost/context at a glance
This chapter is based on the main-branch source of earendil-works/pi.