Goal
Extend the Storybook setup we built around tui-react (state schema + reducer + React view, rendered into 7 output tabs by TuiStoryPreview and headlessly via tui-stories) so it also covers Rust CLIs — ideally with a clap/ratatui-based twin that mirrors the tui-react API, plus a shared rendering/testing surface that maximizes reuse and convergence between the two ecosystems.
Current TS contract (recap)
A storied TUI surface is a triple:
- State schema (Effect.Schema, tagged-union) — pure data, JSON-encodable
- Reducer (
(state, action) => state) — pure transitions
- View — React component reading
Atom<State>, rendering <Box>/<Text>
TuiStoryPreview renders the same triple into 7 output tabs (tty, alt-screen, ci, ci-plain, log, json, ndjson). tui-stories render does the same headlessly. Key split: JSON modes ignore the View; React modes ignore the encoder. They share state, not rendering.
The question for Rust is: where do we splice in?
Options considered
A. JSON state protocol (loose coupling)
Rust CLI grows --emit-state (NDJSON of tagged states). Story references the binary; tui-stories spawns it, reads NDJSON, feeds the timeline into TuiStoryPreview. View stays in React/TS.
- Pros: reuses 100% of Storybook infra; JSON/NDJSON tabs native; deterministic.
- Cons: View duplicated — Rust's real renderer (ratatui) and the React replica must stay in lockstep. Same drift problem as story-only mocks, cross-language.
B. ANSI capture (truth-from-output)
Rust CLI runs unchanged in a PTY; Storybook renders captured ANSI through xterm.js. Story declares a script + optional input timeline.
- Pros: zero duplication; what the user sees is the story; works for any binary; great for visual regressions.
- Cons: no JSON tab; harder to story individual logical states without fixtures; flakier (env, time, width); interaction tests drive via keystrokes, not state.
C. Shared schema, codegen both ways
One schema source (Effect.Schema → Rust emitter, or JSON Schema as source). Both sides derive types. Rust emits typed JSON; React View consumes same schema.
- Pros: type-safe across the boundary; JSON tab works naturally; acceptance criteria language-neutral.
- Cons: real upfront cost (codegen, build wiring, version skew); only pays off across multiple CLIs.
D. WASM-rendered Rust view
Compile a Rust renderer to WASM; React story calls it to produce ANSI.
- ratatui's backends don't target browser cleanly; you'd be rebuilding a backend; doesn't help headless
tui-stories (Bun/Node); ergonomic mismatch with story controls. Probably not worth it.
E. Hybrid: capture for visuals, state for assertions (recommended starting point)
Combine A + B at the story level rather than the framework level:
- Add a small Rust convention:
--emit-state (NDJSON of tagged states) and --script <file> (deterministic input).
- Stories pick the cut point per surface:
- Pure visual / full-flow → capture (B)
- Logic-heavy state machine with many branches → state protocol (A)
TuiStoryPreview gets one new source variant: { _tag: 'subprocess', cmd, args, mode: 'ansi' | 'state-ndjson' }. Existing tab infra unchanged.
Bigger idea: clap/ratatui-based tui-react twin
Rather than splice Rust in only at the rendering edge, build a Rust crate that mirrors the tui-react API surface:
- State + reducer in Rust (clap for CLI surface, serde for the state schema, an Effect.Schema-equivalent type registry so JSON shapes match exactly).
- View in Rust via ratatui, mapping the same primitives (
Box/Text/layout) the React side uses.
- Same output-mode taxonomy (
tty, alt-screen, ci, ci-plain, log, json, ndjson) implemented natively.
- Same story shape — a Rust crate exposing stories the same way TS packages do, discoverable by an extended
tui-stories list.
If both sides converge on the same state schema and the same output-mode contract, then Storybook becomes a multi-language rendering harness rather than a TS-only one.
Proposed direction
Stage in three steps, each independently useful:
- Subprocess ANSI source in
TuiStoryPreview — one new source variant; unlocks Storybook for any Rust (or other) CLI with near-zero per-CLI work. Pure win, no API commitment.
--emit-state convention — adopt opportunistically for surfaces where the JSON tab earns its keep. Schema-by-schema, no codegen yet.
- Rust
tui-react twin (tui-rs?) — once two or three Rust CLIs are storied, invest in the API-mirrored crate + shared schema codegen so state, reducer, and view all converge.
Open questions
- Concrete Rust CLI(s) to pilot against? The right cut depends on whether the CLI has structured internal state or is imperative-print.
- Schema source of truth: Effect.Schema (with a Rust emitter) vs. JSON Schema (with TS+Rust emitters)?
- For the ratatui view: how close can the layout primitives realistically get to the React side without becoming a leaky abstraction?
- Should
tui-stories itself stay TS, or eventually grow a Rust sibling that can run native stories without a Bun runtime?
Acceptance criteria (for the umbrella effort, not a single PR)
Posted on behalf of @schickling
| field |
value |
agent_name |
🌄 cl2-vale |
agent_session_id |
83d36ec6-3725-4ac9-b1fe-38b6dbd79d56 |
agent_tool |
Claude Code |
agent_tool_version |
2.1.118 (Claude Code) |
agent_runtime |
Claude Code 2.1.118 (Claude Code) |
agent_model |
claude-opus-4-7 |
worktree |
effect-utils/schickling/2026-04-25-rust-cli-storybook |
machine |
mbp2025 |
tooling_profile |
dotfiles@37cea1b |
Goal
Extend the Storybook setup we built around
tui-react(state schema + reducer + React view, rendered into 7 output tabs byTuiStoryPreviewand headlessly viatui-stories) so it also covers Rust CLIs — ideally with a clap/ratatui-based twin that mirrors thetui-reactAPI, plus a shared rendering/testing surface that maximizes reuse and convergence between the two ecosystems.Current TS contract (recap)
A storied TUI surface is a triple:
(state, action) => state) — pure transitionsAtom<State>, rendering<Box>/<Text>TuiStoryPreviewrenders the same triple into 7 output tabs (tty,alt-screen,ci,ci-plain,log,json,ndjson).tui-stories renderdoes the same headlessly. Key split: JSON modes ignore the View; React modes ignore the encoder. They share state, not rendering.The question for Rust is: where do we splice in?
Options considered
A. JSON state protocol (loose coupling)
Rust CLI grows
--emit-state(NDJSON of tagged states). Story references the binary;tui-storiesspawns it, reads NDJSON, feeds the timeline intoTuiStoryPreview. View stays in React/TS.B. ANSI capture (truth-from-output)
Rust CLI runs unchanged in a PTY; Storybook renders captured ANSI through xterm.js. Story declares a script + optional input timeline.
C. Shared schema, codegen both ways
One schema source (Effect.Schema → Rust emitter, or JSON Schema as source). Both sides derive types. Rust emits typed JSON; React View consumes same schema.
D. WASM-rendered Rust view
Compile a Rust renderer to WASM; React story calls it to produce ANSI.
tui-stories(Bun/Node); ergonomic mismatch with story controls. Probably not worth it.E. Hybrid: capture for visuals, state for assertions (recommended starting point)
Combine A + B at the story level rather than the framework level:
--emit-state(NDJSON of tagged states) and--script <file>(deterministic input).TuiStoryPreviewgets one new source variant:{ _tag: 'subprocess', cmd, args, mode: 'ansi' | 'state-ndjson' }. Existing tab infra unchanged.Bigger idea: clap/ratatui-based
tui-reacttwinRather than splice Rust in only at the rendering edge, build a Rust crate that mirrors the
tui-reactAPI surface:Box/Text/layout) the React side uses.tty,alt-screen,ci,ci-plain,log,json,ndjson) implemented natively.tui-stories list.If both sides converge on the same state schema and the same output-mode contract, then Storybook becomes a multi-language rendering harness rather than a TS-only one.
Proposed direction
Stage in three steps, each independently useful:
TuiStoryPreview— one new source variant; unlocks Storybook for any Rust (or other) CLI with near-zero per-CLI work. Pure win, no API commitment.--emit-stateconvention — adopt opportunistically for surfaces where the JSON tab earns its keep. Schema-by-schema, no codegen yet.tui-reacttwin (tui-rs?) — once two or three Rust CLIs are storied, invest in the API-mirrored crate + shared schema codegen so state, reducer, and view all converge.Open questions
tui-storiesitself stay TS, or eventually grow a Rust sibling that can run native stories without a Bun runtime?Acceptance criteria (for the umbrella effort, not a single PR)
TuiStoryPreviewsupports a subprocess source rendering captured ANSI through xterm.js--emit-stateNDJSON convention documented; one Rust CLI emits state and uses the JSON tabtui-reacttwin scoped (separate issue) once 2+ CLIs are on the subprocess pathPosted on behalf of @schickling
agent_nameagent_session_idagent_toolagent_tool_versionagent_runtimeagent_modelworktreemachinetooling_profile