You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Reinstate ink alt-screen render fix (#639) via forked ink + npm: alias #663 — Ink + Yoga undercount visual rows on CJK terminals in alt-screen mode. The fix is a one-line patch to Ink's render path. We had to fork Ink and publish it under our own name because patch-package breaks for downstream consumers.
Tracking: split App.tsx (3782 → ≤500 lines) — staged extraction #565 — App.tsx is 3782 lines because Ink/React forces every concern into the render tree to participate in reconciliation. The staged split is in flight, but the ceiling on what we can decompose is set by Ink's component model.
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.
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.
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.
Land Stage 0 (scene-graph extraction) on main first — it is useful even if the rest of this proposal is rejected.
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.
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:
DECSET 2026synchronized-output envelopes on older builds. We can detect the host and degrade frame rate, but we cannot fix the host.patch-packagebreaks for downstream consumers.App.tsxis 3782 lines because Ink/React forces every concern into the render tree to participate in reconciliation. The staged split is in flight, but the ceiling on what we can decompose is set by Ink's component model.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:
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:
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:
What moves to Rust:
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.tsxsplit) 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 insrc/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+Uregressions (these get fixed for free, addressing the lamyc report from #20).Stage 3 — flip the default, deprecate the Ink path
REASONIX_RENDERER=inkbecomes 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
mainwith 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 reasonixinvokes 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 reasonixshells out to the binary), and a single download on the releases page. All three resolve to the same binary.Distribution
npx reasonixkeeps 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:
curl -fsSL reasonix.dev/install | sh), Rust-native, no Node assumption.cargo install reasonixfor 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
Risks
<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.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
src/i18n/*.tsstays canonical and gets serialized to a Rust-readable form at build time. Translators continue to edit.tsfiles.Next steps if accepted
mainfirst — it is useful even if the rest of this proposal is rejected.REASONIX_RENDERER=rustflag. 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.