ππ Agent
Chapter 16 / 21

Interactive TUI

Six thousand lines to make the loop feel instant

Enter→steer, Alt+Enter→follow-up; dialogs, themes, footer telemetry
No simulation scenario available for this chapter

Deep Dive

~7 min read·6885 chars

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

FileLinesResponsibility
packages/coding-agent/src/modes/interactive/interactive-mode.ts6046Interactive mode: message queue, dialogs, rendering
packages/coding-agent/src/modes/interactive/components/tree-selector.ts1427/tree branch navigation component
packages/coding-agent/src/modes/interactive/theme/theme.ts1294Theme 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

  1. 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."

  2. 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.

  3. 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

DirectionChapterRelationship
PrerequisiteCh01 Agent Loopsteer/followUp ultimately drive agentLoop
PrerequisiteCh11 AgentSessionBusiness logic delegated to AgentSession
PrerequisiteCh10 CLI Modesinteractive mode dispatched by main.ts
Follow-upCh17 TUI Enginepi-tui provides rendering infrastructure
Follow-upCh18 Settings & Trustoutput-guard and settings hot-reload
Follow-upCh04 Session Tree/tree navigation based on JSONL session tree

Check Your Understanding

  1. Without output-guard, how would subprocess output corrupt the TUI? What would it look like?
  2. In what user scenarios is the difference between steer and followUp most apparent?
  3. 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.