Start from an ordinary turn. The assistant is streaming text. A command starts, produces output, and completes. A permission prompt may need a user response. The turn finally moves from running to completed. Later, the same thread can be resumed and the transcript appears again.

The source does not treat those as separate UI tricks. Core emits and persists facts; app-server projects them into JSON-RPC notifications; TUI projects them into history cells and status surfaces; rollout keeps the records needed for resume. Client projection is the boundary between those shapes.

Projection means one runtime fact changing owner and shape. It may be an EventMsg, a ServerNotification, a ThreadItem, a TUI history cell, or a rollout record. Ask who owns the shape before asking how it appears on screen.

Evidence boundary. This article only describes event types, mapping functions, notification structs, TUI handlers, and rollout reconstruction visible in the public openai/codex source. It does not infer private product UI internals.

1. Separate the Shapes First

The terms are easy to blur because several of them sound like history or events. They carry different ownership.

Shape Owner Question answered
EventMsg core protocol What runtime fact just happened?
TurnItem core item stream Which response item is worth exposing as a turn item?
ThreadItem app-server v2 protocol How should clients receive transcript items?
ServerNotification app-server JSON-RPC Which notification should clients observe?
history cell TUI How should this fact render in terminal scrollback or live status?
rollout record persistence / resume Which fact must survive so the thread can be reconstructed?

A message delta can update a live TUI stream immediately. The completed assistant message later becomes a stable item. Those two surfaces serve different lifetimes: one is live feedback, the other is replayable history.

2. Core Decides What Becomes a Turn Item

Client-visible items are narrowed before app-server rendering. parse_turn_item maps provider ResponseItem values into TurnItem values for user messages, assistant messages, reasoning, web search, and image generation. It also filters contextual fragments such as permission, skills, and collaboration-mode payloads that are runtime context rather than visible transcript.

Completed response items pass through record_response_item_and_emit_turn_item: record the response item, parse a turn item, then emit item-started and item-completed events when a visible item exists. record_conversation_items updates in-memory history, persists rollout response items, and emits RawResponseItem. Raw response, turn item, and UI item are therefore separate layers.

pub(crate) async fn record_response_item_and_emit_turn_item(
    &self,
    turn_context: &TurnContext,
    response_item: ResponseItem,
) {
    self.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
        .await;

    if let Some(item) = parse_turn_item(&response_item) {
        self.emit_turn_item_started(turn_context, &item).await;
        self.emit_turn_item_completed(turn_context, item).await;
    }
}

The useful lesson is the order: Codex records the provider response as conversation state first, then asks whether it can become a client-visible turn item. App-server and TUI therefore receive a narrowed runtime fact, not an arbitrary slice of the raw provider payload.

3. App-Server Projects Facts Into Notifications

The app-server entry point is apply_bespoke_event_handling. It receives core events and maps them to app-server protocol surfaces. TurnStarted clears pending server requests, notes the running turn, and sends ServerNotification::TurnStarted. Turn completion aborts pending requests and emits a completion notification with status.

The wire names live in ServerNotification: turn/started, turn/completed, item/started, item/completed, and delta notifications. The v2 Turn shape carries items, items_view, status, errors, and timing metadata.

3.1 Turn Status Is a Client Projection

Turn start sends an in-progress turn with unloaded items. Completion goes through handle_turn_complete, which reads app-server turn summary state and chooses Completed or Failed. Then emit_turn_completed_with_status sends the client-facing shape.

3.2 Item Lifecycle and Deltas Use Different Channels

Stateless one-to-one mappings use item_event_to_server_notification. It maps item lifecycle events to item notifications, agent text deltas to AgentMessageDelta, and command output bytes to CommandExecutionOutputDelta. Some paths add app-server state: command begin/end events are deduplicated, and legacy command items are suppressed for unified exec interactions so clients do not render duplicate wait states.

3.3 Approval Is a Request Surface

Approval events become server requests because the client must answer. ApplyPatchApprovalRequest and ExecApprovalRequest send approval requests and await responses. They are not passive history notifications.

4. TUI Projects Notifications Into Terminal State

TUI first routes app-server events by thread. For the active thread, handle_thread_event_now forwards notifications to ChatWidget::handle_server_notification. That handler maps turn start to running state, turn completion to final cleanup, item notifications to item-specific lifecycle handlers, and deltas to streaming or active command cells.

4.1 Streaming Is a Temporary Tail

handle_streaming_delta creates or updates a stream controller and starts commit animation. When the final assistant item lands, flush_answer_stream_with_separator consolidates the streaming cells into one source-backed markdown cell so resize and replay remain stable.

4.2 Item Type Chooses the History Cell

handle_item_started_notification dispatches by ThreadItem: commands to command lifecycle, patches to patch cells, MCP calls to MCP cells, web search to active search cells, and so on. Completed items pass through handle_thread_item, which is shared by live handling and replay.

4.3 Turn Completion Settles Temporary Surfaces

on_task_started resets turn flags, opens the running status, and shows the interrupt hint. on_task_complete flushes streaming and unified exec state, adds final separators when needed, clears running commands, and decides whether to send notifications or queued follow-up input.

5. Rollout Keeps Recovery Evidence

Rollout persistence does not store every transient UI delta. rollout policy selects recordable response items and event messages. User messages, assistant messages, reasoning, turn boundaries, compaction, rollback, web search end, and image generation end survive. Many intermediate deltas, approval requests, exec output deltas, and hook lifecycle events do not.

RolloutRecorder queues canonical items, materializes and flushes rollout files, and loads jsonl lines back into InitialHistory::Resumed. Core then uses reconstruct_history_from_rollout to rebuild model history, turn metadata, reference context, and rollback effects. TUI replay renders safe items and marks them as replayed so live side effects are not triggered.

6. Four Reading Rules

Visible symptom Ask first Source layer
Text is streaming. Is this a delta or a completed item? AgentMessageDelta and stream consolidation.
A command card changes. Is this lifecycle or output delta? event mapping and command lifecycle.
An approval prompt opens. Does the client need to answer? ServerRequestPayload.
History reappears after resume. Is this reconstructed state or a live event? rollout reconstruction and TUI replay.

Client projection protects readability and recovery. Core owns facts, app-server owns protocol boundaries, TUI owns live terminal experience, and rollout owns resume evidence. The next runtime layers, including plugins, MCP, skills, and subagents, still have to pass through the same question: who owns this fact, what can clients see, and what must survive for a future resume?

Sources