The terminal application in packages/coding-agent/src/modes/interactive/ is, in its own words, the mode that "Handles TUI rendering and user interaction, delegating business logic to AgentSession." interactive-mode.ts (about 7,100 lines) subscribes to the session's event stream and turns each event into changes to a tree of pi-tui components; the editor turns keystrokes into session calls. Nothing in the UI decides what the agent does — it only decides how it looks and which session method to call.
Learning goals. Explain the model–view split between AgentSession and InteractiveMode; trace a streamed reply with a tool call from events to screen; explain how typing while the agent works becomes steering or follow-up messages; describe the Escape-key state machine; explain how a tree is flattened into a scrollable list without recursion.
11.1 Model, view and the event subscription
InteractiveMode subscribes once:
private subscribeToAgent(): void {
this.unsubscribe = this.session.subscribe(async (event) => {
await this.handleEvent(event);
});
}handleEvent is one large switch over AgentSessionEvent. The session is the model (state and behaviour), the components are the view, and handleEvent plus the key handlers are the controller. No component reads the session's state on its own initiative; state flows in through events. This one-directional flow is why the same session can drive a terminal UI, an RPC client and a test harness interchangeably.
Rendering is requested, not performed: each case updates components and calls this.ui.requestRender(). The TUI coalesces requests and redraws at most once every 16 ms, roughly 60 frames per second (Chapter 12), so a burst of a hundred text_delta events costs a handful of redraws, not a hundred.
11.2 Tracing a streamed reply with a tool call
The event cases in handleEvent, in the order a turn produces them:
agent_startclears the map of pending tool components and restores the default Escape handler.turn_startshows the "working" indicator and, if enabled, the terminal's progress indicator.message_start(user) appends a user-message component; (assistant) creates anAssistantMessageComponent, keeps it asstreamingComponent, and appends it to the chat container.message_updatepasses the partial message tostreamingComponent.updateContent(). It also scans the partial message for tool-call blocks; for each id not yet seen it creates aToolExecutionComponentwith the arguments parsed so far, so the user watches, for example, theeditcall'soldTextappear before the model has finished writing it. Existing components getupdateArgs().message_endfinalizes the assistant component. Onabortedorerrorit marks every pending tool component as failed with the error text; otherwise it callssetArgsComplete()on each, which, per the comment, triggers "diff computation for edit tools" now that the arguments are final.tool_execution_startmarks the component as running (creating it if the call was not seen streaming). Nested calls from codemode scripts are skipped here ("shown inside their parent's row").tool_execution_updateshows partial output (live bash output, for instance).tool_execution_endshows the final result and duration and removes the component from the pending map.agent_endclears indicators and any orphaned streaming component.
A Map from tool-call id to component is the bridge between the two event families (message updates and tool execution), since a tool call is first a block in a message and later an execution. Keyed lookup makes order independence trivial.
The footer is invalidated on every event and shows tokens, cost, cache-hit rate and context usage (contextPercent%/contextWindow), coloured as the window fills — the user-facing side of Chapter 3's token estimate.
11.3 Typing while the agent works
The editor stays live while the agent runs. On submit, the handler checks state in order: built-in commands (/resume, /quit, …); !command runs a shell command directly (!! excludes it from context); during compaction, input is queued locally; while streaming, input becomes a steering message:
// If streaming, use prompt() with steer behavior
// This handles extension commands (execute immediately), prompt template expansion, and queueing
if (this.session.isStreaming) {
this.editor.addToHistory?.(text);
this.editor.setText("");
await this.session.prompt(text, { streamingBehavior: "steer" });
this.updatePendingMessagesDisplay();
this.ui.requestRender();
return;
}A separate key action, app.message.followUp (Alt+Enter by default), sends with streamingBehavior: "followUp". docs/usage.md gives the user's view: "A message sent with Enter waits until the current response and its tool calls finish, then guides the next response. A follow-up sent with Alt+Enter waits until Pi finishes the current task." Queued messages appear in a pending area above the editor, updated from queue_update events.
11.4 Escape: a small state machine
The Escape handler is a good example of a key whose meaning depends on state:
this.defaultEditor.onEscape = () => {
if (this.session.isStreaming) {
this.restoreQueuedMessagesToEditor({ abort: true });
} else if (this.session.isBashRunning) {
this.session.abortBash();
} else if (this.isBashMode) {
this.editor.setText("");
this.isBashMode = false;
this.updateEditorBorderColor();
} else if (!this.editor.getText().trim()) {
// Double-escape with empty editor triggers /tree, /fork, or nothing based on setting
…
if (now - this.lastEscapeTime < 500) { … }
}
};While the agent streams, Escape aborts the run and moves every queued steering and follow-up message back into the editor (joined with blank lines, ahead of anything already typed) — "Aborting returns queued messages to the editor". Nothing the user typed is lost. During auto-retry and auto-compaction the handler is temporarily replaced by one that cancels the retry or compaction, then restored. A double press within 500 ms on an empty editor opens the tree or fork selector — a time-windowed gesture, detected by remembering the last press time.
Replacing a callback to change a key's meaning per mode is the state pattern implemented with first-class functions.
11.5 The session tree view
/tree opens TreeSelectorComponent, which must show a tree of possibly thousands of entries as a scrollable list with box-drawing connectors (├─, └─, │), fold markers, labels and the active path. Its flattenTree() is a careful piece of algorithm engineering:
- No recursion. Sessions can be very deep (each message is a level), and recursive traversal of a deep tree can overflow the JavaScript call stack. The code uses explicit stacks, noting "Use iterative post-order traversal to avoid stack overflow".
- Post-order via reversed pre-order. To know which subtrees contain the active leaf, it builds a pre-order list with a stack, then processes it backwards so children are handled before parents — a standard trick to get post-order without recursion.
- Active branch first. Siblings are sorted so the subtree containing the leaf comes first.
- Compact indentation. "At indent 2+: stay flat for single-child chains, +1 only if parent branches" — a linear conversation does not march off the right edge; indentation is spent only where the tree actually branches.
- Gutters. Each flattened node carries the list of ancestor columns that need a
│continuation line, so the prefix can be built character by character.
Selecting a node calls session.navigateTree(), which may first offer a branch summary (Chapter 9).
11.6 Other parts worth reading
The components directory holds the rest of the app: selectors for sessions (with search), models, settings, themes and thinking levels; tool-execution.ts, which renders each tool with the tool definition's own renderer when it has one; message components for compaction and branch summaries; diff.ts for edit diffs; footer.ts; login dialogs that implement the AuthInteraction of Chapter 5 for the terminal; and a few easter eggs. theme/ maps semantic names ("dim", "accent", "error") to colours, with a system-theme detector for light and dark terminals.
11.7 Concepts in this chapter
- Model–view–controller and unidirectional data flow. State changes arrive as events; the view never pulls state behind the model's back.
- Render scheduling / coalescing. Request a render; the renderer batches requests into frames.
- Correlation by id across event streams. A map keyed by tool-call id joins message events and execution events.
- State pattern. Behaviour of an input depends on current mode; swap handlers on mode change.
- Gesture detection with time windows. Double-press detection with a timestamp.
- Iterative tree traversal. Explicit stacks replace recursion to avoid stack overflow; reversed pre-order yields post-order.
- No-loss input handling. Queued user input is returned to the editor on abort.
Exercises
- Trace the components created, updated and removed when the model streams "I'll check" and then calls
readtwice in parallel. - Why does
message_updatecreate tool components beforetool_execution_start? What would the user lose without it? - Write an iterative function that computes, for every node in a tree, whether its subtree contains a given id, using the reversed pre-order trick. Prove it visits each node twice at most.
- Change the double-Escape window from 500 ms to a setting. Which file defines settings and their schema (Chapter 9)?
- List every way the Escape key's behaviour changes over the lifetime of a session, and draw it as a state diagram.