Interpreter Output
The result of one code-interpreter run — source collapsed by default, stdout / stderr / rich results behind tabs derived from what actually arrived, the exception pinned above them, tail-capped output that sticks while it streams, and a one-shot Re-run.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/interpreter-output.jsonPrompt
Build a React + TypeScript + Tailwind "InterpreterOutput" component with zod and
lucide-react. No other runtime dependency: the tabs, the disclosure and the
clipboard state machine are hand-rolled so the card can drop into any stack.
Contract
- A zod schema (`interpreterExecutionSchema` in a sibling contract file) is the
single source of truth for ONE run: { id, code, language, state, stdout?,
stderr?, artifacts?, error?, exitCode?, startedAt?, durationMs?, queuePosition?,
cell?, runtime? }.
- The run state union is FOUR values: "queued" | "running" | "succeeded" |
"failed". `queued` is first class, not a flavour of loading — a cell waiting
for a free sandbox has a position in a queue and a Re-run that must stay inert.
- Four things stay separate and must never be merged: stdout (what it printed),
stderr (what it printed on the error stream — a SUCCESSFUL run writes
deprecation warnings and progress bars there), error (the exception that ended
the run: { type?, message, traceback? }), and artifacts (results that are not
text).
- `artifacts` is a discriminated union on `kind`:
· table { id, title?, columns: string[], rows: (string|number|boolean|null)[][],
totalRows? } — `rows` is what the sandbox SENT (a head(n)),
`totalRows` is what the frame really has. Both are needed to say
"the sandbox sent 10 of 12,480 rows" instead of implying it is small.
· image { id, title?, url, alt (required), width?, height? } — alt is required
because a plot with no alt text is a blank rectangle to a screen
reader; width/height reserve the box so the card doesn't jump.
· file { id, name, url?, sizeBytes?, mimeType? } — `url` is OPTIONAL because
an expired sandbox can still name its files but not serve them.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
card's own render state, independent of the run's state. `status="error"` means
the execution RECORD failed to load; the code raising is `state: "failed"`, and
the two read differently on screen.
- Props: execution: Execution | null; status; now? (injected clock);
defaultCodeOpen? (false); defaultTab?; onRerun?(executionId);
onRetry?; onDownloadArtifact?(fileArtifact); outputMaxLines? (200);
tableMaxRows? (8); tracebackExcerptLines? (12); formatDuration?; emptyState?;
errorMessage?; label? ("Code interpreter"); className plus the rest of the
element props, ref forwarded to the <article>.
- Every numeric prop is clamped (outputMaxLines >= 5, tableMaxRows >= 1,
tracebackExcerptLines >= 3) so a 0 or a NaN can never render an empty panel.
Behavior
- TABS ARE DERIVED, never declared. A channel gets a tab only if it has content:
no stdout, no stdout tab. If nothing produced anything the tab bar is replaced
by one sentence that depends on the state ("Waiting for a sandbox to pick this
up." / "Running — nothing has been printed yet." / "This run produced no
output."). The selected tab is stored as the reader's PREFERENCE and resolved
against the channels that currently exist: while a run streams, stderr may not
exist yet, and selecting a tab that will only appear later would render an empty
panel that jumps under the reader. When the preference is unavailable the
fallback is stderr on a failure, else stdout, else the result panel.
- Tabs implement the ARIA tabs pattern by hand: role="tablist" / role="tab" with
aria-selected, roving tabindex (one tab stop for the whole bar), Arrow keys with
wraparound plus Home/End, automatic activation with focus following selection,
and aria-controls set only on the selected tab so it never points at an id that
is not in the document. Only the selected panel is MOUNTED — a hidden panel
keeps its copy buttons and scroll region in the tab order, which is an invisible
keyboard trap.
- THE EXCEPTION IS NOT A TAB. When the run failed, the error type, message and
traceback render above the tab bar, always expanded. A failure hidden behind a
tab is a failure that gets missed, and stderr — which is a different thing — is
where readers would look for it and not find it.
- The traceback is EXCERPTED FROM BOTH ENDS: the first line plus the last
(tracebackExcerptLines - 1) lines, with an explicit "… N frame lines hidden …"
marker between them and a toggle that reveals everything. A blind slice(0, n)
would cut off exactly the last line, which is the one that names the exception.
Copy always writes the full traceback, never the excerpt.
- OUTPUT IS CAPPED FROM THE TAIL, not the head. A tool call's arguments are read
from the top; a program's output is read from the bottom, where the result and
the last thing that happened live. The pane keeps the last `outputMaxLines`
lines above the exact figure ("Showing the last 200 of 4,002 lines") with a
"Show all lines" toggle. Copy always writes the whole channel.
- Text channels are CLEANED before rendering: ANSI CSI sequences are stripped
(a themed card cannot honour "bright red on blue" without hard-coding colours,
and leaving the bytes in prints [0;32m on every line), everything before the
last carriage return of a line is dropped (which is exactly what a terminal
shows, and it collapses a 60-frame progress bar into its final frame), and
trailing blank lines are trimmed. Copy writes the cleaned text — what you see is
what you paste into the bug report.
- WHILE THE RUN IS LIVE the output pane sticks to the tail, and lets go the
moment the reader scrolls up; nothing ever yanks them back down. A settled run
is not scrolled at all, so it opens at the top. The scroll listener is bound by
the callback ref (with a cleanup function), not by an effect keyed on a plain
ref: the pane is rebuilt whenever the channel changes, and an effect would keep
listening to a detached node.
- RE-RUN IS ONE-SHOT. The handler fires at most once per settled run no matter how
fast the button is clicked, because the lock lives in a ref read and written
synchronously inside the click handler: several clicks dispatched in one task
all observe the same stale state, so a state-only guard would queue the same
cell four times on someone's metered sandbox. The lock is keyed to a generation
that ticks on every id or state change, so it expires by itself instead of
leaving a dead button behind. Re-run is inert (aria-disabled, never the native
`disabled`, which would blur the button mid-action and drop it out of the tab
order) while the run is queued or running.
- The card owns NO CLOCK. A settled run shows `durationMs`; a running one shows
`now - startedAt`, recomputed from the start instant every render and never
accumulated, so a re-render, a paused tab or a replayed stream cannot drift.
Omit `now` and a running cell simply shows no number.
- The code panel is COLLAPSED by default — the output is the answer, the source is
the footnote — and the collapsed header previews the first non-blank line. It
renders line numbers in one grid (one grid per line would misalign the gutter at
line 10) with the numbers unselectable, and it is skipped entirely when `code`
is blank.
- Table artifacts cap rows at `tableMaxRows` with a "Show all rows" toggle and a
note that distinguishes the two truncations — "Showing 8 of 10 rows · the
sandbox sent 10 of 12,480". Columns whose non-null values are all numbers are
right-aligned and tabular; null renders as an em dash, never as a blank cell;
ragged rows are tolerated.
- Image artifacts fall back to a NAMED placeholder when the URL fails, and the
failure is keyed by URL so a new plot clears it without a reset effect. An <img>
can also fail before hydration attaches onError, so the ref re-checks
`complete && naturalWidth === 0` once on mount.
- File chips are links only when they have a url (with the download attribute and
the file's name); without one they render as a plain "expired" label. Never
fabricate a href to keep the affordance.
- Identity changes reset the card (adjust state during render, no effect): a new
execution id restores the default disclosure and the default channel, so a
recycled card can never show the previous run's tab. Panes are keyed by
`${id}:${channel}`, which resets "Show all" by itself.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-muted/40 for output and code surfaces, border-primary for the selected tab,
text-destructive with bg-destructive/5 and border-destructive/40 for the
exception block, ring for every focus-visible outline. No hard-coded colours
anywhere — which is also why ANSI colour is stripped rather than translated.
- Four run states are four SHAPES — hourglass / spinner / check / alert — so they
survive a monochrome theme and a colour-blind reader. The spinner stops under
prefers-reduced-motion and still reads as "in progress"; every transition and
the chevron rotation are motion-reduce-guarded.
- The root is an <article aria-label="Code interpreter: python"> holding ONE
persistent sr-only role="status" line that spells out the state ("python cell
failed — KeyError: 'unit_price'"). It is mounted for the whole ready branch, so
a state CHANGE is announced from an element the screen reader is already
watching.
- The scrollable output pane is focusable (tabIndex 0) and named, or the output is
unreadable without a mouse. Long identifiers use `wrap-anywhere` plus `min-w-0`,
not `break-words`: only the former lowers the min-content width, which is what
stops a 90-character path from making the card wider than the message it sits
in. Code keeps `whitespace-pre` and scrolls horizontally, because indentation is
syntax in Python.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Budgets: `outputMaxLines` / `tableMaxRows` / `tracebackExcerptLines` decide how
much is drawn before the explicit reveal control. Raise them in a debugging
surface, lower them in a chat transcript — but never make any of them silent;
the withheld count is part of the reading.
- Disclosure: `defaultCodeOpen` pins the source open (a notebook-like surface) or
closed (a chat transcript). `defaultTab` pins the channel — "result" for an
analysis product where the frame is the answer.
- Actions: omit `onRerun` and the button disappears entirely rather than becoming
decorative; `onDownloadArtifact` is analytics only, the browser still performs
the download; `errorMessage` + `onRetry` own the envelope failure and
`emptyState` replaces the empty body.
- Units: `formatDuration` swaps the wording (seconds, CPU time, credits). Pass a
`now` you tick once per second at the top of your app — one interval for the
whole transcript instead of one per card.
- Chrome: `label` names the region, `cell` is the notebook-style label
("In [7]"), `runtime` is your own meta line ("Python 3.12 · pandas 2.2").
- Artifacts: add a kind by extending the discriminated union and giving it a
renderer in the result panel — consecutive `file` artifacts intentionally
collapse into one wrapping chip row, so a new "link" kind should follow the same
grouping rule to stay compact.Concepts
- Tabs derived from what arrived — a channel earns a tab by having content, so a run that only printed shows one tab and a run that only plotted shows none of stdout. The reader's choice is kept as a preference and re-resolved every render: a channel that has not streamed in yet cannot be selected, and one that disappears cannot leave an empty panel behind.
- stderr is a channel, not a verdict — deprecation warnings and progress bars land there on perfectly successful runs. Colouring the card red because stderr is non-empty trains people to ignore red. The thing that ended the run is
error, and only that is rendered as a failure. - The exception outranks the tabs — it renders above the bar, always expanded, because a failure one click away from being seen is a failure that gets missed. Everything else on the card can be summarised; this is why the card is open.
- Excerpt from both ends — a traceback is read from the top ("this is a traceback") and from the bottom (the frame that raised and the exception line). Keeping the head and the tail with a stated hidden count is the only excerpt that survives;
slice(0, n)deletes the answer. - Cap the head, keep the tail — program output is read from the bottom, so the pane holds the last N lines rather than the first, states exactly how many it withheld, and always copies the whole thing. A silent cap makes you debug the wrong thing.
- Follow the tail, then let go — while the run is live the pane stays pinned to the bottom; the first upward scroll detaches it permanently for that channel and nothing ever drags the reader back down. A settled run is never auto-scrolled at all.
- One-shot Re-run lock — the guard is a ref read and written synchronously inside the click handler, because several clicks dispatched in one task all observe the same stale state. The lock is keyed to a generation that ticks on every state change, so it expires by itself as soon as the consumer moves the run forward.
- No clock inside the component — a settled run carries its
durationMs, a live one derivesnow − startedAtfrom an injected clock. Elapsed time is recomputed from the start instant, never incremented, so a pause, a re-render or a replay can't drift, and server and browser render the same string.
File Change List
The files an agent touched — middle-truncated paths, A/M/D/R marks, churn bars scaled across the whole run, one-shot Accept/Revert per file plus a bulk bar, and a slot for the inline diff.
Human Handoff
A bot-to-human escalation card that walks one ticket through request, queue, connecting, connected and an offline leave-your-address fallback.