GPUI Box GitHub

Content

Markdown, AgentDocument, and MessageList are the surfaces in this library that draw text nobody in the application wrote. A button's label is authored; a document's contents, a typed model result, and a message's body are not. They arrive from a file, a model, or another person, and they may contain a tag, a destination, or an image reference that would act on the reader if anything here acted on it.

So nothing here acts. This file is the posture written down.

AgentDocument keeps Markdown in that prose role instead of making it the protocol for every model result. Its stable, revisioned blocks carry existing code, diff, schema, chart, image, tool-call, notice, choice, and artifact components. A reconnect updates the caller-owned block id; duplicate ids are reported and never silently merged. Markdown events include the block id that produced them, so a host applies the same link and image policy it already uses for a standalone document.

PersonaDialogue keeps the same boundary while adding an expressive speaker, localized execution status, streaming treatment, and typed choices. Its PersonaDialogueEvent::Markdown carries the turn id alongside the unchanged MarkdownEvent; markdown_image and markdown_highlighter are the same host-resolution seams as the standalone renderer. A disabled choice shows the host's refusal and installs no callback. The component never opens a link, fetches portrait or document art, samples a microphone, recognizes speech, or advances the dialogue.

The Markdown security posture

Raw HTML is shown, never run, and never dropped

A fragment of HTML — <div onclick="…">, <script>, <b> — reaches the tree as Block::Html or Inline::Html and is drawn as the literal characters that were written, in the mono face, in a frame that says unrendered html. The node it publishes carries the same words in value.

Both halves of that matter, and they fail differently:

  • Interpreting it would let a document reach outside its own text: into the layout around it, into the pointer, into whatever the host's element tree can be persuaded to do. This crate has no HTML renderer and will not acquire one; pulldown-cmark is compiled with its html feature off.
  • Dropping it would let a document delete its own content from the reader's view by wrapping it in a tag. A reader who is shown a tag knows something was written that this component would not draw. A reader who is shown nothing believes nothing was written. Silence is the worse failure of the two, so the tag is visible and marked.

Native Markdown is not a browser HTML surface

The native subset is Markdown headings, paragraphs, emphasis, strike-through, links, images supplied by the host, code fences, quotes, lists, task markers, rules, and tables. There is no interpreted HTML subset: even <b> and <br> remain visible literals. CSS, scripts, event attributes, forms, iframes, DOM APIs, and browser layout are not implemented by Markdown. BrowserPanel is the separate browser/WebView surface; its host owns navigation and engine policy. Supplying a block plugin does not enable a browser or sanitize HTML.

Leading --- YAML and +++ TOML frontmatter are Block::Frontmatter values. The metadata body is retained verbatim and drawn as selectable, copyable code by default. It never mutates theme, permissions, configuration, or application state. Only the document's beginning recognizes frontmatter; a rule at the beginning of an incremental tail remains a rule.

Markdown::block_renderer receives each mounted top-level parsed block, a source-offset-derived identity, and a reading-order start. Returning None uses the normal safe renderer, including unknown fence languages and HTML. A plugin is trusted host code, not document code: validate its input, use the supplied identity for semantic targets, and use fewer than 65,536 reading-order values starting at the supplied value for selectable runs. The next block has a separate partition. Plugin elements are rebuilt when mounted, so the callback must be repeatable. Native plugins for notices, math, diagrams, or structured results can be supplied without teaching Markdown any product model.

AgentDocument::configure_markdown applies the same host image, highlighter, and plugin policy to every mounted Markdown row. It receives the caller's block id, independent of revision. After asynchronous media or plugin geometry changes, call AgentDocument::remeasure_block: it invalidates only that block's virtual rows through the framework and preserves the current pixel anchor.

Retained parsing and planning work

MarkdownStream::read accepts source replacements and detects appends; append accepts only a delta and avoids comparing or copying the settled source prefix. Both retain parsed blocks and parse the affected tail with the same context as the canonical parser. Tree and source boundaries are collected in one traversal. Link-reference definitions require whole-document parsing because they can change earlier links, including multiline reference labels. Virtual rows receive those fully resolved parsed blocks, not independently parsed source slices. Source ranges alone do not preserve reference context.

Static Markdown borrows its tree. Streaming display mending caches only the affected final block, never clones the settled document on a static frame. AgentDocument retains parsed row trees and its flattened plan across static frames. Stable row keys and content revisions are separate: an append changes only affected row revisions, not semantic identities. Caller revision changes invalidate typed custom blocks; Markdown derives revisions per parsed part.

At 128 KiB, standalone Markdown and virtualized AgentDocument move parsing to GPUI's background executor. Each source identity has at most one parse in flight; arriving replacements coalesce into the latest pending source. Small documents keep synchronous incremental updates. A large initial document shows localized Loading with a busy semantic state; a refresh retains the last verified document until the current parse completes. Characters may therefore appear together rather than one per frame. Obsolete completions never publish, and a worker prefix not previously published cannot be reused as a visible prefix. Completion refreshes only the owning window. Dropping a document or replacing it with a small source discards its completion target. This moves parsing, not standalone layout, off the UI executor: use virtualized AgentDocument for very long rendered documents.

MarkdownStream::work counts parser passes, bytes submitted to those passes (including boundary discovery, fallback and mending), and source-buffer writes. It does not measure CPU time or syntax-tree allocation. AgentDocument::work also reports input comparisons and row records constructed by the actual mounted virtual document. Static reconciliation is linear in caller blocks. Changed documents retain immutable per-caller-block segments and unchanged row records inside them, rather than reconstructing a flattened history. A new one-paragraph message constructs one row; adding a second paragraph constructs two (the old last row changes spacing/streaming metadata, and the new tail is new). Source-key strings are retained with the rows. Changed input still walks metadata and copies key/revision vectors into List; those operations and GPUI layout are outside the row-construction counter. A never-mounted row still has no selection geometry; virtualization does not promise a complete copy of unseen content.

A link states where it goes, and this crate opens nothing

Before a link is taken, its destination is in two places the reader can reach: hover help, through the same Tooltipped trait every other control uses, and the Link node's value. A title the author wrote is shown beside the destination, never instead of it, because a friendly label over a hostile destination is exactly the shape of the attack.

Taking it reports MarkdownEvent::LinkClicked { href } and does nothing else. Whether an https link may be opened, whether a file link may be, and what happens to a scheme nobody expected are all host policy over host capability, and this crate has neither.

An image is named, not fetched

This crate has no network and no asset resolution, so an image reference is drawn as a placeholder naming its alt text and its source, and reported as MarkdownEvent::ImageRequested { src, alt }. A grey rectangle would hide which image is missing; the placeholder says.

The request is made once per source per rendered document, not once per frame, so a host that answers by supplying the image does not provoke another request by answering. A host that has the bytes returns an element from Markdown::image; a host that returns None has said it cannot, and the placeholder stays.

Code is coloured from what the writer said it is, and never from a guess

A fenced block publishes its info string exactly as it was written — rust, rust,no_run, whatever it says — and a block with no info string publishes plain text rather than a language somebody inferred. That name is the only thing colour is taken from: content::highlight has a table for eight languages, and a fence naming one of them is coloured. A fence naming anything else, or naming nothing, is drawn exactly as it was written. Nothing here reads the code to work out what it probably is.

What the scanner finds is four classes — keyword, string, comment, number — tagged with SyntaxColor and drawn from the theme's syntax palette. It stops there on purpose. Types, calls, scopes and errors need a grammar per language and a resolver behind it, and a design system that shipped a half-right one would be putting a claim on the screen it cannot support. A host that has a real grammar installs Markdown::highlight and its spans win outright, in the same SyntaxColor vocabulary, so both sources say the same thing about the same code.

Highlighting is paint and nothing else. Every span lands on the same glyphs at the same size in the same font, so a block looks identical whether or not the pass has run: no reflow when colour arrives, and nothing holding layout back while it is computed. Scanning is line by line with a small carry for what crosses lines, which is what lets a block still streaming be coloured as it arrives instead of once it stops, and a block's result is cached beside it so a frame that changed nothing scans nothing.

Code blocks and prose runs use GPUI's document selection. Pointer dragging can cross separately mounted runs and blocks in reading order; reverse selection, Copy, Select All, wrapped and bidirectional hit testing, and AccessKit selection all operate on grapheme boundaries. Every code block still carries a whole-value copy action that includes text outside a mounted or visible range. MarkdownEvent::CodeCopied is emitted only after a successful clipboard write. An installed clipboard policy evaluates the inherited effect owner; refusal leaves the clipboard unchanged and emits CodeCopyRefused without a payload. Markdown and CodeView display a persistent, localized “Not copied” status until a retry succeeds. A write grant does not require a read grant: these controls do not read the clipboard back. Without an installed policy, ordinary native clipboard behavior is unchanged. Nothing is reflowed, retyped, or re-indented on the way.

Code fences, quotes, tables, code views, diffs, and log streams stay on their caller's reading plane. Their type, whitespace, rails, marks, and row rules state their hierarchy; raised rounded frames are reserved for supplied or missing media and other content that owns a visual plane. This keeps ordinary information from turning into a stack of competing cards.

Truncation says how much it cut

Markdown::max_lines(n) keeps the first n lines and offers the rest by name: Show 12 more lines, which reports MarkdownEvent::MoreRequested. A fade over the last line says something was cut without saying how much, which is a decoration standing where a fact belongs.

A line here is a line of the document's own structure — a heading, a paragraph, one line of a code fence, one list entry, one table row — not a line of wrapped text, whose count only layout knows. A block that cannot be cut without lying, such as a table split from its header, is left out whole rather than split.

What the renderer gives up

A line of prose is drawn as a wrapping row of separately addressable runs, so a link and an image each have their own bounds for a pointer and for a test to find. GPUI has no way to hang a probe on a byte range inside a shaped line, and an unaddressable link is one no test can prove reports its destination. The cost is that a line breaks between runs rather than inside a run that spans two styles.

The message list's delivery vocabulary

DeliveryState has five members and five renderings:

StateMeansDrawn as
Sendinghanded over, not yet acknowledgedneutral, published busy
Sentthe host accepted itinfo
Deliveredit reached the other sideaccent
Readthe other side opened itsuccess
Failed { reason }it did not arrive, and here is whydanger, published invalid

Collapsing sent, delivered, and read into one tick tells the reader less than the host knows. Folding a failure into any of them tells them something untrue.

A failure is never removed and never retried by itself. A failed message keeps its position, its author, its time, and its full text, states the host's reason word for word rather than a friendlier sentence, and gains exactly one thing: a Try again control that reports the message id. Whether to resend, and what a resend even means, belongs to the transport this crate does not have.

Streaming

A message may be streaming, which draws a live mark beside its byline and publishes a streaming node. The mark is keyed to the message's identity and nothing else, so text arriving into a stream does not restart it: content::message_list::streaming_since answers with the instant a stream began, and it is the same instant after the body grows. A "still writing" mark that flickered on every token would be reporting the token, not the stream.

Grouping is declared, not inferred

MessageList::group_consecutive(bool) decides whether consecutive messages from one author are drawn as one turn, for the same reason Toolbar::overflow_after is declared: whether two messages a minute apart are one turn or two is a judgement about the conversation, and this component knows nothing about the conversation. A continued turn drops the repeated byline and keeps the space, because the slot is one height and a hole where a name was is not a saving.

Following the newest message is conditional, and says when it does not

The list follows a new message only while the reader is already at the bottom. Dragging somebody back down while they are reading something further up takes the conversation away from them.

When it does not follow, it says so, with the count: 3 new messages for messages that arrived while the reader was away, 3 more messages for messages that have simply always been further down. This is ScrollArea's rule — content continuing past the view is published rather than left to be noticed — specialized for a surface that grows from the bottom.

Times and authors nobody recorded

A message's time is an EntryTime, the same type Timeline takes and for the same reason: turning an instant into words is calendar, time-zone and locale work, and whoever owns the clock owns the wording. A message whose time nobody knows says Time unknown and publishes time unknown; it is not floated to an end that would claim when it happened. An author nobody named is unknown, not blank, because a blank byline reads as a message with no author rather than one whose author was not recorded.

One message, one slot — unless the caller buys measurement

The default List is uniform, so every row is the same height. That is what makes a conversation of ten thousand messages cost what one of ten costs, and the price is stated rather than hidden: MessageList::body_lines(n) decides how tall a slot is, and a plain body longer than that reports how many lines it left out beside the delivery state.

MessageList::grows_to_fit() chooses the other truthful trade. Rows remain virtualized, but GPUI measures each one when it first approaches the viewport and keeps that height, so every message is whole. The list can only estimate the extent of rows it has not measured, and its scrollbar settles as the reader moves through them. The last call to body_lines or grows_to_fit wins.

Media

ImageViewer and TransportBar are the same posture from the other side. The two surfaces above draw content nobody in the application wrote; these two frame content this crate cannot produce at all. Nothing is fetched and nothing is played.

An image arrives from the host or not at all

There is no network here and no asset resolution, which is exactly why Markdown names an image instead of drawing it. ImageViewer keeps that rule over a whole frame: the picture comes back from ImageViewer::image, and a host that answers None gets a frame naming the image and its source, plus one ImageViewerEvent::ImageRequested. The request is made once per image for as long as the viewer is on screen, so answering it does not provoke another.

Four states are four renderings, and each fails differently:

StateMeansDrawn as
Loadingthe host is still fetchingthe name, Loading, published busy
Unavailable(reason)the host could not supply it, and here is whythe name and the host's own sentence, in the warning tone
Failed(reason)the bytes arrived and could not be readthe name and the host's own sentence, published invalid
Readythe host has itthe picture, or a frame naming what was not supplied

A refusal is never an empty frame. A reader shown a blank rectangle cannot tell a refusal from an image of nothing, which is the same failure Markdown's placeholder exists to avoid.

A size nobody stated is not the size of the box it was drawn in

Natural dimensions are a caller input. Contain, Cover and Actual are all ratios between the source and the frame, and Actual in particular claims one image pixel per frame pixel — a claim nobody can make about an image whose pixel count the host never gave. So a viewer with no dimensions reads Size unknown, refuses the fit and zoom controls, and hands the picture the frame without saying how much of the source that shows. Reporting the rendered size as though it were the source's would invent the one fact the host declined to give.

The frame's own extent comes from the measurement taken during prepaint, the same layout::measure cell the slider and the split divider read, because zoom at a point and a clamped pan are both arithmetic against a box only layout knows. A frame that has not been measured yet states no scale rather than stating zero.

Zoom is anchored at the pointer by recording the image point that was under it and the frame point it was under, both normalized. That pair survives a zoom the caller has not applied yet: a host that refuses the new scale renders at the old one, and the same arithmetic puts the picture back exactly where it was. A pan is clamped so the picture cannot be dragged past its own edge, and is offered at all only while the picture is larger than the frame.

Stepping never wraps. Past the last image the control is refused — it installs no handler at all — and the position is published as 2 of 2, because a reader returned silently to the first image has been told the gallery is endless.

A duration nobody knows is a state

This is PageTotal::Unknown for a timeline, and for the same reason: a page count nobody counted is a number nobody can trust, and a live stream has a position and no total. TransportDuration::Unknown therefore draws no track fraction at all — no fill, no head, no buffered band — states Duration unknown where a remaining time would be, and publishes the scrubber as a status rather than a slider, because there is no range to slide along. A half-filled bar over an unknown total would be reporting a number nobody has.

Scrubbing is refused there too. The forward end of a stream nobody measured is the host's to clamp, so an arrow key still reports the position it was asked for and the host decides whether that is past the live edge.

Everything reports, and nothing is applied

TransportEvent is the whole vocabulary: PlayRequested, PauseRequested, SeekPreview, SeekRequested, VolumeRequested, MuteToggled, SpeedRequested, Stepped. The head is drawn where the caller says it is, so a refused seek keeps the position that still holds — the rule every value in this library follows.

A scrub reports SeekPreview on every move and SeekRequested once, on release. That split is what lets a host show a preview frame without seeking once per pixel. It is the press-move-release gesture the slider and the split divider already use, with the track's measured bounds turning a pointer position into seconds; the drag is followed on the whole bar rather than on the few pixels of the track, so letting go outside it still commits once.

Buffered ranges are drawn as their own band, never folded into the played fill: a reader who cannot tell them apart has been told the media is further along than it is. A host that supplies no ranges gets no band and no node, so "no buffer reported" stays distinguishable from "buffered to the start".

Waiting is not paused

TransportState::Buffering is playing and stalled. It says Waiting for data, publishes busy, and still offers the control that would stop playback, because nothing has stopped. A stalled transport drawn as paused sends the reader to the control that would resume something that never stopped.

Times come from the host

The crate formats no durations. elapsed and remaining are finished strings, exactly as Timeline and MessageList take theirs, because turning seconds into words is locale work and whoever owns the clock owns the wording. A transport given no strings says Time unknown rather than counting for itself. The numeric position is a separate input, and it is the one that drives the track geometry — the picture and the words never come from the same place, so neither can quietly stand in for the other.