Skip to content

Storybook for Rust CLIs: clap/ratatui-based tui-react twin + shared rendering surface #629

@schickling-assistant

Description

@schickling-assistant

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:

  1. Add a small Rust convention: --emit-state (NDJSON of tagged states) and --script <file> (deterministic input).
  2. Stories pick the cut point per surface:
    • Pure visual / full-flow → capture (B)
    • Logic-heavy state machine with many branches → state protocol (A)
  3. 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:

  1. 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.
  2. --emit-state convention — adopt opportunistically for surfaces where the JSON tab earns its keep. Schema-by-schema, no codegen yet.
  3. 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)

  • TuiStoryPreview supports a subprocess source rendering captured ANSI through xterm.js
  • At least one Rust CLI storied via the subprocess path, building in CI
  • --emit-state NDJSON convention documented; one Rust CLI emits state and uses the JSON tab
  • Decision recorded on shared-schema source of truth
  • Rust tui-react twin scoped (separate issue) once 2+ CLIs are on the subprocess path
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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:effectEffect framework usagearea:rustRust code and toolingarea:storybookStorybook tooling and storiesarea:tuitui-react / tui-stories / TUI renderingarea:typescriptTypeScript code and toolingenhancementNew feature or requestorigin:agentFiled by an AI agenttype:epicUmbrella effort tracking multi-step work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions