TUI Surfacing
"Surfacing" is how the pretty frontend promotes spans buried deep in the trace
tree up to the top level — so dagger trace on a CI run leads with its
checks (sub-checks and tests rolled up beneath), and dagger script --model
leads with the LLM conversation, instead of the raw connect/load/exec tree.
There are two independent layers. Get this distinction first — most confusion comes from conflating them:
- Final report — reveal-independent.
DB.SurfacedX()walks all spans by a marker attribute and builds a tree from scratch, ignoringreveal. Rendered as a dedicated section inrenderFinalReport. This is whatdagger traceand the end-of-run report use. - Live tree — promotion-driven. Spans set
reveal=trueand the frontend promotes them to the top by marking the root spanpassthrough; it reuses the normal row/tree machinery, which reads each span'sRevealedSpansset. Checks still populateRevealedSpansviarevealbubbling. The LLM conversation no longer setsreveal— insteadpromoteConversationLockedwires the reveal-independentSurfacedConversationtree intoRevealedSpans(DB.PromoteConversationTo), so the same machinery surfaces it. This is what the interactive TUI shows mid-run.
The flag that switches render paths is fe.finalRender (in frontendPretty.Render,
dagql/idtui/frontend_pretty.go).
Marker attributes
Emitters tag spans with OTel attributes (constants in github.com/dagger/otel-go
plus engine/telemetryattrs). ProcessAttribute in dagql/dagui/spans.go maps
them onto SpanSnapshot fields. The load-bearing ones:
reveal(UIRevealAttr) →Reveal— bubble this span up toward the root (live).passthrough(UIPassthroughAttr) →Passthrough— hide this span; show its revealed spans as the top-level rows instead of its children.encapsulate/boundary(UIEncapsulateAttr/UIBoundaryAttr) →Encapsulate/Boundary— containment walls; stop reveal bubbling and reveal-independent surfacing from crossing them (used by test fixtures so a check/LLM run they drive on purpose doesn't surface to the outer trace).internal(UIInternalAttr) →Internal— machinery; hidden by default.rollup.logs/rollup.spans(UIRollUpLogsAttr/UIRollUpSpansAttr) →RollUpLogs/RollUpSpans— pull descendant logs/spans into this row.CheckNameAttr→CheckName,CheckPassedAttr→ the pass flag — makes a span a check.LLMRoleAttr→LLMRole(LLMToolAttr→LLMTool,UIMessageAttr,UIActorEmojiAttr) — makes a span an LLM message. Thedagger.io/llm.origin.*attrs (engine/telemetryattrs) →LLMOrigin*— a message's recorded provenance;styleLLMMessageViewrenders AGENT-origin messages sender-attributed and EVENT-origin ones as one-liners.
Where they're emitted:
- Checks:
core/modtree.go(CheckName+Reveal()+ rollup +CheckPassed). - LLM messages:
core/llm.go(emitMessageSpan/emitUserMessageSpan/emitAssistantMessageSpan:LLMRole+UIMessage/emoji; the system prompt is markedInternal) andcore/llm_display.go(displayPhasesstreams the live per-block spans with the same attrs). LLM messages do not setreveal— the live tree surfaces them viaDB.PromoteConversationToinstead (see Live promotion).
Reveal bubbling (live)
When a span has Reveal, it adds itself to each ancestor's RevealedSpans set,
walking up until it hits a Boundary, Encapsulate, or another Reveal
ancestor (dagql/dagui/spans.go). So a top-level check/turn reaches the root's
RevealedSpans; a nested one (e.g. a sub-agent's turn under a tool-call span,
itself revealed) stops at that parent and nests under it.
Reveal-independent SurfacedX (report)
The pattern lives in dagql/dagui/checks.go (SurfacedChecks / CheckNode) and
dagql/dagui/conversation.go (SurfacedConversation / MessageNode). Both:
- Walk every span; keep those with the marker (
CheckName != ""/LLMRole != ""). - Containment: a span surfaces only if its ancestor chain reaches
db.RootSpanwith noBoundary/Encapsulatein between. A chain severed before the root (an unreceived placeholder, or a reparenting seam the incremental fetch never loaded) can't be proven boundary-free, so it's treated as contained too — that's why fixture checks stay hidden even when theirBoundaryspan wasn't loaded. - Nest each node under its nearest surfaced ancestor of the same kind.
- Cache per
db.mutations(rebuilt only when span data changes; a render frame reads it many times).
They differ where the domain differs:
| checks | conversation | |
|---|---|---|
| dedup | by CheckName |
none — each span is a node |
| order | failed-first, then name | start time (a sequence) |
| extra | — | skips Internal (system prompt) |
HasChecks() / HasConversation() (in types.go / conversation.go) are the
cheap "did any surface" checks used by live promotion.
Render paths
frontendPretty.Render (dagql/idtui/frontend_pretty.go):
fe.finalRender→renderFinalReport, which callschecksReportthenconversationReport(dagql/idtui/checks_report.go,conversation_report.go). Each returnsnilwhen zoomed and falls back to the raw progress tree when nothing surfaces.- live →
renderProgressLines(the reveal-driven tree) + chrome. Collapsed check rows show an inline rollup viarenderInlineChecks/shouldRenderInlineChecks.
Live promotion
promoteChecksLocked / promoteConversationLocked (called from
recalculateViewLocked, frontend_pretty.go): when HasChecks() /
HasConversation() and the host isn't itself that kind, set
Passthrough = true on the host span and default the zoom to the primary (root)
span. Then the passthrough branch of DB.RowsView (dagql/dagui/types.go)
iterates the host's RevealedSpans instead of its children — so the revealed
checks/turns become the top-level rows and the connect/load noise disappears.
Checks reach RevealedSpans via reveal bubbling; the conversation does not set
reveal, so promoteConversationLocked first calls DB.PromoteConversationTo
to wire the reveal-independent SurfacedConversation tree into RevealedSpans
(top-level turns under the host, a sub-agent's turns under the tool call that
spawned them). This is the mechanism that replaced dagger script's old manual
SetPrimary zoom. zoomKind (dagql/idtui/frontend_trace_policy.go)
distinguishes zoomRoot/zoomCheck/zoomTest/zoomSpan for the zoomed views.
Recipe: add a new surfaced kind
- Emit: tag the spans with a marker (a dedicated attr, or key on an existing
SpanSnapshotfield the way checks key onCheckName) plustelemetry.Reveal()and rollup attrs. Confirm the field lands viaProcessAttribute. - dagui: add
DB.SurfacedX() []*XNode+HasX()mirroringconversation.go; addsurfacedX*cache fields todagql/dagui/db.go. - idtui: add
xReport/renderXSection/renderXNodemirroringconversation_report.go; wire it intorenderFinalReport, and suppress the progress-tree fallback when it surfaces. - Live: add
promoteXLockedmirroringpromoteConversationLocked; call it fromrecalculateViewLocked. - Test: unit the tree (order / nest / containment / cache) like
dagql/dagui/conversation_test.go; render + promote likedagql/idtui/conversation_report_test.go(ImportSnapshots→recalculateViewLocked→ call the report fn / assertRootSpan.Passthroughand top-level rows).
Gotchas
- Report and live can legitimately disagree. The report is reveal-independent (walks all spans by marker); the live tree needs reveal to actually reach the root. On a stored / incrementally-fetched trace the bubbling may be severed, so the report still surfaces things the live tree wouldn't — that asymmetry is the whole reason the report layer exists.
- Surfaced rows render their content from span logs, and the report must
pre-fetch them. A message/tool-call's text, arguments, and output live in the
span's logs (
emitMessageSpan/displayPhaseswrite them viaSpanStdio), not attributes.renderFinalReportrenders once, sorecalculateViewLockedeagerly fetches each surfaced span's logs first — in both report and interactive modes, since the final report also renders on interactive exit. Fetchdescendants=false(own logs only): a tool call's execution output is a nested exec span (LLMTool, noLLMRole) whose logs Cloud'sRollUpLogsdescendant roll-up returns empty for, so fetch that child's own logs directly (toolCallExecSpan). Do it before the report-only failure fetch, or a failed row'sdescendants=trueroll-up wins therequestLogsdedup and the args vanish. - Report sections return
nilwhen zoomed; the zoom views render themselves. - Section headings render
== X ==under an agent, bold for humans (reportHeadingLine/agentStyle, i.e.FrontendOpts.AgentStyle || RunningInAgent()). - Build filter for this repo:
go build ./... 2>&1 | grep -v my-module. - Handle-form IDs panic on
.Digest()/Field()— guard before reading call digests off a span (seestableIDDigestincore/mcp.go).