Skip to content

feat: Allow user to press tab and add aditional context when denying.#1

Merged
esengine merged 2 commits into
esengine:mainfrom
wviana:feat/allow-context-on-deny-tool-call
Apr 29, 2026
Merged

feat: Allow user to press tab and add aditional context when denying.#1
esengine merged 2 commits into
esengine:mainfrom
wviana:feat/allow-context-on-deny-tool-call

Conversation

@wviana

@wviana wviana commented Apr 29, 2026

Copy link
Copy Markdown

Hi there, I got really interested on this project.

This MR is just one pain that I felt when using it. On work I'm used to use claude code. On it there is this feature when you're answering a a prompt with no, that is equivalent to deny here on reasonix.

So I was playing with reasonix and sometimes I've faced it trying to do something where I would like to say no but add some additional context on what it should do instead. But the deny I had was saying to the model to keep going without running that command/tool. This is how I come with this change. This was all coded using reasonix itself.

So this is pretty much the same as on claude, you press tab, it will add a , and allow you to write additional context.

I've only manually tested it, I think you don't yet have tests for those ink components yet.

I'm not expecting to get this merged right away, but just a way to start to get in touch.

There are some other things I would like to improve on it, but I'll make the changes and open new MRs for those. Mostly some UI improvements and sometimes it's saying the user had aborted by pressing esc when I didn't.

But again, just a first contact to get to know if PRs are welcome.

@esengine

Copy link
Copy Markdown
Owner

Hey @wviana — thanks a lot for this. You're literally the first PR on this repo. The Claude-Code-style "press tab and add a reason" UX is well-thought-out.

A few notes:

Heads-up on conflicts. I just landed 0.13.1 (commits 937b979 + d82aa83, now on npm as reasonix@0.13.1) — a sizable TUI redesign: alt-screen layout, new ModalCard with bordered shell + footer slot, side-by-side diff in EditConfirm, plus rewrites of Select.tsx / ShellConfirm.tsx / WorkspaceConfirm.tsx / stdin-reader.ts / App.tsx. Most files this PR touches now look quite different. Sorry — that work was uncommitted at the time you opened the PR, so the conflict is on me, not you.

CI is currently failing (https://github.com/esengine/reasonix/actions/runs/25087905451). Three Biome diagnostics, all auto-fixable:

  • Select.tsx:91 — prefer template literal over string concatenation
  • Select.tsx — formatter wants longer string and ternary wrapped onto multiple lines
  • keystroke-context.tsx — import order (ink should come before react per organizeImports)

npm run lint:fix locally should clear all three. Then npm run typecheck && npm test to confirm no regressions.

Could you rebase onto main, fix lint, and re-push? Once it's mergeable I'll do a real review pass. And please open more PRs — the follow-ups you mentioned (UI feedback polish, the spurious "user aborted" on non-Esc) sound great, and I'd rather see them as separate small PRs.

esengine added a commit that referenced this pull request Apr 29, 2026
Three significant fixes / additions on top of 0.13.1:

1. **Row-granular scrolling.** `sliceVisibleEvents` is replaced by a
   pre-flattened row pipeline in `log-rows.tsx`. Each migrated event
   role decomposes into one or more `LogRow`s (each ≈ one terminal
   line); unmigrated roles fall back to a multi-row `LogBlock` that
   wraps the legacy `<EventRow>` component. Migrated this commit:
     · info / warning / error / step-progress
     · finished assistant turns (header + body lines + stats line)
   The slicer picks items by ROW range, the renderer wraps each item
   in `<Box height={1}>`, and `overflow="hidden"` + `justifyContent`
   handle clipping. Wheel = 3 rows / tick, PgUp/Dn = ~viewport.
   Append-anchor sums row counts (not event counts) so the user's
   logical scroll position is preserved exactly when content arrives.

   The earlier negative-marginTop attempt is gone — Ink's overflow=
   hidden does NOT reliably clip negative-margin children (verified
   empirically), which let log content bleed up into the chrome and
   hide the cost / cache pills. The pre-flatten approach avoids the
   layout fight entirely.

2. **Vertical scrollbar + bottom-hint.** A 1-cell column on the
   right of the log viewport renders a brand-colored thumb whose
   position and size track the visible row range. Hidden when
   `totalRows <= viewportRows`. The scrollbar component clamps its
   own offset internally so a momentarily-stale `logScrollOffset`
   can't make the thumb shrink toward zero in "scrolling into
   emptiness" territory. Paired with a `BottomHint` row that surfaces
   "↓ N rows below — End to jump" whenever the user is scrolled up,
   so newly-arrived content at the bottom is never silently missed.

3. **No more bogus "aborted by user (Esc)".** When the event loop
   was blocked >250ms (heavy Ink render on a long log), a
   multi-byte sequence like `\x1b` + `[A` (Up arrow) split across
   chunks would dispatch as `escape` THEN `upArrow` because the
   `setTimeout`-based ESC ambiguity timer fires in the timers phase
   ahead of the poll phase where stdin data events queue. The
   spurious `escape` triggered `loop.abort()`, killing the running
   turn — the same thing the PR #1 author reported as "sometimes
   it's saying the user had aborted by pressing esc when I didn't".
   Fix: defer the timer's dispatch via `setImmediate`, which runs in
   the CHECK phase after POLL — giving any queued data events a
   chance to consume the rest of the sequence first. The chunk
   handler's `cancelEscTimer` already cancels both the timeout and
   the immediate, so a real follow-up byte still wins.

Other polish:
   · `maxScrollRows = totalRows - viewportRows` (was `totalRows - 1`)
     so Home / max scroll lands on a fully-populated viewport showing
     the start of the log instead of one row visible and the rest
     empty.
   · `useEffect` clamp on `logScrollOffset` snaps it back to
     `scrollMaxRowsRef.current` when role-rendering height drifts
     mid-stream — keeps the thumb in sync with reality.

Tests: 1625/1625 pass. Lint clean. Typecheck clean.
@wviana wviana force-pushed the feat/allow-context-on-deny-tool-call branch from 007e0df to 7d9447b Compare April 29, 2026 13:06
@esengine esengine merged commit 622d5f8 into esengine:main Apr 29, 2026
2 checks passed
esengine added a commit that referenced this pull request Apr 29, 2026
Multi-paragraph docstrings + restated-what comments shipped via PR #1
trimmed back per CLAUDE.md.
esengine added a commit that referenced this pull request Apr 29, 2026
…nt-policy gate

feat(core): replay() reads events.jsonl sidecar and runs the same
pure reducers as in-process apply() — first deterministic proof of
the v0.14 projection layer.
feat(cli): reasonix events <name> for inspecting any session's event
stream from the command line.
feat(ui): deny-with-context shipped via PR #1 (@wviana) — Tab on a
tool-confirm modal opens inline reason entry, forwarded to the model
so it knows *why* the user refused.
chore: tests/comment-policy.test.ts enforces CLAUDE.md rules under
npm run verify; companion sweep removed 6.3k LoC of dead-weight
comments across 148 files.
@wviana

wviana commented Apr 29, 2026

Copy link
Copy Markdown
Author

@esengine could you enable discussions on this repo so we can chat about things that would not yet fit as issue?
For example, I would like to discuss about ctrl+c stoping things instead of closing the application and then asking for a double ctrl+d for exiting. Sometimes I press ctrl+c expecting some as esc. Maybe even esc should be pressed twice so user don't stop things accidently.

@esengine

Copy link
Copy Markdown
Owner

@wviana Discussions is already enabled — categories are live (General / Ideas / Q&A / Show and tell / etc.). The Ctrl+C-as-interrupt + double-Esc-to-abort idea fits Ideas perfectly; would love to dig into it there. FWIW I lean the same way — Ctrl+C should cancel the in-flight turn, not nuke the app.

https://github.com/esengine/reasonix/discussions

esengine added a commit that referenced this pull request May 10, 2026
Second audit session run against the tightened rails (#611) showed
the same head-only-then-conclude failure mode again, this time on a
plan-doc file rather than runtime code: the model read the head of
docs/plans/architecture-refactoring-roadmap.md, saw "8 services
still use singletons", and asserted the plan was now stale —
without reading the rest of the doc to check for a "Status: done"
section that might have been there.

The original rail was scoped to "runtime behavior" because that was
the loop.ts dispatcher case from #610. The same blind spot applies
to any file: don't conclude what's in the elided middle off head +
tail. Broaden the wording to cover runtime behavior, current
architectural state, and doc freshness explicitly.

One test bump on tests/code-prompt.test.ts so the broader scope
can't silently regress to runtime-only.
esengine added a commit that referenced this pull request May 10, 2026
* feat(prompt): add audit-mode rails for review/critique tasks

When the user asks Reasonix Code to audit its own architecture, the
existing "cite or shut up" rule covers absence claims but doesn't
catch the more common audit-mode failure: confident, well-structured
proposals built on factually wrong premises about runtime behavior,
fabricated quantities, or recommendations that contradict pinned
memory.

Adds a six-bullet section after "Cite or shut up": auto-preview is
for locating not auditing, flag→consumer trace before claiming
runtime behavior, no fabricated percentages, schema-cost accounting
for new-tool proposals, MEMORY.md as design constraint, and
user-facing ≠ model-facing as a category-error guardrail.

Closes #610.

* prompt: tighten rails #2 and #6 from real audit-session failure modes

Audit session run against the original 6-rail section (#610) showed
two failures the wording didn't catch:

1. **Inventory-claim hallucination.** Asked which tools have
   `stormExempt: true`, the model enumerated 6 file-system tools as
   having it — only 2 actually do. The rail said "trace flag to
   consumer", which the model interpreted as "for one named tool",
   not "for an inventory claim covering many tools." Add an explicit
   inventory clause: grep the flag, don't enumerate from memory.
2. **Library API → dead-code mischaracterization.** The model
   labeled `registerSubagentTool` "dead code from CLI perspective"
   on the basis of a clean grep in `src/cli/`. It's a deliberate
   library export consumed by embedders via `src/index.ts`. The
   rail enumerated three surfaces (slash / tools / UI); add a fourth
   (library) so library exports aren't mistaken for unused code.

Two-test bump on tests/code-prompt.test.ts so the tightened wording
can't silently regress.

* prompt: broaden rail #1 to cover doc / state claims, not just runtime

Second audit session run against the tightened rails (#611) showed
the same head-only-then-conclude failure mode again, this time on a
plan-doc file rather than runtime code: the model read the head of
docs/plans/architecture-refactoring-roadmap.md, saw "8 services
still use singletons", and asserted the plan was now stale —
without reading the rest of the doc to check for a "Status: done"
section that might have been there.

The original rail was scoped to "runtime behavior" because that was
the loop.ts dispatcher case from #610. The same blind spot applies
to any file: don't conclude what's in the elided middle off head +
tail. Broaden the wording to cover runtime behavior, current
architectural state, and doc freshness explicitly.

One test bump on tests/code-prompt.test.ts so the broader scope
can't silently regress to runtime-only.
esengine added a commit that referenced this pull request May 17, 2026
…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 #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 #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 #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 #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 #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 #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 #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 #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 #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 #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>
gass88wei pushed a commit to gass88wei/DeepSeek-Reasonix that referenced this pull request May 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants