13 KiB
Pico rendering
This document defines how a presentation renders a replicated Pico conversation. It covers local and
remote clients, initial attachment, live streaming, terminal handoff, renderer registration and stable
component identity. The harness model remains defined by pico-v3.md; the examples and client-facing
surface belong in pico-usage-guide.md.
The target is the directness of coding-agent interactive mode and experimental/mini, without their
hard-coded event vocabulary or duplicate hydration logic.
1. The presentation owns a replica, not agent state
A presentation owns one ConversationView. It does not read storage, parse commits or hold live task,
provider, tool or model objects.
Opening a watch atomically captures the view and registers its listener. The harness buffers changes until delivery starts. Each committed change is folded into the view before listeners observe its events. An in-process client uses the harness-owned view; a remote client applies the same exported kind-free reducer before notifying its UI.
Delivery over one WebSocket or SSE connection is ordered and gap-free. If delivery fails or a bounded queue overflows, that watch closes. Reconnection opens a new watch and receives a fresh view. There are no cursors, epochs, acknowledgements, deduplication, replay requests or in-place resnapshot protocol.
The view is authoritative. Events identify the affected portion so a live renderer can avoid scanning the whole view; they are not a second source of presentation state.
2. Local view access
The raw collections remain serializable. A local wrapper adds indexed and typed access:
interface ConversationView {
readonly conversation: Conversation;
readonly entries: readonly Entry[];
readonly tasks: readonly Task[];
readonly previews: ReadonlyMap<Id, JsonValue>;
readonly inbox: readonly Element<QueuedInput>[];
readonly values: ReadonlyMap<Address, JsonValue>;
readonly context: readonly Id[];
readonly faulted: boolean;
readonly readAt: Id;
entry(id: Id): Entry | undefined;
task(id: Id): Task | undefined;
entriesOf<K extends EntryDefinition>(kind: K): readonly EntryOf<K>[];
tasksOf<K extends TaskDefinition>(kind: K): readonly TaskOf<K>[];
preview<S extends TaskStateBase, H extends HookPoints, C extends ConfigSpec, P,
R extends TaskRoles<S>>(
kind: TaskKind<S, H, C, P, R>, taskId: Id,
): P | undefined;
}
preview(kind, taskId) returns undefined when the task is absent, has another kind or has no live
preview. Built-in and plugin renderers use it without casts. task(id) is sufficient to classify a
task_output event because the reducer has already folded that event before the listener runs.
A remote wire view may remain arrays and maps encoded as JSON. The wrapper builds its indexes locally; those indexes are not another replicated representation.
3. Two rendering paths
Both paths use the same registered renderers and component keys. They differ only in how much of the view they visit.
3.1 Hydration
Hydration runs on first attachment and after opening a replacement watch. It walks the current entries and live tasks and upserts their blocks:
function hydrate(view: ConversationView): void {
for (const entry of view.entries) entryRenderers.render(entry, view);
for (const task of view.tasks) taskRenderers.render(task, view);
chrome.sync(view);
}
This is the view-driven path used by Mini. Existing blocks are reconciled by stable key, so a fresh view need not tear down unchanged terminal components.
3.2 Live events
Normal streaming uses targeted event handling, as interactive mode does:
function handle(event: ConversationEvent, view: ConversationView): void {
switch (event.type) {
case "entry":
entryRenderers.render(event.entry, view);
break;
case "task_start":
case "task_update":
taskRenderers.render(event.task, view);
break;
case "task_output": {
const task = view.task(event.task);
if (task) taskRenderers.render(task, view);
break;
}
case "task_end":
taskRenderers.end(event.task, view);
break;
case "inbox":
case "value":
case "context":
chrome.sync(view, event);
break;
case "fault":
case "closed":
chrome.connection(event);
break;
}
}
A streamed token therefore updates one task renderer. It does not rebuild every transcript block or
run every layout. A client that does not need the optimization may ignore the event payload and call
hydrate(view) after every notification; it remains correct.
The renderer callbacks, not a mandatory full-tree diff, are the shared path between hydration and live updates.
4. Renderer registry
Entry and task renderers are registered by kind. Registration is typed by the kind object even though the wire and internal registry use its string token:
renderers.entry(noteKind, previous => ({
render(entry, host, view) { /* entry is NoteEntry */ },
}));
renderers.task(downloadKind, previous => ({
render(task, preview, host, view) { /* typed task and DownloadPreview */ },
end(task, host, view) { /* terminal typed task */ },
}));
The exact component type belongs to the presentation package, not Pico. The common contract is:
renderupserts components through the host using stable keys;endhandles terminal live-state cleanup and does not need a retired preview;- renderers do not directly parent components in the transcript container;
- registration may replace or wrap
previous; - built-ins and plugins use the same API;
- removing renderer code does not affect the replicated view.
An unregistered entry kind uses a generic renderer: model text when present, otherwise collapsed kind/data. An unregistered task kind uses kind, status and a collapsed raw preview when present. Unknown kinds must remain visible and must not break hydration.
The heterogeneous registry may erase renderer types internally. That cast or validation boundary is owned once by the registry; renderer authors do not cast task state, entry data or previews.
5. Renderer host
The host owns component instances and placement. Renderers only describe or update blocks through it. At minimum it maintains:
entry id -> settled transcript block
live task id -> live task block or region
tool-call id -> tool component
inbox item id -> queued-input component
It provides keyed get, upsert, move/adopt and remove operations. A renderer can update an
existing component without knowing which container currently parents it. The host preserves expansion,
selection and other local UI state while adopting or moving a block.
No persistent tool task id -> tool-call id map is required. Every tool-task state variant carries
its call:
- on
task_output,view.task(event.task).state.call.ididentifies the component; - on
task_end, the event's terminal task carries the same call; - tool-result entries use their indexed call key.
A renderer may cache expensive presentation-only products such as parsed Markdown. Such caches are never authoritative and may be discarded on hydration.
6. Stable identity and terminal handoff
Every rendered object has a stable semantic key:
| Object | Key |
|---|---|
| transcript entry | entry ID |
| live generation | generation task ID |
| assistant content block | generation task ID + content index |
| tool component | tool-call ID |
| live tool preview lookup | tool task ID |
| inbox item | list-element/input ID |
| compaction, job or plugin task | task ID |
A component may change ownership without changing its semantic identity.
Assistant handoff
A generation preview renders a partial assistant message under the generation task ID. The final
assistant entry has byTaskId equal to that generation task. Its renderer adopts the existing live
component, applies the immutable final message and then associates the settled transcript position
with the entry ID. It does not append a duplicate assistant component.
If no matching live component exists, as on initial attachment, the entry renderer creates the same settled component directly.
Tool handoff
Tool-call components are born while the assistant preview streams and are keyed immediately by the call ID. A later tool-task start marks that same component running. Tool output updates it from the typed preview. The terminal tool-result entry updates the same call-ID component with immutable final output. No phase creates a second tool component.
Other tasks
A task that has no terminal entry removes or finalizes its live block in end. A task kind that
requires a durable transcript representation writes that entry in the same commit in which it
settles. The view is already folded to the whole commit before listeners run, so no client-visible idle
or missing-result state exists between those events.
7. Task provenance required by rendering
Task creation records generic provenance just as entry creation does:
interface Task {
// ...
readonly byTaskId?: Id;
}
ConversationTx fills byTaskId from the invocation whose commit created the task; it is absent for
tasks created outside a task invocation. Task kinds do not write it themselves.
This replaces kind-specific provenance such as JobState.origin. A job created by a tool has
byTaskId equal to the tool task. A renderer follows that task and narrows it with toolKind.is to
select the originating tool renderer. Nested delegated work follows the chain as needed.
8. Structured previews
A preview is the complete current render model for one live task, not a text delta.
The generation preview is the full partial AssistantMessage assembled from
AssistantMessageFrames. It retains ordered content blocks, text, thinking, signatures, partial tool
arguments, provider metadata, usage and diagnostics. task_output.ops only transports changes; the
renderer reads the already-updated message through view.preview(generationKind, task.id).
A tool preview is the complete current ToolOutputState: text or structured content, plugin details,
images, usage, delegation, handoff, added tools, termination, diagnostics and truncation metadata. Tool
renderers own plugin-specific interpretation. The generic host does not flatten it to text.
Terminal rendering uses immutable entries or terminal task state because settlement retires the live preview.
9. Layouts are optional presentation policy
Grouping tool calls, collapsing a run until its final answer and similar structures are useful, but a general composable layout/reconciliation pipeline is not required by the Pico watch or renderer contract.
A presentation may add layouts above the keyed renderer host. Layouts must preserve block keys and must not make preview delivery depend on rebuilding the complete transcript. They should initially run only for structural changes unless a particular layout explicitly depends on live preview content.
The first coding-agent client should reach feature parity with direct keyed placement. Add a general layout API only after at least two concrete layouts demonstrate that a shared abstraction is smaller than their local implementations.
10. Client-owned visible work
A client operation that runs over time and should survive in the transcript is an ordinary task kind,
not a presentation-only event stream. For example, !cmd is a pi.user_bash task owned by the coding
agent or bash plugin. It shares process-output helpers with jobs, publishes a typed preview, and writes
its terminal entry in the same commit as settlement.
The presentation starts it with a command and then handles its generic task_start, task_output,
task_end and entry events. Escape calls abortTask(id). Plugins use exactly the same pattern for
client-initiated work.
11. Mapping from interactive mode
| Interactive-mode event | Pico rendering |
|---|---|
agent_start / turn_start / assistant message_start |
generation task_start |
message_update |
generation task_output; read typed assistant preview |
message_end |
assistant entry; adopt by byTaskId |
tool_execution_start |
tool task_start; identify component by call ID |
tool_execution_update |
tool task_output; read typed tool preview |
tool_execution_end |
tool task_end plus tool-result entry |
compaction_start / _end |
collapse task_start / task_end; summary entry |
| retry events | generation or collapse task_update |
agent_end / agent_settled |
no live foreground task after the commit |
queue_update |
inbox |
| thinking/model change | typed configuration value |
| custom entry | registered entry renderer or generic fallback |
bash_execution_update |
pi.user_bash task_output |
The generic vocabulary changes dispatch, not component behavior. Compared with interactive mode, Pico removes the second independently authored hydration implementation. Compared with Mini, it replaces hard-coded operation fields with typed task kinds and replaceable renderers while retaining the same small keyed-component model.