Skip to content

RFC: rewrite the TUI renderer in Rust, migrate the agent core in stages #868

@esengine

Description

@esengine

Summary

Rewrite the TUI renderer in Rust, and migrate the agent core to Rust in staged follow-ups. The Node/TypeScript surface shrinks to a thin launcher + slash-command authoring layer; everything that renders, parses streams, drives the event log, or talks to providers moves to a native binary.

This is an architectural bet, not a perf chase. The justification is robustness on the long tail of terminal hosts and a smaller, sharper internal surface — not "Rust is faster."

Motivation

We have shipped credible workarounds for every individual rendering bug, but the rate of new bugs in the same family is not slowing. Recent representative cases:

The pattern: React + Yoga + Ink is a layout engine designed for the DOM, retargeted at a cell grid. The mismatches it papers over (character width vs cells, ZWJ clusters, alt-screen vs main buffer, synchronized output, mouse reports, ConPTY input quirks) are exactly where our bugs live. Every fix is a workaround against a layer that does not model the terminal.

Secondary motivations:

  • Startup cost. ~250–400ms of Node + npm graph load on cold start. We have not chased it because shaving npm deps is whack-a-mole; the realistic win is removing the Node dependency from the hot path entirely.
  • Install pain on Windows / Termux. native-module rebuild prompts, npm registry hiccups, postinstall scripts. A prebuilt single binary replaces all of it.
  • Test surface. Ink renderers are hard to assert against without spinning up the whole app. A Rust render layer fed by a serialized scene graph is trivially snapshot-testable.

This does not change our pricing pitch. Cheap remains load-bearing — this RFC is an implementation refactor with no impact on tokens or API spend. If anything, a faster local layer makes the cache-first loop cheaper to defend.

Proposal

Two-binary architecture in the steady state:

+---------------------------+         +------------------------------+
|  reasonix (Rust binary)   |<--JSON->|  reasonix-host (Node, opt.)  |
|  - terminal I/O           |  RPC    |  - slash command plugins      |
|  - render (ratatui)       |  stdio  |  - user .ts customization     |
|  - input + key chords     |         |  - npm-distributed prompts    |
|  - event log              |         +------------------------------+
|  - streaming parser       |
|  - tool dispatcher        |
|  - MCP client             |
|  - DeepSeek HTTP client   |
+---------------------------+

The Rust binary is the primary entrypoint. The Node host is optional — present only when a user has authored TS slash commands or skills. For the majority of users who never write a plugin, there is no Node on the system.

What stays in JS / TS:

  • The slash-command authoring API (the surface we promise to plugin authors).
  • User-written skills.
  • The website / docs / dashboard.

What moves to Rust:

  • All rendering. Frame composition, line diffing, alt-screen handling, mouse tracking, key chord recognition.
  • All terminal I/O. Raw mode, signal handling, resize, paste bracketing, ConPTY input.
  • The agent loop, event log, and streaming parser. These are state machines today; they translate cleanly.
  • Tool dispatch + the MCP transport. MCP is JSON-RPC; the spec is language-agnostic.
  • The DeepSeek client. Including cache-aware request shaping (the core of our positioning).

Staging

Each stage is independently shippable and reversible. If any stage fails its acceptance check we stop and reassess — this is the kill switch.

Stage 0 — landing pad (no Rust yet)

Finish #565 (App.tsx split) and define a serializable scene-graph type. The render tree must emit a JSON-shaped description of "what should be on screen" that does not depend on React lifecycle. This is the contract the Rust renderer will consume.

Acceptance: App.tsx ≤ 500 lines; the scene-graph type lives in src/cli/ui/scene/ and is produced from the current Ink tree without behavior change.

Stage 1 — Rust renderer behind a feature flag

A separate Rust process consumes scene-graph frames over stdio and renders with ratatui. JS still owns state and produces frames. Enabled via REASONIX_RENDERER=rust; default stays Ink.

Acceptance: the card-stream view renders correctly in Rust on Windows Terminal, iTerm2, VSCode, and at least one ssh-from-Linux configuration. No new visual regressions vs Ink on any of those. Mouse-wheel handling fixes #867 as a side effect.

Stage 2 — Rust input layer

Move composer input, key chords, paste bracketing, and mouse to Rust. JS receives semantic input events ("submit", "history-prev", "approve") instead of raw bytes. This is where the actual UX wins land — every input-layer bug we have today is in the JS/Ink boundary, not in the state machine.

Acceptance: every existing key chord works; submit / abort / approval flows match current behavior; no Home / End / Ctrl+U regressions (these get fixed for free, addressing the lamyc report from #20).

Stage 3 — flip the default, deprecate the Ink path

REASONIX_RENDERER=ink becomes opt-in. We keep it around for one minor release to catch host-specific regressions, then delete it. The Ink fork (#663) is retired.

Acceptance: at least two weeks on main with the Rust renderer as default and no severity-1 rendering regressions reported.

Stage 4 — migrate the agent core

Event log, streaming parser, tool dispatcher, MCP client, DeepSeek client move to Rust. The JS host becomes optional. At this point npx reasonix invokes a tiny launcher that downloads + execs the right native binary.

Acceptance: cold start under 50ms on Linux / macOS / Windows; no behavioral regressions in the test suite; the cache-hit rate on our reference workloads (see the real-world cache case study in benchmarks/real-world-cache/) is unchanged or better.

Stage 5 — single binary, optional plugin host

The Rust binary is self-contained. If a user has TS plugins, the binary spawns a Node host child process and talks to it over stdio. Most users never trigger this path.

Acceptance: distribution via curl-installer (Rust-native), npm wrapper (npx reasonix shells out to the binary), and a single download on the releases page. All three resolve to the same binary.

Distribution

npx reasonix keeps working. The npm package becomes a launcher: on first run it downloads the right prebuilt binary for the platform + arch from our releases, caches it, and execs it. No Node is required at runtime once the binary is present.

We also ship:

  • A shell installer (curl -fsSL reasonix.dev/install | sh), Rust-native, no Node assumption.
  • Per-platform archives on GitHub Releases.
  • Optional cargo install reasonix for the small population that wants source builds.

Prebuilt targets at GA: x86_64-pc-windows-msvc, x86_64-apple-darwin, aarch64-apple-darwin, x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, aarch64-linux-android (for Termux).

Non-goals

  • No feature additions. The migration is mechanical; user-visible behavior is preserved at each stage.
  • No protocol changes. DeepSeek API client behavior, MCP transport semantics, and the event-log schema are stable across the cut.
  • No language for plugin authors changing. TS plugins stay TS. We do not require Rust to extend Reasonix.
  • No SDK / library extraction. We are not building a "Reasonix-as-a-library" surface as part of this. That is a separate conversation.

Risks

  • Build complexity for contributors. A Rust toolchain becomes table stakes for core contributors. Mitigation: CI builds the matrix; contributors touching only plugins / skills / docs never need Rust. We document the split clearly.
  • Half-migration drag. The worst outcome is being stuck in Stage 2 forever, maintaining both renderers. Mitigation: Stage 3's deprecation is on a calendar deadline, not a feel-good "when it's ready" gate.
  • Iteration speed during dev. Rust compile times are not Node compile times. Mitigation: state-owned-by-JS through Stage 2 means the inner dev loop on UX work is still Ink-fast until the cut.
  • Loss of Ink ecosystem features. Some Ink-specific niceties (e.g., the existing <Static> optimization, dev hot-reload) have no direct ratatui analog. Mitigation: most of these features exist to compensate for React-on-terminal — they become unnecessary, not missed.
  • Provider ecosystem reach. Some users contribute via JS-only PRs to the agent loop today. After Stage 4, those PRs move to Rust. We will lose some contributor velocity here; the bet is that the bug-rate reduction frees up more reviewer time than the contributor barrier costs us.

Alternatives considered

Keep Ink + fix bugs one by one. The status quo. Each fix is real but the queue is not shrinking. We have already forked Ink (#663) and patched our way around Yoga; the next workaround tier is hand-rolling cell-level diffing inside the Ink fork, which is the same engineering cost as moving off Ink without any of the upside.

Move to a lighter JS terminal lib (blessed, neo-blessed, hand-rolled on ansi-escapes). Same fundamental problem at a smaller scale. We would still be paying for Node startup, still wrestling with single-threaded I/O, and still lacking a clean way to migrate the agent core later.

Go for the core, keep Ink for rendering. Splits the migration awkwardly — every render bug stays, while we pay full migration cost for a layer that is not where the user-visible problems live. Rendering is the higher-pain side, so rendering moves first.

WASM render layer instead of native binary. Considered. Loses the Node startup win, gains portability we do not need (Reasonix is a terminal tool; "runs in browser" is not a goal). Skipped.

Embed via N-API / napi-rs instead of a subprocess. Tighter coupling, harder distribution (per-Node-version ABI), worse failure isolation. The subprocess + stdio JSON-RPC design has a clean process boundary and is easier to debug. We can revisit if the IPC cost is measurably bad, but the prior is that it is not.

Open questions

  • State library on the Rust side. I lean toward a hand-rolled state object since we already model the world as an event log; we should not import a reactive framework just to feel familiar. Open to argument.
  • ratatui vs. hand-rolled on crossterm. Start with ratatui. Drop down if a specific widget needs control ratatui does not give us.
  • Plugin sandbox. TS plugins via a Node child host is the obvious answer. A WASM plugin host (wasmtime + wit-bindgen) is the ambitious one. Stage 5 territory; not blocking earlier stages.
  • i18n strings. src/i18n/*.ts stays canonical and gets serialized to a Rust-readable form at build time. Translators continue to edit .ts files.
  • The dashboard / web companion. Out of scope for this RFC. The dashboard is its own deployment and is not affected by the renderer cut.

Next steps if accepted

  1. File a tracking issue with the staged checklist above, the same way Tracking: split App.tsx (3782 → ≤500 lines) — staged extraction #565 tracks the App.tsx split.
  2. Land Stage 0 (scene-graph extraction) on main first — it is useful even if the rest of this proposal is rejected.
  3. Spike Stage 1 in a feature branch with a single view rendered through Rust, behind the REASONIX_RENDERER=rust flag. The spike either produces a working card-stream render or surfaces a blocker; either outcome is informative within ~2 weeks.

Comments welcome. The technical commitments above are not load-bearing — the bet is that "stop fighting React-on-terminal" is the right axis. If reviewers think a different cut produces the same robustness gain at lower cost, that lands here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    rfcArchitecture proposal / request for commentstrackingTracking issue / umbrella for a multi-PR effort

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions