Chapter 16: The TUI as an Event Renderer
Reading Contract: Use this chapter to answer one practical question: when a rich terminal conversation is on screen, which layer owns the fact, which layer owns the interaction, and which layer is only rendering a projection? Track four boundaries: the
App::runselect loop,ChatWidgetprotocol projection,BottomPaneinterruption state, and source-backed scrollback. Afterward, you should be able to tell whether a claim belongs to runtime authority, UI ownership, or terminal mechanics.

Source boundary: source-level claims in this chapter count as verified source only when they link to the pinned Codex commit 569ff6a1c400bd514ff79f5f1050a684dc3afde3 or to the Source Map. Design language such as “projection”, “ownership boundary”, “terminal substrate”, and “runtime authority” is surrounding contract inference from those visible files. This chapter does not infer OpenAI service internals or treat terminal UI behavior as the authoritative agent runtime.
Chapter 15 studied the external reach of Codex: SDKs, daemons, and remote-control bridges preserve or narrow the app-server contract. Chapter 16 moves to the client most users experience directly. The terminal UI can look like “the app” because the user types there, approves commands there, watches output there, and copies transcript rows from there. That intuition is understandable, but it assigns ownership to the wrong layer.
The TUI does not own the agent runtime. It owns the interactive projection of a thread.
That single sentence explains why the code is shaped the way it is. The app-server session owns typed calls such as turn/start, turn/steer, request resolution, rollback, review, compaction, and shutdown. The thread event stream owns protocol observations: turn started, item completed, message delta, error, server request. ChatWidget owns the projection of those observations into cells, streams, status, and local interaction state. BottomPane owns focused input and interruption views. The terminal layer owns raw mode, draw cadence, resize, cursor, and scrollback mechanics.
The useful reading move is to stop asking only “where is the UI state?” and ask the sharper question: “which state is a fact, which state is a request, and which state is only a rendered view?“
1. Inline TUI Means Scrollback Is Part Of The Product
Codex’s TUI is not a conventional fullscreen alternate-screen program that can repaint a private screen forever. It behaves like an inline terminal application. Completed transcript material can be written into real terminal scrollback, while the live viewport keeps mutable state: current assistant stream, composer, status surfaces, approval prompts, selection views, and redraw-sensitive cells.
That choice shows up in initialization and rendering. App owns terminal-facing orchestration state such as app_event_tx, chat_widget, transcript cells, overlays, thread channels, pending app-server requests, and terminal title/status flags. It does not become “the session runtime.” Instead, it holds an AppServerSession, the facade that talks to app-server methods.
The inline model creates three concrete obligations:
- Do not trust painted rows as durable state. Rows wrap differently after resize and can be cleared or replayed.
- Do not block protocol progress behind visual state. If the runtime asks for an approval, the UI must preserve or resolve that request even when views change.
- Do not turn every keypress into runtime authority. Most keypresses are local editor gestures; only some become
AppCommandvalues.
This is why the TUI code can look more actor-like than tree-like. It is not just a nested widget hierarchy. It is an event renderer with explicit ownership boundaries.
2. The Select Loop Is The Event Ownership Table

App::run is the ownership table: each event that wins the select loop is routed to the handler allowed to mutate local state, submit an app-server command, resolve a request, or schedule a frame.The mechanical center of the TUI is the App::run select loop. It waits on four inputs:
| Event source | Handler | What it owns locally |
|---|---|---|
app_event_rx | handle_event | Internal UI requests, consolidation, exits, browser/open-link actions, and outbound AppCommand routing. |
active_thread_rx | handle_active_thread_event | Buffered notifications for the active thread and replay-sensitive thread state. |
tui_events | handle_tui_event | Terminal input, paste normalization, draw, resize, cursor, and external-editor handoff. |
app_server.next_event() | handle_app_server_event | App-server notifications, app-server requests, disconnects, and global server messages. |
An abbreviated source excerpt makes the boundary concrete:
let control = select! {
Some(event) = app_event_rx.recv() => {
app.handle_event(tui, &mut app_server, event).await?
}
active = async {
if let Some(rx) = app.active_thread_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if App::should_handle_active_thread_events(...) => {
if let Some(event) = active {
app.handle_active_thread_event(tui, &mut app_server, event).await?;
} else {
app.clear_active_thread().await;
}
AppRunControl::Continue
}
event = tui_events.next() => {
// terminal input, draw, resize, or terminal stream closure
}
app_server_event = app_server.next_event(), if listen_for_app_server_events => {
// notifications, requests, disconnects
AppRunControl::Continue
}
};
The important point is not the syntax of tokio::select!; it is the authority attached to each branch. A terminal paste can update the composer. A server request can block runtime progress until answered. An internal AppEvent::ConsolidateAgentMessage can rewrite transcript cell ownership after streaming ends. A draw event should not invent protocol state; it should render what is already owned elsewhere.
2.1 Terminal input stays local until it crosses a command boundary
handle_tui_event keeps this split explicit. A Key goes to handle_key_event. A Paste normalizes \r to \n before giving text to ChatWidget. A Draw or Resize performs pre-render work, lets the chat widget tick timers, computes desired height, and calls either draw_with_resize_reflow or draw.
The draw path is abbreviated below:
TuiEvent::Draw | TuiEvent::Resize => {
self.chat_widget.maybe_post_pending_notification(tui);
self.chat_widget.pre_draw_tick();
let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width);
if terminal_resize_reflow_enabled {
tui.draw_with_resize_reflow(desired_height, |frame| {
let area = frame.area();
self.chat_widget.render(area, frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(area) {
frame.set_cursor_style(self.chat_widget.cursor_style(area));
frame.set_cursor_position((x, y));
}
})?;
} else {
tui.draw(desired_height, |frame| { /* same render closure */ })?;
}
}
This is why “the TUI is the runtime” is the wrong model. Draw is a projection step. It can ask ChatWidget what the current view should look like; it cannot decide that a turn has completed or that an approval has been granted. Those facts must arrive through the protocol path or through an explicit command response.
2.2 Internal app events are routed, not globally mutable
AppEvent is the internal message bus. The module documentation states the intent: widgets can request app-layer actions without directly reaching into App; exit is explicitly modeled. That local architecture rule has practical consequences.
For example, ChatWidget does not directly own the app-server session. When it wants to submit runtime work, it sends AppEvent::CodexOp(AppCommand) or a targeted SubmitThreadOp. When it finishes streaming assistant text, it sends AppEvent::ConsolidateAgentMessage. When it needs a browser link or external editor, it sends an app event. The app dispatcher then decides which submodule handles the event.
The handle_event match is intentionally exhaustive and routing-heavy. Large domain actions are delegated to focused app modules. That keeps the central loop a boundary instead of a catch-all mutator.
3. AppCommand Is The Runtime Crossing
The TUI uses AppCommand as the typed form of user intent that can cross out of presentation. A prompt submission, interrupt, approval response, permissions response, rollback, compact request, review request, shell command, or reload is not just “some UI happened.” It becomes a command with an explicit routing path.
ChatWidget’s submit_op shows the local boundary:
pub(crate) fn submit_op<T>(&mut self, op: T) -> bool
where
T: Into<AppCommand>,
{
let op: AppCommand = op.into();
self.prepare_local_op_submission(&op);
match &self.codex_op_target {
CodexOpTarget::Direct(codex_op_tx) => {
crate::session_log::log_outbound_op(&op);
if let Err(e) = codex_op_tx.send(op) {
tracing::error!("failed to submit op: {e}");
return false;
}
}
CodexOpTarget::AppEvent => {
self.app_event_tx.send(AppEvent::CodexOp(op));
}
}
true
}
The two targets matter. In test or direct paths, the command can go to a direct sender. In the app-server-backed TUI, it first becomes an internal app event, so the app layer can resolve pending requests or submit to the active thread through AppServerSession.
The next boundary is try_submit_active_thread_op_via_app_server. Its UserTurn branch first checks whether an active turn can be steered. If a turn is active, the TUI tries turn_steer; if not, or if the active-turn state races and clears, it starts a new turn through turn_start. The UI does not sample the model. It asks the app-server to steer or start a turn.
The app-server facade makes that explicit. AppServerSession::turn_start builds a typed ClientRequest::TurnStart with thread_id, input items, cwd, approval policy, reviewer, sandbox/permission overrides, model, service tier, reasoning effort, summary, personality, schema, and collaboration mode. That is the runtime boundary.
The boundary becomes practical in this table:
| UI gesture | Local owner | Runtime crossing |
|---|---|---|
| Move cursor, edit draft, open completion popup | BottomPane / composer | None. |
| Paste text | handle_tui_event then ChatWidget | None until submitted. |
| Submit prompt | ChatWidget builds input | AppCommand::UserTurn to turn_steer or turn_start. |
| Interrupt | ChatWidget / App | turn_interrupt or startup interrupt. |
| Approve a command | ApprovalOverlay records choice | AppCommand::ExecApproval resolves a server request. |
| Redraw frame | Terminal layer and ChatWidget::render | None. |
This table keeps UI richness from becoming architectural confusion. A rich terminal surface can still remain a client.
4. ChatWidget Projects Protocol Events Into Conversation State
ChatWidget is the conversation controller. Its source comments say it owns state derived from the protocol event stream: history cells, streaming buffers, bottom-pane overlays, transient status text, and keypress-to-intent conversion. It is not responsible for running the agent.
That line is easy to underestimate. ChatWidget is more than a renderer, but less than a runtime. It owns display-level semantics:
- how turn notifications update progress and task lifecycle;
- how completed items become history cells;
- how streaming assistant output becomes a live tail;
- how status, warnings, rate limits, tool runs, patches, and diffs appear;
- how local user actions become
AppCommandor internalAppEventvalues.
The protocol projection is visible in chatwidget/protocol.rs. handle_server_notification matches app-server notifications and calls local handlers. TurnStarted updates lifecycle and starts task state. TurnCompleted finalizes or interrupts. ItemStarted and ItemCompleted become command, patch, MCP, web-search, image-generation, review, or other item surfaces. AgentMessageDelta enters on_agent_message_delta.
An abbreviated slice shows the event-to-projection shape:
match notification {
ServerNotification::TurnStarted(notification) => {
self.turn_lifecycle.last_turn_id = Some(notification.turn.id);
self.on_task_started();
}
ServerNotification::TurnCompleted(notification) => {
self.handle_turn_completed_notification(notification, replay_kind);
}
ServerNotification::ItemStarted(notification) => {
self.handle_item_started_notification(notification, replay_kind.is_some());
}
ServerNotification::ItemCompleted(notification) => {
self.handle_item_completed_notification(notification, replay_kind);
}
ServerNotification::AgentMessageDelta(notification) => {
self.on_agent_message_delta(notification.delta);
}
// more protocol notifications omitted
}
The app-server notification is the fact. The local method call is the projection decision. That distinction is why replay and live handling can share the same conceptual boundary: the TUI can render a history event without claiming it originated the event.
4.1 ChatWidget owns process-level interaction, BottomPane owns local focus
The ChatWidget source comment also calls out quit and interrupt behavior: local input routing belongs to the bottom pane, while process-level decisions such as interrupting active work, arming double-press quit, and shutdown-first exit belong to ChatWidget. This is a useful example of ownership being split by authority instead of by screen area.
If a user presses Ctrl+C, the currently focused bottom-pane view may consume it. If an approval view is active, Ctrl+C may cancel that view. If no local view consumes it, ChatWidget may decide that active work should be interrupted or that exit should be requested. Same key, different ownership boundary.
That split prevents two concrete bugs:
- a local text editor mode should not accidentally send
turn/interrupt; - an active runtime turn should not be left running because the UI treated Ctrl+C as only a text-editing gesture.
The codebase expresses that split through typed views, app events, and commands rather than through one giant key handler that mutates everything.
5. BottomPane Is An Interruption System, Not A Footer
BottomPane is documented as the owning container for ChatComposer and BottomPaneView. It handles local input routing, rendering, and time-based hints, while leaving process-level decisions to ChatWidget. The correct mental model is therefore not “footer” but focused interaction plane.
It owns the composer, a view stack, delayed approval requests, pending input previews, pending thread approvals, status/footer surfaces, key state, paste state, and context-window display. Its as_renderable path gives the active view priority and otherwise composes status/footer, pending approval, pending preview, and composer surfaces.
That distinction matters because many app-server interactions are not linear transcript text. A command approval, permissions request, MCP elicitation, user-input request, app-link view, or picker is not just an output row. It is a focused state machine that must return a decision or preserve pending state.
6. Approval Requests Are Protocol State With A View

Approval handling is the clearest place to see why “UI projection” does not mean “stateless UI.” The app-server can send a ServerRequest that must be answered. The TUI has to remember it, show it, accept a decision, serialize the matching response, and send that response back to the app-server. A modal that disappears without resolving or preserving the request is a correctness bug, not merely a UX bug.
The pending ledger is PendingAppServerRequests:
pub(super) struct PendingAppServerRequests {
exec_approvals: HashMap<String, AppServerRequestId>,
file_change_approvals: HashMap<String, AppServerRequestId>,
permissions_approvals: HashMap<String, AppServerRequestId>,
user_inputs: HashMap<String, VecDeque<PendingUserInputRequest>>,
mcp_requests: HashMap<McpRequestKey, AppServerRequestId>,
}
pub(super) fn note_server_request(
&mut self,
request: &ServerRequest,
) -> Option<UnsupportedAppServerRequest> {
match request {
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
let approval_id = params
.approval_id
.clone()
.unwrap_or_else(|| params.item_id.clone());
self.exec_approvals.insert(approval_id, request_id.clone());
None
}
ServerRequest::FileChangeRequestApproval { request_id, params } => {
self.file_change_approvals.insert(params.item_id.clone(), request_id.clone());
None
}
// permissions, user input, MCP elicitation, and unsupported cases omitted
}
}
The app-server event handler records the request before it decides how to show it. handle_server_request_event calls note_server_request, rejects unsupported request families with an app-server error, extracts the target thread, and routes supported requests to the primary or active thread state. That sequencing matters: the UI view is downstream of the protocol ledger.
Resolution is the mirror path. take_resolution maps an AppCommand back to an app-server request id and serializes the correct response type. try_resolve_app_server_request then calls resolve_server_request.
ApprovalOverlay is therefore a view over pending protocol state, not the state itself. Its module docs say it converts approval requests into list-selection views and emits explicit decision events; MCP elicitation Escape maps to cancel; it does not evaluate whether an action is safe. The safety decision belongs to policy and runtime layers. The overlay’s job is to present, collect, and route a decision.
7. Streaming Markdown Forces Source-Backed Rendering

Streaming assistant text is the hardest rendering path because incomplete Markdown is not layout stable. A later token can change whether earlier text is a paragraph, list, table, code block, or link. Terminal width can also change while the stream is still live. If the TUI only stored painted rows, it would eventually lose track of the underlying content.
Codex solves this by separating temporary stream rows from source-backed transcript cells.
The streaming start path is in handle_streaming_delta. Before starting an agent stream, it flushes active exec state, handles separators, and creates a StreamController using the current stream width and render mode. on_agent_message_delta is deliberately small: it pushes the delta into that streaming path.
The consolidation path is the key:
fn flush_answer_stream_with_separator(&mut self) {
let had_stream_controller = self.stream_controller.is_some();
if let Some(mut controller) = self.stream_controller.take() {
let scrollback_reflow = if controller.has_live_tail() {
ConsolidationScrollbackReflow::Required
} else {
ConsolidationScrollbackReflow::IfResizeReflowRan
};
self.clear_active_stream_tail();
let (cell, source) = controller.finalize();
let deferred_history_cell = if scrollback_reflow == Required {
cell
} else {
if let Some(cell) = cell {
self.add_boxed_history(cell);
}
None
};
if let Some(source) = source {
self.app_event_tx.send(AppEvent::ConsolidateAgentMessage {
source,
cwd: self.config.cwd.to_path_buf(),
scrollback_reflow,
deferred_history_cell,
});
}
}
if had_stream_controller && self.stream_controllers_idle() {
self.app_event_tx.send(AppEvent::StopCommitAnimation);
}
}
The source comment at flush_answer_stream_with_separator states the invariant directly: consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell that can re-render from source after resize.
The app side receives that event in event_dispatch.rs, then delegates to agent_message_consolidation.rs. That module’s docs explain the whole design: during streaming, transient AgentMessageCells let stable lines animate into scrollback while the mutable tail stays in the bottom pane; after finish, the app replaces the trailing run with a source-backed AgentMarkdownCell, making the transcript the canonical owner of raw markdown source for future resize re-renders.
7.1 Resize reflow is a transcript replay, not a screen stretch
resize_reflow.rs is explicit about the contract. It connects terminal resize events to source-backed transcript scrollback rebuilds. It stores history as HistoryCells but writes finalized history into terminal scrollback. When width changes, it uses stored cells as source, clears Codex-owned terminal history, and re-emits the transcript.
That is a different model from “just redraw whatever was on screen.” It is closer to a projection rebuild:
| Thing | Owner | Resize behavior |
|---|---|---|
| Raw markdown source | AgentMarkdownCell / transcript cells | Re-render from source. |
| Temporary streaming rows | stream controller / active tail | Consolidate or require final reflow. |
| Terminal scrollback rows | terminal substrate | Can be cleared and replayed. |
| Overlay transcript view | app overlay state | Receives cell consolidation and frame scheduling. |
That is why “source-backed scrollback” is not decorative wording. It is what keeps inline terminal history from becoming stale after resize, streaming, or replay.
8. App-Server Events Keep The TUI Honest
The TUI has its own rich local state, but app-server events keep it honest. handle_app_server_event distinguishes lag, notification, request, and disconnect events. A disconnect becomes a chat error plus FatalExitRequest. A server request goes through the pending-request ledger. A notification may be global or thread-scoped.
handle_server_notification_event has another important boundary: it resolves pending app-server requests when a ServerRequestResolved notification arrives, updates account/rate-limit/global state for global notifications, and otherwise routes thread-targeted notifications to the primary thread or another thread. Only after target selection does ChatWidget handle the notification.
That target selection is why multi-thread and side-conversation behavior can exist without turning ChatWidget into a global runtime. The app layer owns routing. The chat widget owns projection for the active conversation.
9. Failure Modes: What Goes Wrong When You Misread The Boundary
Misreading the TUI as “the runtime” usually produces one of five bugs:
| Misread | Concrete failure |
|---|---|
| Treat painted terminal rows as state. | Resize or replay produces stale scrollback, broken wrapping, or duplicate stream tails. |
| Treat approval modals as plain UI. | The app-server request remains blocked or is lost after view dismissal. |
| Treat key handling as direct runtime mutation. | Local editor gestures interrupt turns, or runtime interrupts are swallowed by local views. |
Treat ChatWidget as the app-server owner. | Protocol requests, request resolution, and thread routing become tangled with display state. |
| Treat every notification as active-thread display. | Side-thread or global notifications leak into the wrong transcript. |
The codebase’s answer is not “make the UI thinner.” The answer is “make ownership typed.” Rich UI state is allowed; it just has to stay on the correct side of the boundary.
10. Apply This
- Read the TUI as a projection. Runtime facts arrive as app-server notifications and requests; the TUI renders, routes, and responds.
- Separate local interaction from runtime authority. Draft editing, popups, and cursor movement are local.
AppCommandvalues are the crossing. - Keep pending protocol work in a ledger. Approval, permissions, user-input, and MCP elicitation requests must be resolved or preserved, never silently dropped.
- Render scrollback from source and verify it visually. Painted rows are a projection cache, not the canonical transcript; figures, snippets, and lazy images still need to render clearly in the actual book layout.
- Let the app layer route targets.
Appdecides primary-thread, side-thread, global, and fatal paths;ChatWidgetshould not become a global message bus.
Closing
The TUI completes the client-side arc of this book. Chapter 14 defined the app-server as the shared thread contract. Chapter 15 showed how SDKs, daemons, and remote-control bridges preserve or narrow that contract. Chapter 16 shows how a rich terminal surface can remain a client: it owns interaction, projection, source-backed scrollback, and user decisions, but it does not usurp the runtime.
That distinction matters beyond Codex. Agent systems increasingly need multiple frontends: CLIs, IDE panes, browsers, remote dashboards, SDK consumers, and automation hooks. The transferable pattern is not “copy Codex’s terminal UI.” It is “keep runtime authority behind typed protocol boundaries, and let each client own only the projection it can honestly maintain.” Part V turns from clients to extension surfaces: MCP, skills, plugins, connectors, and the governance needed when new capabilities enter the runtime.
Source Map
| Concept | Source anchor |
|---|---|
| TUI app state and select loop | codex-rs/tui/src/app.rs, App::run, handle_tui_event |
| Internal app event bus and command routing | app_event.rs, event_dispatch.rs, app_command.rs |
| App-server session facade and turn submission | app_server_session.rs, turn_start, thread_routing.rs |
| Chat widget protocol projection and streaming | chatwidget.rs, chatwidget/protocol.rs, flush_answer_stream_with_separator, handle_streaming_delta |
| Bottom pane and approval overlay | bottom_pane/mod.rs, as_renderable, approval_overlay.rs |
| App-server requests and source-backed scrollback | app_server_requests.rs, app_server_events.rs, agent_message_consolidation.rs, resize_reflow.rs |
| Rendering and consolidation tests | chatwidget/tests/status_and_layout.rs, chatwidget/tests/exec_flow.rs |