Skip to content

feat(playground): show baml.io output in the run view - #4273

Merged
hellovai merged 2 commits into
canaryfrom
hellovai/playground-io-output
Jul 29, 2026
Merged

hellovai merged 2 commits into
canaryfrom
hellovai/playground-io-output

Conversation

@hellovai

@hellovai hellovai commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

The bug, in one line

Any BAML program with a print in it worked under baml run and crashed in the playground.

function main() -> string {
    baml.io.println("hello");   // baml run: prints. playground: panics the run.
    "done"
}

baml.io.print / println / eprint / eprintln returned Unsupported in the playground/LSP host (playground_io.rs) and the wasm/webview host (wasm_io.rs). Those builtins can't throw a user value, so the error came out as a panic and took the run down.

Real programs worked around it by escaping BAML entirely:

// bamlcode, before this PR
// "baml captures normal stdout, so we bypass it via /dev/tty"
function say(text: string) -> void {
    let opts = baml.sys.ProcessOptions { stdin: text, .. };
    baml.sys.shell("cat > /dev/tty 2>/dev/null", opts);
}

/dev/tty targets the host process's controlling terminal, so output landed in whatever terminal launched baml playground and was invisible to the UI.

What it does now

Stream writes become run events and render inline in the run strip, between the request rows and RESULT:

agent.main() {}
  INPUT   {}
  ...     POST https://api.anthropic.com/v1/messages
  OUTPUT  ┌────────────────────────────────────────────┐
          │   bamlcode · a tiny Claude Code, in BAML    │
          └────────────────────────────────────────────┘
            Ask me to read, edit, or build code in this dir.
            type a task       run it now
          you ›
  RESULT  "session ended"

Screenshot: drag the run-view screenshot in here.

Process stdout is never touched. Under baml lsp it carries the JSON-RPC frames, so one stray byte desyncs the editor's language client. Under baml playground the user is looking at a browser tab, not a terminal. Both hosts route to the run instead.

Why a terminal emulator and not escape-to-HTML

BAML programs emit real terminal control, not just colors. Cursor movement, \r progress bars, and \x1b[2J screen clears need a screen buffer to interpret. anser / ansi-to-react (already used in app-website) only translate SGR color codes, so those sequences would render as garbage.

xterm.js is lazily imported, so it stays out of the bundle until a run actually prints (dist/assets/xterm-*.js is its own chunk).

The corollary is that the projection must stay dumb. Chunks are passed through verbatim and in order, never split into lines or regrouped per stream, because a single escape sequence can straddle two print calls:

print("\x1b[3")  →  chunk 1
print("1mred")   →  chunk 2   // "\x1b[31m" only exists across the boundary

xterm.write() buffers partial sequences across calls, so this resolves correctly. There's a test for exactly this case.

Data flow

flowchart LR
    A["baml.io.println"] --> B["PlaygroundIo::write_output<br/>(or WasmIo)"]
    B --> C["run_store.ingest_output<br/>PayloadKind::Output"]
    C --> D["broadcast_run_patch"]
    D --> E["runToOutputChunks"]
    E --> F["RunOutputTerminal<br/>xterm.js"]
    B -.->|never| G["process stdout<br/>= JSON-RPC under baml lsp"]
Loading

Env vars: stop blocking runs on optional keys

Second fix, same area. The env dialog popped on every run and blocked until answered, including for keys with a ?? fallback that the program didn't need.

Resolution is now: user overrides → process env → prompt only if the project declares the key → unset.

"Declared" means it came from all_env_var_names(), i.e. env.FOO in a client. A key reached through baml.env.get("...") resolves to unset immediately and silently. The silence is load-bearing: EnvVarRequest is what makes the UI badge a key as required and open the dialog, so there's no notify-only channel. Sending one "just so you know" reopens the modal every run.

Key How it's reached Before After
ANTHROPIC_API_KEY env.X in a client prompt + block prompt + block
BAMLCODE_MEMORY_DIR baml.env.get("...") prompt + block, every run unset, no prompt
NO_COLOR baml.env.get("...") prompt + block, every run unset, no prompt

Declared keys are replaced rather than accumulated on each project compile, so a key you stop referencing stops blocking.

Bounded by construction

record.run.payloads is unbounded per run, so a tight print loop could grow the run store without limit. Output is capped at 1 MiB per run with exactly one truncation notice, then further writes are dropped. Tested.

A write that can't be attributed to a live run is dropped rather than failed: panicking a program over an unroutable debug print costs more than the lost line.

Test plan

  • cargo test -p bex_events — 154 pass
  • cargo test -p baml_lsp_server — 68 pass
  • pnpm --filter @b/pkg-playground test — 128 pass
  • cargo clippy -p bex_events -p baml_lsp_server --all-targets — clean
  • cargo clippy -p bridge_wasm --target wasm32-unknown-unknown — clean
  • tsc --noEmit — clean
  • Manually run against a real project (bamlcode), output renders with ANSI intact

New tests:

Test Guards
print_writes_are_recorded_verbatim_on_the_run escape sequence split across two calls survives
print_from_an_unattached_call_is_dropped_not_failed orphan write doesn't kill the run
ingest_output_caps_a_runaway_print_loop one notice, bounded retention
ingest_output_without_an_attached_host_call_records_nothing no phantom payloads
undeclared_key_resolves_to_unset_without_waiting_or_prompting returns Ready, not Async; emits no EnvVarRequest
declared_keys_are_replaced_not_accumulated stale keys stop blocking
keeps baml.io output chunks verbatim, in order (TS) no reshaping in the projection

Not in this PR

  • History replay. Live output works, but reopening a past run won't show it. Persisted history has its own on-disk record types for value captures and logs (bex_events/src/history); output needs a new one.
  • baml.sys.shell inheriting the host's stdin. With no stdin option, run_process leaves the child's stdin inherited (io_impls.rs:1128). Under baml lsp that's the JSON-RPC stream, so a shell command that reads stdin steals protocol bytes. Same shape as the stdout problem, opposite direction. Worth a follow-up.
  • Trace/flame bar min-width. Math.max(1.5, ...) in run-store-projections.ts floors bar width at 1.5% of the whole run, so in a 100s trace everything under ~1.5s renders identically. The floor belongs in CSS as a pixel minimum, not in data space. Separate change.

Callers must update

Programs using the /dev/tty workaround should switch to baml.io.print (note: print, not println, if the strings already carry their own \n). The one thing that doesn't port cleanly is EOF: shell signalled it via exit code, and baml.io.input has no EOF channel.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VWbXTcLbNVtm3Xmd5aWajK

Summary by CodeRabbit

  • New Features

    • Added live terminal output for baml.io print, println, eprint, and eprintln, rendered in a streaming terminal view.
    • Run details now include an “Output” section with streamed stdout/stderr, preserving ANSI escape sequences.
    • Added output-chunk capture with byte-budget limits to keep runs responsive (including controlled truncation behavior).
  • Bug Fixes

    • Undeclared environment variables now resolve silently without unnecessary prompts/blocking.
    • Output from unattached or completed runs is safely ignored/dropped.

`baml.io.print` / `println` / `eprint` / `eprintln` returned Unsupported
in both the playground/LSP host and the wasm/webview host. Because those
builtins cannot throw a user value, the error surfaced as a panic, so any
BAML program containing a print worked under `baml run` and died in the
playground. Programs worked around it by shelling out to `/dev/tty`,
which escapes BAML entirely and lands in whatever terminal launched the
server.

Route them to the run instead. Writes become `PayloadKind::Output` events
that stream over the existing run-patch channel and render inline in the
run strip, next to INPUT and RESULT. Process stdout is never touched: under
`baml lsp` it carries the JSON-RPC frames, and under `baml playground` the
user is watching a browser tab.

Rendered through xterm.js rather than an escape-to-HTML converter, because
programs emit real terminal control: cursor movement, `\r` progress bars and
screen clears need a screen buffer to interpret. Chunks are passed through
verbatim and in order, so an escape sequence split across two `print` calls
still resolves. Retained output is capped per run so a runaway print loop
cannot grow the run store without bound.

Also stop the env dialog from blocking runs on optional keys. Env resolution
now prompts only for keys the project declares via `env.FOO`; a key reached
through `baml.env.get("...")` resolves to unset immediately and silently,
since `EnvVarRequest` is what badges a key required and opens the dialog.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWbXTcLbNVtm3Xmd5aWajK
@vercel

vercel Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview, Comment Jul 29, 2026 5:44am
promptfiddle Ready Ready Preview, Comment Jul 29, 2026 5:44am
promptfiddle2 Ready Ready Preview, Comment Jul 29, 2026 5:44am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e54c18d5-4ccd-45ce-8e77-42abf94c83c8

📥 Commits

Reviewing files that changed from the base of the PR and between 84e9b83 and 1e456c1.

📒 Files selected for processing (3)
  • baml_language/crates/baml_lsp_server/src/playground_server.rs
  • typescript2/pkg-playground/src/ExecutionPanel.tsx
  • typescript2/pkg-playground/src/RunOutputTerminal.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • baml_language/crates/baml_lsp_server/src/playground_server.rs
  • typescript2/pkg-playground/src/RunOutputTerminal.tsx

📝 Walkthrough

Walkthrough

The PR limits playground environment prompts to declared keys and adds streamed baml.io output. Output is stored as bounded run payloads, serialized through worker protocols, projected into run history, and rendered in an xterm.js terminal.

Changes

Environment resolution

Layer / File(s) Summary
Declared environment-key resolution
baml_language/crates/baml_lsp_server/src/playground_env.rs, baml_language/crates/baml_lsp_server/src/playground_server.rs
Declared keys are refreshed per WebSocket session and source update; undeclared keys resolve immediately to None, while declared missing keys retain the prompt flow. Tests cover both behaviors and replacement semantics.

Streaming BAML IO output

Layer / File(s) Summary
Output payload model and run-store ingestion
baml_language/crates/bex_events/src/run.rs, baml_language/crates/bex_events/src/run_wire.rs
Run payloads represent stdout/stderr output, enforce a per-run byte cap with one truncation notice, serialize output events, and test attribution and truncation.
Host output capture and broadcast
baml_language/crates/baml_lsp_server/src/playground_io.rs, baml_language/crates/bridge_wasm/src/wasm_io.rs
Print-style IO calls record output patches, preserve stream and newline behavior, notify clients, and drop empty or unattached writes.
Output wire and projection contract
typescript2/pkg-playground/src/worker-protocol.ts, typescript2/pkg-playground/src/run-store-projections.ts, typescript2/pkg-playground/src/run-store-projections.test.ts
Worker payloads and display runs expose ordered output chunks while preserving raw text and interleaved streams.
Run output terminal rendering
typescript2/pkg-playground/src/RunOutputTerminal.tsx, typescript2/pkg-playground/src/ExecutionPanel.tsx, typescript2/pkg-playground/package.json
Run details render output through a lazy xterm.js terminal with buffering, resizing, run resets, and fit behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BamlIo
  participant InMemoryRunStore
  participant PlaygroundClient
  participant RunOutputTerminal
  BamlIo->>InMemoryRunStore: Write stdout/stderr output payload
  InMemoryRunStore-->>PlaygroundClient: Broadcast RunPatch
  PlaygroundClient->>RunOutputTerminal: Project ordered output chunks
  RunOutputTerminal-->>RunOutputTerminal: Render and fit terminal output
Loading

Possibly related PRs

  • BoundaryML/baml#3949: Both changes update playground WebSocket environment-key handling and filtering.

Suggested reviewers: sxlijin

Poem

I’m a rabbit with output to stream,
Bouncing stdout through every beam.
Env keys now know when to ask,
Terminals display each task.
Hop, hop—no stray prompts remain!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately captures the main user-facing change: showing streamed baml.io output in the playground run view.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hellovai/playground-io-output

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel
vercel Bot temporarily deployed to Preview – beps July 29, 2026 05:03 Inactive
@github-actions

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@hellovai
hellovai marked this pull request as ready for review July 29, 2026 05:03
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 29, 2026 05:10 Inactive
@github-actions

github-actions Bot commented Jul 29, 2026 •

Copy link
Copy Markdown

Binary size checks passed

✅ 7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 27.4 MB 11.7 MB file 27.3 MB +81.3 KB (+0.3%) OK
✅ packed-program Linux 🔒 17.8 MB 7.3 MB file 17.7 MB +30.6 KB (+0.2%) OK
✅ baml-cli macOS 🔒 21.3 MB 10.2 MB file 21.2 MB +66.2 KB (+0.3%) OK
✅ packed-program macOS 🔒 13.9 MB 6.4 MB file 13.9 MB +32 B (+0.0%) OK
✅ baml-cli Windows 🔒 22.9 MB 10.4 MB file 22.9 MB +61.4 KB (+0.3%) OK
✅ packed-program Windows 🔒 14.8 MB 6.5 MB file 14.8 MB +14.7 KB (+0.1%) OK
✅ bridge_wasm WASM 16.6 MB 🔒 4.5 MB gzip 4.5 MB +12.1 KB (+0.3%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

@hellovai
hellovai added this pull request to the merge queue Jul 29, 2026
@hellovai
hellovai removed this pull request from the merge queue due to a manual request Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
typescript2/pkg-playground/src/ExecutionPanel.tsx (1)

3597-3612: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test runs don't get the same Output section.

The test-run branch (around Lines 3087-3143) renders input prompts, error, and result but no RunOutputTerminal, so print from a test is silently dropped from the UI even though the payloads exist. Intentional for this PR?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@typescript2/pkg-playground/src/ExecutionPanel.tsx` around lines 3597 - 3612,
Add the same output section used by the regular run branch to the test-run
rendering path around the test-run result UI, using the test run’s outputChunks
and id with RunOutputTerminal. Render it when outputChunks is non-empty while
preserving the existing test input, error, and result sections.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@baml_language/crates/baml_lsp_server/src/playground_server.rs`:
- Around line 1612-1614: Update the successful playground_update_source_file
path to refresh WsState.env_state by calling set_declared_keys with the current
state.bex.all_env_var_names(). Keep the existing initialization update
unchanged, ensuring removed environment keys stop prompting and newly referenced
keys are recognized without reconnecting.

In `@typescript2/pkg-playground/src/RunOutputTerminal.tsx`:
- Around line 91-96: Update the cleanup function that disposes the terminal to
reset writtenRef.current to its initial cursor value and clear the pending write
buffer, ensuring a newly mounted terminal replays all chunks from the beginning.
- Around line 134-141: Update the output-writing flow around Term.write so
buffer.active.length is measured only after the write completion callback runs,
then defer the measurement by a requestAnimationFrame UI tick before updating
rows and calling fit(). Preserve the existing MIN_ROWS/MAX_ROWS clamping and
no-op state update behavior.

---

Nitpick comments:
In `@typescript2/pkg-playground/src/ExecutionPanel.tsx`:
- Around line 3597-3612: Add the same output section used by the regular run
branch to the test-run rendering path around the test-run result UI, using the
test run’s outputChunks and id with RunOutputTerminal. Render it when
outputChunks is non-empty while preserving the existing test input, error, and
result sections.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 09b9b146-182f-4f63-8acb-67cdb431a848

📥 Commits

Reviewing files that changed from the base of the PR and between 95d9d8a and 84e9b83.

⛔ Files ignored due to path filters (1)
  • typescript2/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (12)
  • baml_language/crates/baml_lsp_server/src/playground_env.rs
  • baml_language/crates/baml_lsp_server/src/playground_io.rs
  • baml_language/crates/baml_lsp_server/src/playground_server.rs
  • baml_language/crates/bex_events/src/run.rs
  • baml_language/crates/bex_events/src/run_wire.rs
  • baml_language/crates/bridge_wasm/src/wasm_io.rs
  • typescript2/pkg-playground/package.json
  • typescript2/pkg-playground/src/ExecutionPanel.tsx
  • typescript2/pkg-playground/src/RunOutputTerminal.tsx
  • typescript2/pkg-playground/src/run-store-projections.test.ts
  • typescript2/pkg-playground/src/run-store-projections.ts
  • typescript2/pkg-playground/src/worker-protocol.ts

- Reset the write cursor when the terminal is disposed. Refs outlive the
  emulator, so a remount (StrictMode's dev double-invoke, or any parent
  remount) rebuilt an empty screen while the chunk loop still started at N,
  losing every chunk written before the remount.

- Measure the buffer in `write`'s completion callback. `write` is async, so
  reading `buffer.active.length` synchronously sized the box against the
  previous frame and lagged one update behind.

- Render Output for test runs too. The payloads were already projected onto
  the display run; only the function-run branch rendered them.

- Refresh declared env keys when playground source is updated, not just on
  WebSocket connect. An edit that adds or removes an `env.FOO` reference
  otherwise left a removed key prompting and a new one silently unset until
  the session reconnected.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01VWbXTcLbNVtm3Xmd5aWajK
@vercel
vercel Bot temporarily deployed to Preview – beps July 29, 2026 05:22 Inactive
@hellovai
hellovai enabled auto-merge July 29, 2026 05:27
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 July 29, 2026 05:30 Inactive
@hellovai
hellovai added this pull request to the merge queue Jul 29, 2026
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle July 29, 2026 05:44 Inactive
Merged via the queue into canary with commit fd993c7 Jul 29, 2026
85 of 86 checks passed
@hellovai
hellovai deleted the hellovai/playground-io-output branch July 29, 2026 06:15

This branch was previously deployed

3 inactive deployments
Preview – promptfiddle — 1e456c12 Deployed Jul 29, 2026 by vercel[bot]
Preview – promptfiddle2 — 1e456c12 Deployed Jul 29, 2026 by vercel[bot]
Preview – beps — 1e456c12 Deployed Jul 29, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant