feat: integrated-mode polish + sidebar Ctrl+B toggle and label wrap#1044
Merged
Conversation
added 29 commits
May 15, 2026 21:44
Stage A of the user's "build the foundation right first" architectural
pivot. The JS side no longer builds a SceneFrame layout tree; it just
serializes the latest input state. Rust deserializes the state and
runs the full layout / palette / row-shape logic against the *real*
terminal area.
## Why
The TS scene producer was a recurring source of "things don't look
right" bugs because:
- JS reads its idea of terminal size from Ink's `useStdout()`, which
is the **null stream** under `REASONIX_RENDERER=rust` mode. Cols
and rows fell back to 80 × 24 every render, so layout decisions
(boot block size, card cap, dock thickness) were divorced from the
actual terminal — even though the renderer rendered against the
real area. Producer + renderer disagreed on dimensions.
- Two languages held the same constants (palette hexes, row counts,
glyphs). Every visual tweak required keeping both sides in sync.
- Each behavior change required `npm run build` + restart and a
fresh `cargo build` if either side drifted.
Moving the producer to Rust makes the renderer authoritative for
layout. Single source of truth, single rebuild target.
## What
New Rust modules under `crates/reasonix-render/src/`:
- `state.rs` — `SceneState` / `SceneCard` / `SlashMatch` /
`SessionItem` / `SetupState` / `Message` (tagged enum
`{ type: "trace" | "setup", ... }`).
- `theme.rs` — `palette::{bg, bg2, fg, fg1..3, ds, ds_bright,
ds_purple, ok, warn, err}` matching the v1 mock's oklch tokens.
- `producer.rs` — `build_trace_frame(&state, cols, rows)` +
`build_setup_frame(&state, cols, rows)`. Ports the boot block
(REASONIX ASCII banner), card kinds (`user` / `reasoning` /
`streaming` head+body, `tool` rich row, generic single-line for
others), composer / meta / status dock, slash overlay, sessions
picker, approval modal — all from the old TS producer.
`main.rs` reads messages from stdin: tries `Message`, falls back to
bare `SceneState`, then `SetupState`, then legacy `SceneFrame` for
back-compat with any pre-rolling-out builds. Each frame calls
`terminal.size()` to get the real cols/rows and feeds them into the
producer.
## JS side
`useSceneTrace.ts` shrunk from ~700 LOC to ~270 LOC — purely state
shaping now. The hook builds a plain object (`{ type: "trace", ...
state }`) and emits it via the renamed
`emitSceneMessage(message: unknown)`. Same for `useSetupSceneTrace`.
Helpers that survived (still used to serialize wire-format payloads):
`toSceneCard`, `parseRecentCards`, `parseSlashMatches`,
`parseSessions`, `summarizeCard`.
Gone (moved to Rust): `buildTraceFrame`, `buildSetupFrame`, all the
row builders (`composerRow`, `metaRow`, `statusBarRow`, `slashRow`,
…), the kind-glyph / color / label tables, `PALETTE`, `listWindow`,
`slashWindow`, `cardsForHeight`.
## Tests
- Rust (`crates/reasonix-render/tests/producer.rs`) — 14 new cases
covering boot block, card kinds, composer cursor, dock rows,
approval / slash / sessions overlays, status bar segments, edit
mode color, rich tool format, setup frame, body line cap.
- TS (`tests/scene-trace-frame.test.ts`) — pared down to the
20 wire-format tests that still apply (`summarizeCard`,
`toSceneCard`, `parseRecentCards`, `parseSlashMatches`,
`parseSessions`).
\`cargo test -p reasonix-render\` — 51 / 51 green (23 render +
14 producer + 6 decode-only + 6 input + 2 round-trip).
\`npm run verify\` — 3071 / 3074 green via prepush gate (3 pre-existing
skipped).
## Migration notes
- Rust binary **must** be rebuilt for users running off the prebuilt
`target/release/reasonix-render.exe` — the new producer is the
binary's job now.
- The legacy `SceneFrame` JSON shape is still accepted by Rust as a
fourth-tier fallback, so a stale JS bundle paired with a fresh
Rust binary keeps working until both roll forward.
Refs #868
Two suspected causes of the "底部 / 左右 / 上下 都有点没顶全" gap that survives Stage A: 1. `main.rs` never called `enable_raw_mode()` for the render path. In cooked mode the terminal driver treats a write to the last row × last column as an implicit newline / scroll, which leaves the bottom row visually empty under ratatui + alt-screen. `run_emit_input` (the keystroke-only branch) already enables raw mode for its own loop; the render branch was missing it. 2. `scroll_area` carried `padding_x: 2, padding_y: 1` inside the producer, eating 2 cells off each horizontal edge of the scroll area and 1 row off the top/bottom. The outer box's bg drew the right color underneath, so it didn't read as a true "gap" — but on a terminal whose own cell-edge padding is non-zero the compounded effect looked like all four sides were short. Also added a one-shot debug log gated on REASONIX_RENDER_DEBUG=1 that writes `terminal.size()` at startup + the first frame's `area` to stderr (which under rust mode is the ~/.reasonix/rust-render-stderr.log file). Useful for verifying the renderer is seeing the real terminal dimensions, not 80×24. Refs #868
Stderr from the rust child inherits Node's OS-level fd (stdio: "inherit"); the parent's process.stderr.write override is only JS-level and doesn't catch native writes from the rust binary, so the previous eprintln-based diagnostic vanished into the parent terminal (where ratatui's alt screen masked it). Switched to writing the diagnostic to a file — REASONIX_RENDER_DEBUG=1 enables, REASONIX_RENDER_DEBUG_LOG overrides the path; default is ~/.reasonix/rust-render-debug.log.
User feedback: \"为什么其他他们都能自动顶满,我们还要设置 WT padding?\" Answer: we were painting a #0f1018 dark bg on the outer box, which makes the cell grid contrast with the terminal's own background. The WT pixel padding between window frame and cell grid then renders in the terminal's default bg — visually a colored frame around our drawing area. vim / less / claude / etc. don't paint a bg color. Their cells render in the terminal's default bg, so the WT padding ring matches and disappears. Same treatment here: outer + scroll_area now use BoxLayout::default() (no bg). The dock's composer / status rows keep their bg2 tint — that's a deliberate panel-strip effect inside the content flow, not a full-screen wash. Setup frame also drops its bg for the same reason. Refs #868
User report: dropping outer bg caused ghosting. Diagnosis: ratatui's diff renderer compares cells frame-to-frame and only writes cells that changed. When the outer box paints a solid bg every frame, every cell of the frame is touched with that bg → any leftover content from a prior frame gets overwritten cleanly. When outer bg was removed, cells with no current-frame writes (default Cell = space + Color::Reset) didn't necessarily appear as a change vs the previous frame, and stale characters / fg colors stuck. Restoring outer + setup-frame background = palette::bg(). The cost is the WT pixel-padding ring showing the terminal's default bg instead of ours, which is the same trade-off vim / lazygit / btop / zellij all make. Recommend the WT 0-padding setting in the README rather than try to eliminate the bg layer. Also added terminal.autoresize() at startup so the very first frame already knows the actual terminal size — defensive in case the backend's cached size lags the first draw on Windows. Refs #868
…ize main Two upstream releases pulled in: - ratatui 0.30.0 (2025-12-26): `Alignment` → `HorizontalAlignment` alias, `Color::from_crossterm/into_crossterm` replacing From/Into, `Flex::SpaceAround` semantics now match CSS. None of the breaking surface touches our code (we don't use those identifiers). - crossterm 0.29.0: KeyModifiers Display fix; no API change at our call sites. Meanwhile, three modern idioms we were missing: 1. **BufWriter around stdout** — ratatui docs flag unbuffered stdout as the #1 perf footgun. Per-cell write syscalls in the diff loop add up fast on Windows. Wrapping with BufWriter collapses each frame into one flush. 2. **Panic hook that restores the terminal** — without it, a Rust panic leaves the user stuck in raw mode + alt-screen with no keyboard echo and a broken prompt. We now install a hook that disables raw mode and leaves the alt-screen before chaining to the previous hook (so backtraces still print). 3. **Factored init / restore helpers** — `init_terminal()` does `enable_raw_mode` + `EnterAlternateScreen` + new BufWriter backend; `restore_terminal()` is the matching teardown. Same shape as `ratatui::init` / `ratatui::restore` (which we'd use directly if we didn't need the BufWriter / custom backend variant). Renamed local terminal type to `RenderTerminal` for clarity (`Terminal<CrosstermBackend<BufWriter<Stdout>>>` is unwieldy at every call site). All 51 Rust tests still pass. JS-side npm verify untouched. Refs #868
Two stability strategies that modern TUI apps use to eliminate tearing / partial-frame flicker / cross-frame ghosting: ## Synchronized Output Mode (DCS 2026) Every `terminal.draw()` is now bracketed by ESC[?2026h (BeginSynchronizedUpdate) ESC[?2026l (EndSynchronizedUpdate) Supported by Windows Terminal 1.18+, kitty, foot, alacritty, ghostty, iTerm2 3.5+, recent VS Code terminal. Terminals that don't recognize the sequences silently ignore them — zero downside. What it does: the terminal app stops flushing pixels mid-frame. Whatever ANSI bytes arrive between BSU and ESU are buffered; one atomic display update happens at ESU. Eliminates: - Visible cursor "scanning" across the screen during a draw - Partial-frame artifacts when the diff updates two adjacent rows that haven't been ESC-positioned together - Cross-frame ghosting that survives a diff miss (the next frame's paint lands atomically over the previous, no in-between state) ratatui doesn't auto-wrap draws in BSU/ESU (probably because the escape sequences are still terminal-specific), so we do it ourselves around each call via `crossterm::execute!`. ## Force terminal.clear() on terminal resize Each iteration of the stream loop reads `terminal.size()` and compares to the previous frame's size. On change → `terminal.clear()` which marks the next frame as a full redraw (no diff). Prevents stale cells from showing through after a window resize. ratatui already auto-resizes on `terminal.draw`, but auto-resize preserves the diff state which can leave the OLD-dimensions content in cells that no longer belong to the new layout. ## Borrow-checker note `Terminal::draw` returns a `CompletedFrame<'_>` that borrows the terminal. We discard it with `.err()` so the second `crossterm::execute!` (for `EndSynchronizedUpdate`) can re-borrow the terminal mutably. Refs #868
…stom scene tree
The previous architecture had two layers of abstraction stacked on top
of ratatui — `SceneNode` (the protocol type) and `BoxNode` (our own
flex layout engine). The Rust render code wrote cells via
`buf[(x,y)].set_char()` directly, bypassing ratatui's widget system.
That bypass was the root cause of every \"shouldn't ratatui handle this\"
class of bug we kept hitting:
- ghosting from diff misses when our cell-direct writes didn't match
ratatui's expected mutation pattern
- CJK / emoji width counted twice (once by our `unicode-width` advance,
once by ratatui internally)
- no layout cache; full re-layout every frame
- ~1500 LOC of producer + render + scene-tree we maintained ourselves
instead of using ratatui's tested Layout + Paragraph + Block
This PR throws all of that out and uses ratatui's widgets directly.
## What's gone
Deleted from the Rust crate:
- `src/scene.rs` (SceneFrame / SceneNode / BoxLayout / TextRun /
TextStyle / BorderStyle / Dim / FillToken / FlexDirection /
FlexAlign / FlexJustify — 178 LOC)
- `src/producer.rs` (the entire 1,387-LOC scene-tree builder)
- `src/render.rs` (the 389-LOC manual cell-writing renderer with our
own flex algorithm)
- `tests/render.rs` (557 LOC of tests against the deleted renderer)
- `tests/producer.rs` (335 LOC against the deleted producer)
- `tests/round_trip.rs` (107 LOC against the deleted SceneFrame
protocol type)
Deleted from the JS side (no longer used now that JS only emits raw
state):
- `src/cli/ui/scene/build.ts` (the `box` / `text` / `frame` helpers)
- `src/cli/ui/scene/types.ts` (SceneNode / BoxLayout etc. — protocol
types are Rust's now)
- `src/cli/ui/scene/theme.ts` (palette moved to Rust)
- `src/cli/ui/scene/lower.ts` (the abandoned Ink-tree-to-scene
conversion from the original Stage 0 plan)
- 4 test files that exercised the deleted modules
Total: -3,806 LOC across deleted files.
## What's new
`src/view.rs` (779 LOC) — a single `render_trace(state, frame)` +
`render_setup(state, frame)` entry point that uses ratatui widgets
directly:
- `Layout::default().direction(Direction::Vertical).constraints([...])`
for the scroll-area / dock split (instead of our `compute_axis_sizes`)
- `Paragraph::new(Text::from(lines))` with `Wrap { trim: false }` for
the scroll content (instead of our line-by-line `set_char` loop)
- `Block::default().style(Style::default().bg(...))` for the dock and
status bg tints (instead of our custom bg fill path)
- `Line::from(Vec<Span>)` for every styled row; ratatui handles wide
characters / emoji widths internally
- `render_row_split` helper for left-aligned + right-aligned spans on
the meta and status rows
`tests/view.rs` (288 LOC) — 15 cases using `ratatui::backend::TestBackend`
to render into an in-memory grid and assert the symbol stream. Every
behavior the deleted producer.rs tests covered (boot block, card
kinds, composer cursor, dock rows, approval / slash / sessions
overlays, status bar segments, edit mode color, rich tool format,
setup frame) is re-tested against the new view.
`theme.rs` absorbed `Color` / `NamedColor` (previously in scene.rs);
palette is unchanged.
`decode_only.rs` switched from `serde_json::from_str::<SceneFrame>`
(no longer exists) to `serde_json::Value` — it's a dev helper that
just counts valid JSON-line frames, doesn't validate their shape.
## Pipeline shape (unchanged, still JS → JSON state → Rust)
```
JS state change
↓ useSceneTrace useEffect
↓ emitSceneMessage({ type: "trace", model, cards, ... })
↓ child.stdin.write(json + "\n")
↓ Rust child reads line
↓ decode_message → Payload::Trace(SceneState)
↓ render_trace(state, frame) ← new
↓ ratatui widgets emit cell ops
↓ ratatui diff vs previous frame
↓ BufWriter → stdout → terminal
```
Sync-output mode (begin/end synchronized update) and the resize
clear-on-change guard from the previous commits are still in place.
`cargo test -p reasonix-render` — 24 / 24 green
(15 view + 6 input + 3 decode_only).
`npm run verify` — 3039 / 3042 green (3 pre-existing skipped).
Refs #868
The previous render_scroll manually carved a `Rect` 2 cells in from each edge of the scroll area and rendered the Paragraph into that inner Rect. The OUTER 2-cell horizontal margin + 1-row top/bottom margin were never touched by the Paragraph and the canvas-block at the start of render_trace only sets style (bg) on cells, not their symbol. So when boot block → cards transitioned, the boot block's LOGO chars at the very edge of the scroll area persisted as ghosts. Switched to `Paragraph::new(...).block(Block::default() .padding(Padding::new(2, 2, 1, 1)).style(...))`. The Block paints its bg over the full scroll-area rect (including the padding ring) and indents the inner content via Padding — so every cell is fresh each frame, no ghosting. Refs #868
Two real bugs the ratatui source dive (paragraph.rs:407-413) caught:
## 1. Paragraph paints its own style over the OUTER area first
The render order inside Paragraph::render is:
1. buf.set_style(area, self.style) ← outer paint
2. self.block.render(area, buf) ← block paint (covers
only border / bg
cells controlled by
the block style)
3. render text into inner(area)
If Paragraph has no style set, step 1 paints style=default (no bg),
WIPING any bg that a separate Block widget rendered over the same
area beforehand. We were doing exactly that — render_status painted
a Block with bg=bg2 over the status area, then called render_row_split
which used Paragraph::new(left/right) with no style. The Paragraphs
painted their default "no bg" over the left and right chunks,
overwriting the bg2 strip the Block had just laid down. Net effect:
status bar bg2 only survived in cells the Paragraphs didn't touch,
which is a fragmented mess after a diff.
Fix: set `.style(Style::default().bg(...))` on every Paragraph that
needs a bg, instead of relying on a separate Block widget.
Applied to:
- render_scroll (Paragraph.style + Block.style both = palette::bg())
- render_composer (bg2)
- render_approval (bg2)
- render_meta (bg, via render_row_split bg arg)
- render_status (bg2, via render_row_split bg arg)
- render_slash_overlay (bg)
- render_sessions_picker (bg)
- render_setup (bg)
render_row_split now takes a `bg: Color` parameter and applies it to
both half-paragraphs.
## 2. Cards anchor at the bottom of the scroll area, not the top
Paragraph has no vertical-alignment setter — content always starts
at the top row of the area (paragraph.rs:449-458). For chat we want
the latest message adjacent to the composer, not the brand banner.
scroll_lines() now pads the line list at the TOP with empty Lines
when content is shorter than the available height, so cards drift
down to sit just above the dock. Boot block (empty cards path) is
unchanged — stays at the top of scroll.
Together these two fixes address every "didn't fill / ghost / partial
bg" complaint that survives the architectural rewrite.
Refs #868
Replace the ratatui Paragraph + Layout-based render_trace with a single cell-level Widget that hand-paints every (x, y) in the frame. The whole layout splits into the whole_screen/ module: - theme + paint primitives (paint, paint_str, fill_bg, truncate, format_ts) with CJK width handled via unicode-width + set_skip on continuation cells - boot block (REASONIX logo + model/cwd/git/tools/hint rows) - dock: bordered input box, kbd hint row, status bar with ctx bar segments and threshold colors - sidebar: Mission Control header + PLAN / JOBS / CHANGES / SESSION sections, auto-binding to todo + tool cards in state - cards/ subdir, one file per kind: message (user/reasoning/ assistant), todo, tool, diff, output (cmd/fileview/search), notify (subagent/confirm/await/error). 13 kinds total. - slash + @file autocomplete overlays as bordered popups above the dock, with arrow-key navigation and Enter to complete - row-level scrolling via virtual buffer + thumb scrollbar on the right edge; mouse wheel, PgUp/PgDn, Home/End - mouse drag selection with cards-area clamping, scroll-anchor tracking (selection follows content under scroll), auto-scroll when dragging past edges, auto-copy to system clipboard via arboard on mouse-up - tick loop (80ms) drives spinner frames on Running tool cards, composer caret blink, streaming text reveal on the assistant card body Stream-loop now multiplexes JSON state from stdin (in a reader thread feeding an mpsc channel) with crossterm events. Keyboard stays with Node (no protocol change needed for typing); rust captures mouse only and updates local UI state (scroll, selection) independently of incoming state. A new --demo flag drives an interactive playground state with all card kinds populated. 32 new tests in tests/whole_screen.rs cover overlays, card kinds, selection, scrolling, animation. view::render_trace is no longer reached from main.rs (deleted in a follow-up commit).
stream-loop has rendered Trace payloads through WholeScreen since the previous commit; view::render_trace and its 28 helpers are no longer reachable. Delete them. Keep view::render_setup — that's the API-key entry screen (Setup payload), a separate UI path that WholeScreen doesn't cover. view.rs: 796 → 98 lines tests/view.rs: 15 → 2 tests (kept the 2 setup tests)
…oint Track composer cursor index in the demo loop instead of always appending to the buffer. Char keys insert at cursor (not just push to end). Cursor moves through the text: Left / Right one character Ctrl+Left / Right one word Home / End start / end of buffer Backspace delete char before cursor Ctrl+Backspace delete word before cursor Delete delete char after cursor Home / End used to scroll cards top / bottom; that was reassigned to PgUp / PgDn (which were already there). Cursor is the more natural binding for editing. Word boundary uses char::is_whitespace transitions. CJK runs are treated as one word since is_whitespace is false for them; matches typical terminal editing intuition. The caret ▮ renders at composer_cursor in dock.rs — text before cursor + caret + text after cursor, so the caret visually sits between characters when mid-string.
When state.cards is empty, the cards area now renders a 2-row idle banner (OK rail) instead of leaving the middle blank: ▎ ● idle ready for next task ▎ type below · / commands · @ file refs · ! shell Matches the React mock's Idle component. Shown on first launch and after /clear when production reasonix wires state.cards to empty. dock composer paints the caret ▮ at composer_cursor instead of always at end-of-text, so the caret can sit between characters. The ❯ prompt now signals what mode the user is in by color: default ds-bright (chat) /... ds-bright (slash command — paired with overlay) @... ds-purple (attach file — paired with overlay) !... ok-green (shell mode) 3 new tests: idle banner content, caret at cursor index, !shell prefix doesn't accidentally trigger slash/at overlays.
…ine nav
Composer now grows from 1 to 5 content rows as the buffer gains
newlines (DOCK_HEIGHT + lines - 1, capped at 5+MAX_COMPOSER_ROWS-1
= 9 rows total). Cards area shrinks accordingly; selection cards
layout reads the same dock height so mouse coords stay aligned.
Shift+Enter inserts \n at cursor
Enter submits the whole buffer (newlines preserved)
Up / Down when no overlay is active, move cursor between
lines preserving column; otherwise navigate the
overlay match list (existing behavior)
Beyond 5 content rows the box scrolls vertically so the cursor
line is always visible. ↑ and ↓ glyphs appear at the box edge to
indicate hidden lines above or below the visible window.
Slash and @file completion now place the cursor at end of the
substituted buffer instead of leaving it stale.
Standard shell-completion gesture. Tab moves selection forward and wraps to top when at end. Shift+Tab moves back and wraps to bottom. Updates the navigate hint in both overlays to show "↑↓/Tab navigate". Up/Down still work as before; Tab is just an extra binding.
… Node
A new mode flag that combines the demo loop's interactive composer
with the stream loop's stdin-driven scene state. Designed to let
production reasonix hand the entire UI (typing, slash/@ overlays,
scroll, selection, all keybindings) over to the rust renderer
instead of splitting input between Node's Ink composer and rust's
mouse-only capture.
Architecture:
stdin Node → rust line-delimited SceneState JSON
stderr rust → Node line-delimited event JSON
stdout rust → tty rendered frames
controlling rust ↔ tty keyboard + mouse via crossterm
rust owns composer state locally (buffer, cursor, slash_idx,
at_idx, scroll, selection, dragging, tick). On each frame, it
overlays composer_text + composer_cursor onto the incoming
SceneState clone, then renders WholeScreen as usual. Local UI
state never round-trips to Node.
Events emitted to stderr:
{"event":"submit","text":"…"} plain Enter with non-empty buffer
{"event":"interrupt"} Ctrl+C while scene.busy
{"event":"exit"} Ctrl+C when idle, or Ctrl+D
Setup payloads from Node fall through to render_setup with no
keyboard capture for that frame; Node-side Ink can keep handling
the API-key entry if needed.
Plumbing change: Payload + decode_message moved from main.rs to
state.rs so integrated.rs can use them. Composer editing helpers
(insert_char_at, cursor math, word boundaries, line nav) moved to
a new editor module shared between demo and integrated loops.
Node side is unchanged — needs a follow-up PR there to spawn rust
with --integrated, pipe stderr, parse events, and disable Ink's
own composer.
…grated
The rust renderer's --integrated mode (lands on the
whole-screen-prototype branch in the reasonix-render crate) lets
it own keyboard + composer state and report submit/interrupt/exit
events back to Node via stderr. This wires the Node side to it.
When REASONIX_RENDERER=rust and REASONIX_RENDERER_INTEGRATED=1:
- spawnRenderer adds --integrated to the child args
- the child's stderr is piped (was inherit) and parsed
line-by-line as JSON events
- the keystroke input child is skipped — rust captures the
terminal directly via crossterm
- trace exposes setIntegratedEventHandler so chat.tsx can
register a single dispatcher before the first scene frame emits
Events handled:
submit routed to qqSubmitRef.current — same code path the QQ
channel uses to feed text into App's queuedSubmit
effect, then through handleSubmit
exit clean shutdown via stopAndSaveCpuProfile + process.exit
interrupt no-op for now (terminal SIGINT already reaches Node);
wiring loop.abort is a follow-up
Backwards compatible: without the env var the existing rust mode
(Ink composer + RustKeystrokeReader input child) keeps working.
Pulls in pulldown-cmark 0.13 (+ unicase) so the renderer crate can parse markdown bodies from assistant messages.
Parses message/notify/output card bodies as markdown (headings, code, lists, tables, blockquotes, inline emphasis) and renders them cell-by-cell within the card body width. Adds shared wrap_visual helper in cards/mod.rs so wrap math stays in one place.
…sidebar toggle Round of feature work on top of the --integrated runner: - Approval prompts (plan / shell / path / edit / choice / checkpoint) rendered as a full-screen overlay above the dock, with key handlers on the rust side and an event protocol back to Node. - Mode picker (review/auto/yolo) and preset picker (auto/flash/pro) triggered from clicking the pills in the status bar. - Dynamic slash and @file overlays — both now drive from a catalog pushed in scene state (slash_catalog / at_state) instead of hardcoded command and path lists. - Multiline composer wrap + cursor positioning across visual lines. - Live session stats in the right sidebar (model, ctx, ↑/↓ tokens, cache %, cost, balance, last turn) read straight from scene state. - Integrated event loop split into stdin / terminal reader threads with a unified Evt channel. - Sidebar Ctrl+B toggle (was an unimplemented "⌘. toggle" hint); long PLAN / JOBS labels now wrap multi-line inside the sidebar instead of being truncated. - Bounded paint_str_to() so the boot hint, cwd, logo etc. clip to the main-panel width instead of bleeding into sidebar columns. Tests: full sidebar/toggle/wrap regression suite + new dynamic slash/at catalog coverage.
Bridges the rust --integrated runner back to the Node UI layer: - App.tsx routes approval-response / mode-set / preset-set / composer events from the rust child to the existing React handlers via refs. - useSceneTrace pushes the additional scene fields the rust side now consumes (preset, session/last-turn tokens + cost, cache_hit_ratio, slash_catalog, prompt_history, approval, at_state). - State + reducer track session input/output tokens and last turn ms for the new sidebar SESSION block. - Composer text echoed from the rust side feeds useInputRecall so the recall popover sees the live buffer. - renderer-process gains a composer event type; chat.tsx forwards the integrated flag so REASONIX_RENDERER_INTEGRATED=1 spawns rust with --integrated.
…weak - src/demo-utils.ts + tests: tiny sample module used as the target for risk:med submit_plan dogfooding. - scripts/probe-fanout.mts: headless probe that measures tool-call fan-out and ordering for the run_skill flow (issue #675). - benchmarks/tau-bench/db.ts: minor adjustment to test data.
5-line header tripped the ≤2-line rule. Names are doc enough here.
…o-end # Conflicts: # crates/reasonix-render/src/lib.rs # crates/reasonix-render/src/main.rs
CI's cargo fmt --check failed on the new test bodies (long single-line strings and asserts). Ran cargo fmt to bring everything in line.
CI runs `cargo clippy --all-targets -- -D warnings`; the integrated-mode polish landed lints on: - too_many_arguments — paint_str_to, paint_cell, paint_entry, render_block, render_table, render_card_header (renderer helpers with many style/geometry params; #[allow(clippy::too_many_arguments)]) - manual_clamp — overlay.rs / overlay_at.rs: use .clamp() directly - needless_range_loop — md_render.rs table rows: switch to col_widths.iter().enumerate() - large_enum_variant — state::Message / Payload: SceneState dwarfs SetupState; #[allow] on both - needless_lifetimes — overlay_at::entries_for - question_mark — integrated::cycle_or_pick: let-else → `?` - dead_code — markdown::MdBlock::Code lang field (kept for parser fidelity) - field_reassign_with_default — three test fixtures; use struct init - unused assignment / parentheses / loop counter — trivial cleanups
cargo clippy --fix removed unused imports but left the remaining
use {...} braces on too many lines for rustfmt's check.
clippy 1.95 added collapsible_match — fold the inner `if !in_code` into a match guard on the outer SoftBreak/HardBreak arm.
ChasLui
pushed a commit
to ChasLui/DeepSeek-Reasonix
that referenced
this pull request
May 23, 2026
…sengine#1044) * refactor(scene): move the entire scene producer from TS to Rust Stage A of the user's "build the foundation right first" architectural pivot. The JS side no longer builds a SceneFrame layout tree; it just serializes the latest input state. Rust deserializes the state and runs the full layout / palette / row-shape logic against the *real* terminal area. ## Why The TS scene producer was a recurring source of "things don't look right" bugs because: - JS reads its idea of terminal size from Ink's `useStdout()`, which is the **null stream** under `REASONIX_RENDERER=rust` mode. Cols and rows fell back to 80 × 24 every render, so layout decisions (boot block size, card cap, dock thickness) were divorced from the actual terminal — even though the renderer rendered against the real area. Producer + renderer disagreed on dimensions. - Two languages held the same constants (palette hexes, row counts, glyphs). Every visual tweak required keeping both sides in sync. - Each behavior change required `npm run build` + restart and a fresh `cargo build` if either side drifted. Moving the producer to Rust makes the renderer authoritative for layout. Single source of truth, single rebuild target. ## What New Rust modules under `crates/reasonix-render/src/`: - `state.rs` — `SceneState` / `SceneCard` / `SlashMatch` / `SessionItem` / `SetupState` / `Message` (tagged enum `{ type: "trace" | "setup", ... }`). - `theme.rs` — `palette::{bg, bg2, fg, fg1..3, ds, ds_bright, ds_purple, ok, warn, err}` matching the v1 mock's oklch tokens. - `producer.rs` — `build_trace_frame(&state, cols, rows)` + `build_setup_frame(&state, cols, rows)`. Ports the boot block (REASONIX ASCII banner), card kinds (`user` / `reasoning` / `streaming` head+body, `tool` rich row, generic single-line for others), composer / meta / status dock, slash overlay, sessions picker, approval modal — all from the old TS producer. `main.rs` reads messages from stdin: tries `Message`, falls back to bare `SceneState`, then `SetupState`, then legacy `SceneFrame` for back-compat with any pre-rolling-out builds. Each frame calls `terminal.size()` to get the real cols/rows and feeds them into the producer. ## JS side `useSceneTrace.ts` shrunk from ~700 LOC to ~270 LOC — purely state shaping now. The hook builds a plain object (`{ type: "trace", ... state }`) and emits it via the renamed `emitSceneMessage(message: unknown)`. Same for `useSetupSceneTrace`. Helpers that survived (still used to serialize wire-format payloads): `toSceneCard`, `parseRecentCards`, `parseSlashMatches`, `parseSessions`, `summarizeCard`. Gone (moved to Rust): `buildTraceFrame`, `buildSetupFrame`, all the row builders (`composerRow`, `metaRow`, `statusBarRow`, `slashRow`, …), the kind-glyph / color / label tables, `PALETTE`, `listWindow`, `slashWindow`, `cardsForHeight`. ## Tests - Rust (`crates/reasonix-render/tests/producer.rs`) — 14 new cases covering boot block, card kinds, composer cursor, dock rows, approval / slash / sessions overlays, status bar segments, edit mode color, rich tool format, setup frame, body line cap. - TS (`tests/scene-trace-frame.test.ts`) — pared down to the 20 wire-format tests that still apply (`summarizeCard`, `toSceneCard`, `parseRecentCards`, `parseSlashMatches`, `parseSessions`). \`cargo test -p reasonix-render\` — 51 / 51 green (23 render + 14 producer + 6 decode-only + 6 input + 2 round-trip). \`npm run verify\` — 3071 / 3074 green via prepush gate (3 pre-existing skipped). ## Migration notes - Rust binary **must** be rebuilt for users running off the prebuilt `target/release/reasonix-render.exe` — the new producer is the binary's job now. - The legacy `SceneFrame` JSON shape is still accepted by Rust as a fourth-tier fallback, so a stale JS bundle paired with a fresh Rust binary keeps working until both roll forward. Refs esengine#868 * fix(rust): enable raw mode and drop scroll-area inner padding Two suspected causes of the "底部 / 左右 / 上下 都有点没顶全" gap that survives Stage A: 1. `main.rs` never called `enable_raw_mode()` for the render path. In cooked mode the terminal driver treats a write to the last row × last column as an implicit newline / scroll, which leaves the bottom row visually empty under ratatui + alt-screen. `run_emit_input` (the keystroke-only branch) already enables raw mode for its own loop; the render branch was missing it. 2. `scroll_area` carried `padding_x: 2, padding_y: 1` inside the producer, eating 2 cells off each horizontal edge of the scroll area and 1 row off the top/bottom. The outer box's bg drew the right color underneath, so it didn't read as a true "gap" — but on a terminal whose own cell-edge padding is non-zero the compounded effect looked like all four sides were short. Also added a one-shot debug log gated on REASONIX_RENDER_DEBUG=1 that writes `terminal.size()` at startup + the first frame's `area` to stderr (which under rust mode is the ~/.reasonix/rust-render-stderr.log file). Useful for verifying the renderer is seeing the real terminal dimensions, not 80×24. Refs esengine#868 * fix(rust): debug log writes to a file instead of stderr Stderr from the rust child inherits Node's OS-level fd (stdio: "inherit"); the parent's process.stderr.write override is only JS-level and doesn't catch native writes from the rust binary, so the previous eprintln-based diagnostic vanished into the parent terminal (where ratatui's alt screen masked it). Switched to writing the diagnostic to a file — REASONIX_RENDER_DEBUG=1 enables, REASONIX_RENDER_DEBUG_LOG overrides the path; default is ~/.reasonix/rust-render-debug.log. * fix(rust): drop outer background fill, let terminal bg show through User feedback: \"为什么其他他们都能自动顶满,我们还要设置 WT padding?\" Answer: we were painting a #0f1018 dark bg on the outer box, which makes the cell grid contrast with the terminal's own background. The WT pixel padding between window frame and cell grid then renders in the terminal's default bg — visually a colored frame around our drawing area. vim / less / claude / etc. don't paint a bg color. Their cells render in the terminal's default bg, so the WT padding ring matches and disappears. Same treatment here: outer + scroll_area now use BoxLayout::default() (no bg). The dock's composer / status rows keep their bg2 tint — that's a deliberate panel-strip effect inside the content flow, not a full-screen wash. Setup frame also drops its bg for the same reason. Refs esengine#868 * fix(rust): restore outer background fill, drop "no bg" variant User report: dropping outer bg caused ghosting. Diagnosis: ratatui's diff renderer compares cells frame-to-frame and only writes cells that changed. When the outer box paints a solid bg every frame, every cell of the frame is touched with that bg → any leftover content from a prior frame gets overwritten cleanly. When outer bg was removed, cells with no current-frame writes (default Cell = space + Color::Reset) didn't necessarily appear as a change vs the previous frame, and stale characters / fg colors stuck. Restoring outer + setup-frame background = palette::bg(). The cost is the WT pixel-padding ring showing the terminal's default bg instead of ours, which is the same trade-off vim / lazygit / btop / zellij all make. Recommend the WT 0-padding setting in the README rather than try to eliminate the bg layer. Also added terminal.autoresize() at startup so the very first frame already knows the actual terminal size — defensive in case the backend's cached size lags the first draw on Windows. Refs esengine#868 * chore(rust): bump ratatui 0.29 → 0.30 + crossterm 0.28 → 0.29, modernize main Two upstream releases pulled in: - ratatui 0.30.0 (2025-12-26): `Alignment` → `HorizontalAlignment` alias, `Color::from_crossterm/into_crossterm` replacing From/Into, `Flex::SpaceAround` semantics now match CSS. None of the breaking surface touches our code (we don't use those identifiers). - crossterm 0.29.0: KeyModifiers Display fix; no API change at our call sites. Meanwhile, three modern idioms we were missing: 1. **BufWriter around stdout** — ratatui docs flag unbuffered stdout as the #1 perf footgun. Per-cell write syscalls in the diff loop add up fast on Windows. Wrapping with BufWriter collapses each frame into one flush. 2. **Panic hook that restores the terminal** — without it, a Rust panic leaves the user stuck in raw mode + alt-screen with no keyboard echo and a broken prompt. We now install a hook that disables raw mode and leaves the alt-screen before chaining to the previous hook (so backtraces still print). 3. **Factored init / restore helpers** — `init_terminal()` does `enable_raw_mode` + `EnterAlternateScreen` + new BufWriter backend; `restore_terminal()` is the matching teardown. Same shape as `ratatui::init` / `ratatui::restore` (which we'd use directly if we didn't need the BufWriter / custom backend variant). Renamed local terminal type to `RenderTerminal` for clarity (`Terminal<CrosstermBackend<BufWriter<Stdout>>>` is unwieldy at every call site). All 51 Rust tests still pass. JS-side npm verify untouched. Refs esengine#868 * fix(rust): wrap each draw in synchronized output + clear on resize Two stability strategies that modern TUI apps use to eliminate tearing / partial-frame flicker / cross-frame ghosting: ## Synchronized Output Mode (DCS 2026) Every `terminal.draw()` is now bracketed by ESC[?2026h (BeginSynchronizedUpdate) ESC[?2026l (EndSynchronizedUpdate) Supported by Windows Terminal 1.18+, kitty, foot, alacritty, ghostty, iTerm2 3.5+, recent VS Code terminal. Terminals that don't recognize the sequences silently ignore them — zero downside. What it does: the terminal app stops flushing pixels mid-frame. Whatever ANSI bytes arrive between BSU and ESU are buffered; one atomic display update happens at ESU. Eliminates: - Visible cursor "scanning" across the screen during a draw - Partial-frame artifacts when the diff updates two adjacent rows that haven't been ESC-positioned together - Cross-frame ghosting that survives a diff miss (the next frame's paint lands atomically over the previous, no in-between state) ratatui doesn't auto-wrap draws in BSU/ESU (probably because the escape sequences are still terminal-specific), so we do it ourselves around each call via `crossterm::execute!`. ## Force terminal.clear() on terminal resize Each iteration of the stream loop reads `terminal.size()` and compares to the previous frame's size. On change → `terminal.clear()` which marks the next frame as a full redraw (no diff). Prevents stale cells from showing through after a window resize. ratatui already auto-resizes on `terminal.draw`, but auto-resize preserves the diff state which can leave the OLD-dimensions content in cells that no longer belong to the new layout. ## Borrow-checker note `Terminal::draw` returns a `CompletedFrame<'_>` that borrows the terminal. We discard it with `.err()` so the second `crossterm::execute!` (for `EndSynchronizedUpdate`) can re-borrow the terminal mutably. Refs esengine#868 * refactor(rust): rewrite view layer on top of ratatui widgets, drop custom scene tree The previous architecture had two layers of abstraction stacked on top of ratatui — `SceneNode` (the protocol type) and `BoxNode` (our own flex layout engine). The Rust render code wrote cells via `buf[(x,y)].set_char()` directly, bypassing ratatui's widget system. That bypass was the root cause of every \"shouldn't ratatui handle this\" class of bug we kept hitting: - ghosting from diff misses when our cell-direct writes didn't match ratatui's expected mutation pattern - CJK / emoji width counted twice (once by our `unicode-width` advance, once by ratatui internally) - no layout cache; full re-layout every frame - ~1500 LOC of producer + render + scene-tree we maintained ourselves instead of using ratatui's tested Layout + Paragraph + Block This PR throws all of that out and uses ratatui's widgets directly. ## What's gone Deleted from the Rust crate: - `src/scene.rs` (SceneFrame / SceneNode / BoxLayout / TextRun / TextStyle / BorderStyle / Dim / FillToken / FlexDirection / FlexAlign / FlexJustify — 178 LOC) - `src/producer.rs` (the entire 1,387-LOC scene-tree builder) - `src/render.rs` (the 389-LOC manual cell-writing renderer with our own flex algorithm) - `tests/render.rs` (557 LOC of tests against the deleted renderer) - `tests/producer.rs` (335 LOC against the deleted producer) - `tests/round_trip.rs` (107 LOC against the deleted SceneFrame protocol type) Deleted from the JS side (no longer used now that JS only emits raw state): - `src/cli/ui/scene/build.ts` (the `box` / `text` / `frame` helpers) - `src/cli/ui/scene/types.ts` (SceneNode / BoxLayout etc. — protocol types are Rust's now) - `src/cli/ui/scene/theme.ts` (palette moved to Rust) - `src/cli/ui/scene/lower.ts` (the abandoned Ink-tree-to-scene conversion from the original Stage 0 plan) - 4 test files that exercised the deleted modules Total: -3,806 LOC across deleted files. ## What's new `src/view.rs` (779 LOC) — a single `render_trace(state, frame)` + `render_setup(state, frame)` entry point that uses ratatui widgets directly: - `Layout::default().direction(Direction::Vertical).constraints([...])` for the scroll-area / dock split (instead of our `compute_axis_sizes`) - `Paragraph::new(Text::from(lines))` with `Wrap { trim: false }` for the scroll content (instead of our line-by-line `set_char` loop) - `Block::default().style(Style::default().bg(...))` for the dock and status bg tints (instead of our custom bg fill path) - `Line::from(Vec<Span>)` for every styled row; ratatui handles wide characters / emoji widths internally - `render_row_split` helper for left-aligned + right-aligned spans on the meta and status rows `tests/view.rs` (288 LOC) — 15 cases using `ratatui::backend::TestBackend` to render into an in-memory grid and assert the symbol stream. Every behavior the deleted producer.rs tests covered (boot block, card kinds, composer cursor, dock rows, approval / slash / sessions overlays, status bar segments, edit mode color, rich tool format, setup frame) is re-tested against the new view. `theme.rs` absorbed `Color` / `NamedColor` (previously in scene.rs); palette is unchanged. `decode_only.rs` switched from `serde_json::from_str::<SceneFrame>` (no longer exists) to `serde_json::Value` — it's a dev helper that just counts valid JSON-line frames, doesn't validate their shape. ## Pipeline shape (unchanged, still JS → JSON state → Rust) ``` JS state change ↓ useSceneTrace useEffect ↓ emitSceneMessage({ type: "trace", model, cards, ... }) ↓ child.stdin.write(json + "\n") ↓ Rust child reads line ↓ decode_message → Payload::Trace(SceneState) ↓ render_trace(state, frame) ← new ↓ ratatui widgets emit cell ops ↓ ratatui diff vs previous frame ↓ BufWriter → stdout → terminal ``` Sync-output mode (begin/end synchronized update) and the resize clear-on-change guard from the previous commits are still in place. `cargo test -p reasonix-render` — 24 / 24 green (15 view + 6 input + 3 decode_only). `npm run verify` — 3039 / 3042 green (3 pre-existing skipped). Refs esengine#868 * fix(rust): scroll area padding via Block, not by shrinking the area The previous render_scroll manually carved a `Rect` 2 cells in from each edge of the scroll area and rendered the Paragraph into that inner Rect. The OUTER 2-cell horizontal margin + 1-row top/bottom margin were never touched by the Paragraph and the canvas-block at the start of render_trace only sets style (bg) on cells, not their symbol. So when boot block → cards transitioned, the boot block's LOGO chars at the very edge of the scroll area persisted as ghosts. Switched to `Paragraph::new(...).block(Block::default() .padding(Padding::new(2, 2, 1, 1)).style(...))`. The Block paints its bg over the full scroll-area rect (including the padding ring) and indents the inner content via Padding — so every cell is fresh each frame, no ghosting. Refs esengine#868 * fix(rust): set bg on every Paragraph + bottom-anchor cards in scroll Two real bugs the ratatui source dive (paragraph.rs:407-413) caught: ## 1. Paragraph paints its own style over the OUTER area first The render order inside Paragraph::render is: 1. buf.set_style(area, self.style) ← outer paint 2. self.block.render(area, buf) ← block paint (covers only border / bg cells controlled by the block style) 3. render text into inner(area) If Paragraph has no style set, step 1 paints style=default (no bg), WIPING any bg that a separate Block widget rendered over the same area beforehand. We were doing exactly that — render_status painted a Block with bg=bg2 over the status area, then called render_row_split which used Paragraph::new(left/right) with no style. The Paragraphs painted their default "no bg" over the left and right chunks, overwriting the bg2 strip the Block had just laid down. Net effect: status bar bg2 only survived in cells the Paragraphs didn't touch, which is a fragmented mess after a diff. Fix: set `.style(Style::default().bg(...))` on every Paragraph that needs a bg, instead of relying on a separate Block widget. Applied to: - render_scroll (Paragraph.style + Block.style both = palette::bg()) - render_composer (bg2) - render_approval (bg2) - render_meta (bg, via render_row_split bg arg) - render_status (bg2, via render_row_split bg arg) - render_slash_overlay (bg) - render_sessions_picker (bg) - render_setup (bg) render_row_split now takes a `bg: Color` parameter and applies it to both half-paragraphs. ## 2. Cards anchor at the bottom of the scroll area, not the top Paragraph has no vertical-alignment setter — content always starts at the top row of the area (paragraph.rs:449-458). For chat we want the latest message adjacent to the composer, not the brand banner. scroll_lines() now pads the line list at the TOP with empty Lines when content is shorter than the available height, so cards drift down to sit just above the dock. Boot block (empty cards path) is unchanged — stays at the top of scroll. Together these two fixes address every "didn't fill / ghost / partial bg" complaint that survives the architectural rewrite. Refs esengine#868 * feat(rust): rewrite trace renderer as cell-level WholeScreen widget Replace the ratatui Paragraph + Layout-based render_trace with a single cell-level Widget that hand-paints every (x, y) in the frame. The whole layout splits into the whole_screen/ module: - theme + paint primitives (paint, paint_str, fill_bg, truncate, format_ts) with CJK width handled via unicode-width + set_skip on continuation cells - boot block (REASONIX logo + model/cwd/git/tools/hint rows) - dock: bordered input box, kbd hint row, status bar with ctx bar segments and threshold colors - sidebar: Mission Control header + PLAN / JOBS / CHANGES / SESSION sections, auto-binding to todo + tool cards in state - cards/ subdir, one file per kind: message (user/reasoning/ assistant), todo, tool, diff, output (cmd/fileview/search), notify (subagent/confirm/await/error). 13 kinds total. - slash + @file autocomplete overlays as bordered popups above the dock, with arrow-key navigation and Enter to complete - row-level scrolling via virtual buffer + thumb scrollbar on the right edge; mouse wheel, PgUp/PgDn, Home/End - mouse drag selection with cards-area clamping, scroll-anchor tracking (selection follows content under scroll), auto-scroll when dragging past edges, auto-copy to system clipboard via arboard on mouse-up - tick loop (80ms) drives spinner frames on Running tool cards, composer caret blink, streaming text reveal on the assistant card body Stream-loop now multiplexes JSON state from stdin (in a reader thread feeding an mpsc channel) with crossterm events. Keyboard stays with Node (no protocol change needed for typing); rust captures mouse only and updates local UI state (scroll, selection) independently of incoming state. A new --demo flag drives an interactive playground state with all card kinds populated. 32 new tests in tests/whole_screen.rs cover overlays, card kinds, selection, scrolling, animation. view::render_trace is no longer reached from main.rs (deleted in a follow-up commit). * refactor(rust): drop view::render_trace and its 13 tests stream-loop has rendered Trace payloads through WholeScreen since the previous commit; view::render_trace and its 28 helpers are no longer reachable. Delete them. Keep view::render_setup — that's the API-key entry screen (Setup payload), a separate UI path that WholeScreen doesn't cover. view.rs: 796 → 98 lines tests/view.rs: 15 → 2 tests (kept the 2 setup tests) * feat(rust): composer editing — cursor movement, word-nav, insert at point Track composer cursor index in the demo loop instead of always appending to the buffer. Char keys insert at cursor (not just push to end). Cursor moves through the text: Left / Right one character Ctrl+Left / Right one word Home / End start / end of buffer Backspace delete char before cursor Ctrl+Backspace delete word before cursor Delete delete char after cursor Home / End used to scroll cards top / bottom; that was reassigned to PgUp / PgDn (which were already there). Cursor is the more natural binding for editing. Word boundary uses char::is_whitespace transitions. CJK runs are treated as one word since is_whitespace is false for them; matches typical terminal editing intuition. The caret ▮ renders at composer_cursor in dock.rs — text before cursor + caret + text after cursor, so the caret visually sits between characters when mid-string. * feat(rust): idle empty-state banner + composer cursor + prompt mode hint When state.cards is empty, the cards area now renders a 2-row idle banner (OK rail) instead of leaving the middle blank: ▎ ● idle ready for next task ▎ type below · / commands · @ file refs · ! shell Matches the React mock's Idle component. Shown on first launch and after /clear when production reasonix wires state.cards to empty. dock composer paints the caret ▮ at composer_cursor instead of always at end-of-text, so the caret can sit between characters. The ❯ prompt now signals what mode the user is in by color: default ds-bright (chat) /... ds-bright (slash command — paired with overlay) @... ds-purple (attach file — paired with overlay) !... ok-green (shell mode) 3 new tests: idle banner content, caret at cursor index, !shell prefix doesn't accidentally trigger slash/at overlays. * feat(rust): multi-line composer with Shift+Enter, scroll-to-cursor, line nav Composer now grows from 1 to 5 content rows as the buffer gains newlines (DOCK_HEIGHT + lines - 1, capped at 5+MAX_COMPOSER_ROWS-1 = 9 rows total). Cards area shrinks accordingly; selection cards layout reads the same dock height so mouse coords stay aligned. Shift+Enter inserts \n at cursor Enter submits the whole buffer (newlines preserved) Up / Down when no overlay is active, move cursor between lines preserving column; otherwise navigate the overlay match list (existing behavior) Beyond 5 content rows the box scrolls vertically so the cursor line is always visible. ↑ and ↓ glyphs appear at the box edge to indicate hidden lines above or below the visible window. Slash and @file completion now place the cursor at end of the substituted buffer instead of leaving it stale. * feat(rust): Tab / Shift+Tab cycles slash and @file overlay matches Standard shell-completion gesture. Tab moves selection forward and wraps to top when at end. Shift+Tab moves back and wraps to bottom. Updates the navigate hint in both overlays to show "↑↓/Tab navigate". Up/Down still work as before; Tab is just an extra binding. * feat(rust): --integrated mode — full UI ownership with event proto to Node A new mode flag that combines the demo loop's interactive composer with the stream loop's stdin-driven scene state. Designed to let production reasonix hand the entire UI (typing, slash/@ overlays, scroll, selection, all keybindings) over to the rust renderer instead of splitting input between Node's Ink composer and rust's mouse-only capture. Architecture: stdin Node → rust line-delimited SceneState JSON stderr rust → Node line-delimited event JSON stdout rust → tty rendered frames controlling rust ↔ tty keyboard + mouse via crossterm rust owns composer state locally (buffer, cursor, slash_idx, at_idx, scroll, selection, dragging, tick). On each frame, it overlays composer_text + composer_cursor onto the incoming SceneState clone, then renders WholeScreen as usual. Local UI state never round-trips to Node. Events emitted to stderr: {"event":"submit","text":"…"} plain Enter with non-empty buffer {"event":"interrupt"} Ctrl+C while scene.busy {"event":"exit"} Ctrl+C when idle, or Ctrl+D Setup payloads from Node fall through to render_setup with no keyboard capture for that frame; Node-side Ink can keep handling the API-key entry if needed. Plumbing change: Payload + decode_message moved from main.rs to state.rs so integrated.rs can use them. Composer editing helpers (insert_char_at, cursor math, word boundaries, line nav) moved to a new editor module shared between demo and integrated loops. Node side is unchanged — needs a follow-up PR there to spawn rust with --integrated, pipe stderr, parse events, and disable Ink's own composer. * feat(scene): wire REASONIX_RENDERER_INTEGRATED=1 to spawn rust --integrated The rust renderer's --integrated mode (lands on the whole-screen-prototype branch in the reasonix-render crate) lets it own keyboard + composer state and report submit/interrupt/exit events back to Node via stderr. This wires the Node side to it. When REASONIX_RENDERER=rust and REASONIX_RENDERER_INTEGRATED=1: - spawnRenderer adds --integrated to the child args - the child's stderr is piped (was inherit) and parsed line-by-line as JSON events - the keystroke input child is skipped — rust captures the terminal directly via crossterm - trace exposes setIntegratedEventHandler so chat.tsx can register a single dispatcher before the first scene frame emits Events handled: submit routed to qqSubmitRef.current — same code path the QQ channel uses to feed text into App's queuedSubmit effect, then through handleSubmit exit clean shutdown via stopAndSaveCpuProfile + process.exit interrupt no-op for now (terminal SIGINT already reaches Node); wiring loop.abort is a follow-up Backwards compatible: without the env var the existing rust mode (Ink composer + RustKeystrokeReader input child) keeps working. * chore(rust): add pulldown-cmark for markdown rendering Pulls in pulldown-cmark 0.13 (+ unicase) so the renderer crate can parse markdown bodies from assistant messages. * feat(rust): render markdown bodies in assistant message cards Parses message/notify/output card bodies as markdown (headings, code, lists, tables, blockquotes, inline emphasis) and renders them cell-by-cell within the card body width. Adds shared wrap_visual helper in cards/mod.rs so wrap math stays in one place. * feat(rust): integrated-mode polish — approvals, pickers, completion, sidebar toggle Round of feature work on top of the --integrated runner: - Approval prompts (plan / shell / path / edit / choice / checkpoint) rendered as a full-screen overlay above the dock, with key handlers on the rust side and an event protocol back to Node. - Mode picker (review/auto/yolo) and preset picker (auto/flash/pro) triggered from clicking the pills in the status bar. - Dynamic slash and @file overlays — both now drive from a catalog pushed in scene state (slash_catalog / at_state) instead of hardcoded command and path lists. - Multiline composer wrap + cursor positioning across visual lines. - Live session stats in the right sidebar (model, ctx, ↑/↓ tokens, cache %, cost, balance, last turn) read straight from scene state. - Integrated event loop split into stdin / terminal reader threads with a unified Evt channel. - Sidebar Ctrl+B toggle (was an unimplemented "⌘. toggle" hint); long PLAN / JOBS labels now wrap multi-line inside the sidebar instead of being truncated. - Bounded paint_str_to() so the boot hint, cwd, logo etc. clip to the main-panel width instead of bleeding into sidebar columns. Tests: full sidebar/toggle/wrap regression suite + new dynamic slash/at catalog coverage. * feat(ts): wire React UI to integrated-mode rust events Bridges the rust --integrated runner back to the Node UI layer: - App.tsx routes approval-response / mode-set / preset-set / composer events from the rust child to the existing React handlers via refs. - useSceneTrace pushes the additional scene fields the rust side now consumes (preset, session/last-turn tokens + cost, cache_hit_ratio, slash_catalog, prompt_history, approval, at_state). - State + reducer track session input/output tokens and last turn ms for the new sidebar SESSION block. - Composer text echoed from the rust side feeds useInputRecall so the recall popover sees the live buffer. - renderer-process gains a composer event type; chat.tsx forwards the integrated flag so REASONIX_RENDERER_INTEGRATED=1 spawns rust with --integrated. * chore: demo-utils sample + probe-fanout debug script + tau-bench db tweak - src/demo-utils.ts + tests: tiny sample module used as the target for risk:med submit_plan dogfooding. - scripts/probe-fanout.mts: headless probe that measures tool-call fan-out and ordering for the run_skill flow (issue esengine#675). - benchmarks/tau-bench/db.ts: minor adjustment to test data. * chore: drop demo-utils file header to meet comment-policy 5-line header tripped the ≤2-line rule. Names are doc enough here. * chore(rust): cargo fmt across reasonix-render CI's cargo fmt --check failed on the new test bodies (long single-line strings and asserts). Ran cargo fmt to bring everything in line. * chore(rust): satisfy clippy -D warnings CI runs `cargo clippy --all-targets -- -D warnings`; the integrated-mode polish landed lints on: - too_many_arguments — paint_str_to, paint_cell, paint_entry, render_block, render_table, render_card_header (renderer helpers with many style/geometry params; #[allow(clippy::too_many_arguments)]) - manual_clamp — overlay.rs / overlay_at.rs: use .clamp() directly - needless_range_loop — md_render.rs table rows: switch to col_widths.iter().enumerate() - large_enum_variant — state::Message / Payload: SceneState dwarfs SetupState; #[allow] on both - needless_lifetimes — overlay_at::entries_for - question_mark — integrated::cycle_or_pick: let-else → `?` - dead_code — markdown::MdBlock::Code lang field (kept for parser fidelity) - field_reassign_with_default — three test fixtures; use struct init - unused assignment / parentheses / loop counter — trivial cleanups * chore(rust): reflow message.rs use line after clippy import prune cargo clippy --fix removed unused imports but left the remaining use {...} braces on too many lines for rustfmt's check. * chore(rust): collapse Event::SoftBreak | HardBreak match arm into guard clippy 1.95 added collapsible_match — fold the inner `if !in_code` into a match guard on the outer SoftBreak/HardBreak arm. --------- Co-authored-by: reasonix <reasonix@deepseek.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end pass on the rust
--integratedrunner. The Rust process now owns the full screen during a session — approvals, mode/preset pickers, slash & @file completion, multiline composer, live session stats — and the React shell relays events back. Plus a sidebar Ctrl+B toggle, in-panel label wrapping, and a fix for main-panel text leaking into sidebar columns.Commits
chore(rust): pulldown-cmark dep— markdown parsing dep.feat(rust): render markdown bodies in assistant cards— message/notify/output cards parse + render markdown blocks.feat(rust): integrated-mode polish— approvals, mode/preset pickers, dynamic slash/at catalog, multiline composer, live SESSION stats in sidebar, threaded event loop, Ctrl+B sidebar toggle, in-panel PLAN/JOBS wrap, bounded paints so boot hint / cwd / logo no longer bleed into the sidebar.feat(ts): wire React UI to integrated-mode rust events— App.tsx + hooks + state route approval / mode-set / preset-set / composer events back into the React handlers; sidebar receives live token / cost / cache stats.chore: demo-utils sample + probe-fanout debug script + tau-bench db tweak.chore: drop demo-utils file header to meet comment-policy— comment-policy fix.Test plan
npm run verify(already green locally — full suite incl. comment-policy)cargo test -p reasonix-render(40/40 green)REASONIX_RENDERER_INTEGRATED=1, exercise: