`turnController.recordMessageComplete` and `recordMessageDelta` both
prioritised `payload.rendered` over `payload.text`. `payload.rendered`
is the Rich-Console output `tui_gateway` builds for terminals that
can't render markdown themselves; the TUI already renders markdown via
`<Md>`. Two real bugs follow:
1. **Final answer garbled when `display.final_response_markdown: render`
is set** (#16391). Raw ANSI escape sequences pass through into the
React tree and the user sees overlapping coloured text instead of
their answer.
2. **Streaming silently drops content.** Per-delta `rendered` is an
*incremental* Rich fragment. The previous code did
`this.bufRef = rendered ?? this.bufRef + text`, which on every tick
replaced the whole accumulated buffer with the latest mid-sequence
ANSI fragment. Long replies arrived truncated and looked
half-painted — easy to miss as "model is being terse" instead of a
client bug.
Fix:
* `recordMessageComplete` now prefers `payload.text`, falling back to
`payload.rendered` only when the gateway elected not to send any.
* `recordMessageDelta` always accumulates `text`; `rendered` is ignored
on the streaming path entirely (Ink does its own markdown render via
`<Md>` / `streamingMarkdown.tsx`).
Tests:
* `prefers raw text over Rich-rendered ANSI on message.complete` —
the assistant message reflects raw markdown, not ANSI.
* `falls back to payload.rendered when text is missing` — preserves
the legacy "no `text`, only ANSI" path used by some adapters.
* `always accumulates raw text in message.delta and ignores rendered` —
pre-fix code would have made this assertion fail because each delta
overwrote the buffer.
Validation: `npm run type-check` clean, `npm test --run` 392/392 pass.
* fix(tui): make /browser connect actually take effect on the live agent
Reports were that `/browser connect <url>` (and "changes to CDP url
don't get picked up") didn't propagate to the live agent in `--tui`,
forcing users to fall back to setting `browser.cdp_url` in
`config.yaml` and restarting. Tracing the path on current main shows
the protocol wiring is already correct — `/browser` is registered in
`ui-tui/src/app/slash/commands/ops.ts` and dispatches `browser.manage`
through the gateway RPC, NOT the slash worker (covered by the
`browser.manage` row in `slashParity.test.ts`). But three real gaps
left the experience flaky:
1. `cleanup_all_browsers()` ran AFTER `os.environ["BROWSER_CDP_URL"]`
was rewritten. `_ensure_cdp_supervisor(...)` reads the env to
resolve its target URL, so a tool call landing in that brief window
could re-attach the supervisor to the OLD CDP endpoint just before
we reaped sessions, leaving the agent talking to a dead URL.
Reorder to clean first, swap env, clean again so the supervisor
for the default task is definitively closed.
2. `browser.manage status` reported only the env var, ignoring
`browser.cdp_url` from config.yaml. `_get_cdp_override()` (the
resolver the agent itself uses) consults both — match it so
`/browser status` answers the same question the next
`browser_navigate` will see. Closes a stealth bug where users
saw "browser not connected" while their CDP URL was perfectly
set in config.yaml.
3. `/browser disconnect` only cleared `BROWSER_CDP_URL` and reaped
once, leaving the same swap window as connect. Symmetrical
double-cleanup here too.
Frontend (`ops.ts`):
* Echo "next browser tool call will use this CDP endpoint" on success
so users see immediate confirmation that the gateway accepted the
swap, even before any tool runs.
* Mention `browser.cdp_url` in `config.yaml` in the usage hint and
the not-connected status line. Persistent config is the correct
fix for some terminal-multiplexer / sub-agent flows where env
inheritance is unreliable; surfacing it makes that workaround
discoverable.
Tests (4 new, all hermetic):
* `status` returns the resolved URL when only `browser.cdp_url` is
set in config.yaml.
* `connect` writes env AND cleans before/after, in that order.
* `connect` against an unreachable endpoint does NOT mutate env or
reap.
* `disconnect` removes env and cleans twice.
Validation:
scripts/run_tests.sh tests/test_tui_gateway_server.py — 94/94 pass.
cd ui-tui && npm run type-check — clean; npm test --run — 389/389.
* review(copilot): always defer to _get_cdp_override; normalize bare host:port
* review(copilot): collapse discovery-style CDP paths so /json/version isn't duplicated
* fix(tui): /browser status must not perform CDP discovery I/O
Copilot review on PR #17120: previous version routed through
`tools.browser_tool._get_cdp_override`, which calls
`_resolve_cdp_override` and performs an HTTP probe to /json/version
with a multi-second timeout for discovery-style URLs. That blocks
the TUI on `/browser status` whenever the configured host is slow
or unreachable.
Status now reads env-then-config directly with no network I/O. The
WS normalization still happens in `browser_navigate` for actual
tool calls, so behaviour-on-call is unchanged.
* fix(tui): skip /json/version probe for concrete ws://devtools/browser endpoints
Round 2 Copilot review on PR #17120: hosted CDP providers (Browserbase,
browserless, etc.) return concrete `ws[s]://.../devtools/browser/<id>`
URLs which are already directly connectable but don't serve the HTTP
discovery path. The previous `/json/version` probe rejected these
valid endpoints with 'could not reach browser CDP'.
For `ws[s]://...` URLs whose path starts with `/devtools/browser/` we
now do a TCP-level reachability check (`socket.create_connection`)
instead of the HTTP probe. The actual CDP handshake happens on the
next `browser_navigate` call, so we still surface unreachable hosts
as 5031 errors — just without the false negatives.
Discovery-style URLs (`http://host:port[/json[/version]]`) keep the
HTTP probe path unchanged. Updated existing test + added two new
ones (TCP-only success, TCP unreachable → 5031).
* feat(tui): opt-in auto-resume of the most recent session
`hermes --tui` always forges a fresh session at startup unless the user
sets `HERMES_TUI_RESUME=<id>`. Disconnects, terminal-window crashes,
and accidental Ctrl+D therefore lose every piece of in-flight context
even though `state.db` still has the full history a `/resume` away.
Add an opt-in path that mirrors classic CLI's `hermes -c` muscle
memory: when `display.tui_auto_resume_recent: true` is set in
`~/.hermes/config.yaml`, the TUI looks up the most recent human-facing
session and resumes it instead of starting fresh. Default off so
existing users aren't surprised; explicit `HERMES_TUI_RESUME` always
wins.
Wires:
* New `session.most_recent` JSON-RPC in `tui_gateway/server.py` that
returns the first non-`tool` row from `list_sessions_rich`, or
`{"session_id": null}` when none. Uses the same deny-list as
`session.list` so sub-agent rows can't sneak in.
* `createGatewayEventHandler.handleReady` re-ordered: explicit
`STARTUP_RESUME_ID` first (unchanged), then conditional auto-resume
via `config.get full → display.tui_auto_resume_recent`, then the
legacy `newSession()` fallback. Failures of either RPC fall back
to `newSession()` so the path is always finite.
* Default `display.tui_auto_resume_recent: False` added to
`DEFAULT_CONFIG` in `hermes_cli/config.py` (no `_config_version`
bump per AGENTS.md — deep-merge handles the additive key).
Tests:
* 4 new vitest cases in `createGatewayEventHandler.test.ts` cover
every gate-and-fallback combination (env wins, config off, config
on with hit, config on with miss).
* 3 new pytest cases for `session.most_recent` (denied row skip,
tool-only → null, db-unavailable → null).
Validation:
scripts/run_tests.sh tests/test_tui_gateway_server.py — 93/93.
cd ui-tui && npm run type-check — clean; npm test --run — 393/393.
* review(copilot): fold session.most_recent errors into null + extend ConfigDisplayConfig
* review(copilot): cover RPC-rejection fallbacks in auto-resume tests
* fix(tui): drop stale stream events after ctrl-c interrupt
Once interruptTurn() flips this.interrupted, only recordMessageDelta
short-circuited. recordReasoningDelta/Available, recordToolStart/
Progress/Complete, and recordInlineDiffToolComplete kept populating
turnState until the python loop reached its next _interrupt_requested
check (~1s on busy turns), making it look like ctrl-c was ignored
while late "thinking" + tool calls kept landing in the UI.
Add the same interrupted guard to every stream-side recorder, and
clear the flag at startMessage() so the next turn isn't suppressed
if the previous turn never delivered message.complete.
* fix(tui): guard recordTodos against post-interrupt mutation; fake-timers in test
Copilot review on PR #16706:
1. `recordToolStart` is interruption-guarded, but `tool.start`
handler also calls `recordTodos(payload.todos)` first — so a
late tool.start carrying todos could still mutate `turnState.todos`
after Ctrl-C, leaving ghost rows in the panel. Adds the same
`if (this.interrupted) return` early-exit to `recordTodos` so
*all* tool.start side-effects are dropped post-interrupt.
2. The interrupt test was leaking a real `setTimeout` (interrupt
cooldown) across test files, which could fire later and mutate
uiStore from the wrong test context. Wraps the test in
`vi.useFakeTimers()` + `vi.runAllTimers()` and restores real
timers in finally.
3. Extends the same test with a todos payload on the post-interrupt
tool.start so we have explicit regression coverage for #1.
* fix(tui): guard pushTrail post-interrupt; harden interrupt-test cleanup
Round 2 Copilot review on PR #16706:
1. `tool.generating` events route through `pushTrail`, which was not
interruption-guarded — late events could still write 'drafting …'
into `turnTrail` after Ctrl-C, leaving a stale shimmer in the UI.
Adds the same `if (this.interrupted) return` early-exit.
2. Test cleanup moved `vi.runAllTimers()` into `finally` (before
`vi.useRealTimers()`) so a mid-test assertion failure can't leak
the interrupt-cooldown setTimeout across other test files.
3. Replaced the misleading 'pre-interrupt todos … expected to be
cleared by the interrupt cycle' comment with an accurate one
reflecting current behaviour (interrupt does NOT clear todos).
4. Added an explicit assertion that a post-interrupt `tool.generating`
event does not extend `turnTrail` — regression coverage for #1.
* fix(tui): append gateway stderr tail to start_timeout activity
`gateway.start_timeout` previously published only `cwd` + `python`,
which made TUI startup failures hard to disambiguate. The user saw
`gateway startup timed out · /path/to/python /repo · /logs to inspect`
with no signal whether the actual cause was a wrong python interpreter,
a missing dependency, or a config parse failure.
Plumb a 20-line stderr tail through the event so the most useful lines
land directly in the TUI activity feed, capped to the last 8 non-empty
lines for readability:
* `gatewayClient.ts` — collect `getLogTail(20)` when the readyTimer
fires and attach it as `payload.stderr_tail`.
* `gatewayTypes.ts` — extend the `gateway.start_timeout` event union
with the new optional field.
* `createGatewayEventHandler.ts` — emit the trimmed lines after the
existing `gateway startup timed out` activity entry, classified
`error`.
Tests: regression test in `createGatewayEventHandler.test.ts` checks
that `ModuleNotFoundError` / `FileNotFoundError` lines from the tail
land in `getTurnState().activity` so they show up in the UI immediately.
Validation: `npm run type-check` clean, `npm test --run` 390/390.
* review(copilot): filter blanks before slice and cap stderr tail at 120 chars
* feat(tui): auto copy-on-select for transcript text
Drag in the transcript already highlighted but you had to press Cmd+C to
land it on the clipboard, and the highlight cleared on copy — most users
never realised selection existed. Now drag-release fires copySelectionNoClear
so the text is on the clipboard immediately while the highlight stays put,
matching iTerm2's "Copy to pasteboard on selection" default. Esc clears.
Behaviour:
- Single click in the input still positions the cursor (TextInput onClick).
- Single click in the transcript still does nothing destructive.
- Double / triple click select word / line, then drag extends.
- /copyselect [on|off|toggle] (alias /cos) flips the setting at runtime,
HERMES_TUI_DISABLE_COPY_ON_SELECT=1 disables at startup, persists via
display.tui_copy_on_select in config.yaml.
Help overlay now lists drag-select, multi-click, and click-to-position
so the gestures are discoverable.
Made-with: Cursor
* fix(tui): support prompt text selection gestures
Add mouse drag selection and Shift+Arrow/Home/End extension inside the TUI composer so prompt text behaves like a normal editable field while keeping click-to-position and right-click paste intact.
Made-with: Cursor
* Revert "feat(tui): auto copy-on-select for transcript text"
This reverts commit 6701288fe0.
* fix(tui): allow composer selection from prompt whitespace
Give the composer a one-cell mouse capture pad before the editable text. The prompt glyph/gutter still does not become selectable, but dragging from the edge now anchors at input offset 0 so users do not need to hit the first character precisely.
Made-with: Cursor
* fix(tui): clear selections from blank composer space
Clicking blank space in the transcript or composer now clears active TUI/input selections like a normal text surface. TextInput clicks stop bubbling so cursor placement and selection gestures keep their local behavior.
Made-with: Cursor
* fix(tui): delegate prompt gutter drags to composer text
The prompt gutter is now an input gesture region, not selectable content. Dragging from the whitespace or prompt area anchors the composer selection at offset 0, while selection highlight/copy remains limited to actual input text.
Made-with: Cursor
* fix(tui): move composer cursor to end on selection clear
External clear actions now collapse the composer selection to the end of the input, matching normal text-field behavior after dismissing a selection.
Made-with: Cursor
* fix(tui): capture composer padding before prompt
Add an explicit mouse capture cell over the left padding before the prompt glyph. Drags starting there now delegate to the composer input at offset 0 instead of starting terminal-level selection over the prompt chrome.
Made-with: Cursor
* fix(tui): avoid npm install on lockfile mtime churn
Compare package-lock.json against npm's hidden node_modules lock by content instead of mtimes. Git checkouts and npm lock rewrites can make the root lockfile newer even when installed dependencies already match, causing hermes --tui to print Installing TUI dependencies on every launch.
Made-with: Cursor
* fix(tui): include prompt leading cell in gesture region
Use the prompt box's real layout region to cover the leading whitespace cell before the glyph. The cell now participates in mouse hit testing and delegates to composer selection instead of starting terminal-level selection.
Made-with: Cursor
* fix(tui): widen prompt-side gesture capture band
Capture a wider left-side band around the composer prompt row so drags starting in terminal gutter/padding cells are consumed and delegated to input selection, instead of triggering terminal-level selection chrome.
Made-with: Cursor
* fix(tui): make pre-prompt spacer non-selectable content
Replace the sticky-prompt fallback `Text(' ')` with an empty spacer box so the visual gap remains but no literal space character is rendered/copyable before the composer prompt.
Made-with: Cursor
* fix(tui): capture pre-prompt spacer without shifting prompt layout
Revert the widened negative-margin prompt capture band and instead capture drags on the dedicated spacer row above the prompt. This keeps prompt/text alignment stable while still delegating whitespace-start drags to composer selection.
Made-with: Cursor
* fix(tui): align prompt with status bar and capture full input row
Drop the leading prompt column from 3 to 2 so the input first character lines up with the status bar text. Wrap the prompt+input row in a single mouse-capture box and stop event propagation from TextInput's own handlers so any drag in that row delegates to composer selection without leaking to terminal-level selection.
Made-with: Cursor
* fix(tui): anchor hardware cursor during composer selection
When a composer selection covers a row exactly the column width, the rendered text fills the row and the terminal auto-wraps the hardware cursor to col 0 of the next row, leaving a ghost block beneath the prompt. Park the cursor at the start of the input box during selection so it can't escape the input region.
Made-with: Cursor
* fix(tui): hide hardware cursor during composer selection
Stop fighting auto-wrap by hiding the hardware cursor outright while the
composer has an active selection. This prevents both the ghost block under
the prompt (cursor wrapping past the last cell) and the parked-cursor block
on the first selected character. The cursor restores as soon as the
selection clears or focus changes.
Made-with: Cursor
* chore(tui): /clean — drop dead capture-pad path, dedupe gutter handlers
- TextInput: remove unused leftCaptureColumns prop and capture-pad math, drop
unused mouseApi.startAt, fold mouse offset into a single offsetAt helper,
share a MouseEventLite type across the four handlers.
- appLayout: hoist a GutterMouseEvent type and an endInputDrag callback so the
spacer/prompt/input rows share one shape.
- _tui_need_npm_install: lift the runtime-only key set to a module constant,
collapse nested isinstance checks, and document the mtime fallback.
Made-with: Cursor
* fix(tui): address copilot review on PR #16732
- Split InputSelection.clear() into clear() (cursor-preserving) and
collapseToEnd() (clear + jump to end). Cmd+C copy paths keep using
clear() so the cursor stays put; the blank-area click in useMainApp
switches to collapseToEnd() to match the requested UX.
- Spacer-row drags now force row=0 when forwarding into the input,
since the spacer's vertical origin doesn't align with the input box
and Ink mouse-capture keeps dispatching motion to the original
target. Prompt+input row drag keeps localRow because origins match.
Made-with: Cursor
* fix(tui): give TextInput Box an explicit width
After the /clean pass dropped the unused capture-pad math, the wrapping
Box also lost its explicit width and started sizing to its rendered
content. Clicks past the last character missed TextInput and fell
through to the parent prompt-row Box, which collapsed the cursor to
offset 0. Pin the Box back to `columns` so the input owns its full
column span regardless of value length.
Made-with: Cursor
* feat(tui): double-click select-all + hide cursor on terminal blur
- Track click time/offset in TextInput so a quick second click on the
same offset triggers select-all. Ink's screen-level multi-click is
bypassed once our onMouseDown captures, so the gesture has to be
detected locally.
- Extend the cursor-hide effect to also fire when the terminal loses
focus, so the hollow-rect ghost most terminals draw at the parked
cursor position disappears too.
Made-with: Cursor
* chore(tui): /clean — extract isMultiClickAt helper
Pull the click-recurrence math out of TextInput's onMouseDown into a
small isMultiClickAt(offset) helper so the handler reads as the gesture
list it actually is (multi-click → select-all, otherwise start).
Drop the redundant length>0 guard now that selectAll() already noops on
an empty value.
Made-with: Cursor
* docs(tui): explain _tui_need_npm_install content-vs-mtime comparison
Expand the docstring so future readers understand why we parse the
lockfiles instead of comparing mtimes, what the optional/peer skip
covers, how stale hidden-lock entries are handled, and when we fall
back to mtime.
- Rename `removeAt` → `removeAtInPlace` and document the mutation
contract; the old name read like a non-mutating helper.
- Hotkey table + queue header: use `Ctrl+X` / `Esc` to match the
rest of the UI (was `⌃X` / `esc`).
- Render the queued header as a single template literal so JSX
text-node whitespace can't sneak into the rendered line.
- Make `Esc` while editing beat the `terminal.hasSelection` clear:
the header promises 'Esc cancel', so an active selection
shouldn't silently consume the keystroke.
Today there's no way to remove a queued message — ↑ loads it for edit,
ctrl-K dispatches the head, but a draft you no longer want stays put
forever. ctrl-C just clears the composer and exits edit mode without
touching the queue.
Two new bindings, both gated on queueEditIdx !== null so they're
inert when the user isn't pointing at a queue item:
- ctrl-X — delete the queue item being edited, clear composer, exit
edit mode. "cut" matches the mental model and doesn't collide with
any existing binding.
- esc — cancel the edit (composer clears, item stays in queue).
Mirrors ctrl-C's existing behavior so muscle memory has two paths.
Header line now reads `queued (3) · editing 2 · ⌃X delete · esc cancel`
when in edit mode, so the affordance is discoverable without /help.
The /help hotkey table also gets a Ctrl+X entry.
ctrl-C is intentionally unchanged: it should never destroy queued
content. Cancel is non-destructive (esc / ctrl-C); only ctrl-X
removes the item.
Expose a small forceRedraw API from @hermes/ink and use it for Ctrl/Cmd+L so the hotkey performs a real terminal clear + full repaint instead of a no-op state patch.
Use explicit repaint patch semantics for Ctrl/Cmd+L and narrow the hotkey assertion to the actual +L entry so unrelated descriptions do not cause false failures.
Harden busy mode config reads against invalid display config shapes and align /fast help+usage text with accepted aliases, with regression coverage for non-dict display values.
Make Ctrl+L non-destructive by redrawing the current screen state instead of starting a new session, and stop auto-appending --global for typed /model commands so session scope remains the default unless explicitly requested.
Route /browser, /reload-mcp, /rollback, /stop, /fast, and /busy through direct TUI RPC handlers so state changes hit the live gateway session instead of slash-worker fallback. Add TUI session finalize/reset parity hooks (memory commit + plugin boundaries) and parity matrix tests to keep mutating commands off fallback.
Route TUI /title through session.title RPC and queue titles when the session DB row is still initializing, so renamed sessions reliably appear in /resume and browse flows.
- drop unused TUI helpers, test-only layout scaffolding, and stale public debug exports
- remove an unused profiler import and trim test-only coverage for deleted helpers
- gateway handler: turnController always archives in recordMessageComplete,
so the post-complete archiveTodosAtTurnEnd().forEach is dead code. Drop
it and the now-unused import.
- turnController: collapse archive prepend into a single spread expression.
- gateway server: one-line comment for the tool.start todo skip.
Two bugs surfaced together while the model fired the todo tool:
1. Count flickered (e.g. 3 → 1 → 3) because tool.start echoed
args.todos as the live state. With merge=true (or any partial
replacement) args.todos is just the items being updated, not the
full list. Drop the early echo — tool.complete already carries the
canonical full list from the tool result.
2. After turn end the panel jumped from under the user prompt to below
thinking/tools because archiveDoneTodos() was pushed AFTER segments
in finalMessages. Prepend the archive trail msg so it sits right
after the user prompt — same visual slot the live panel occupied
during streaming.
- stringWidth: true LRU on cache hit (touch-on-read via delete+set) so
hot strings stay resident under long sessions; was insertion-order
FIFO before
- virtualHeights: include todos, panel sections, and intro version in
messageHeightKey so height-cache reuse correctly invalidates when
todo content / panel sections change
- virtualHeights: estimate trail+todos rows at todos.length+2 (or 2
collapsed) instead of the generic ~1-line fallback, so initial
virtualization offsets are closer to reality
- useInputHandlers: clearTimeout on unmount for scrollIdleTimer so
pending relaxStreaming() never fires after teardown
- render-node-to-output: drop unused declined.noHint counter from
scrollFastPathStats; it was always 0 (the "hint missing" branch is
outside the diagnostics block)
- perfPane / hermes-ink.d.ts: follow the noHint removal
- wheelAccel: replace ~/claude-code path comment with generic
attribution that doesn't reference a developer-local checkout
Adds an `evictInkCaches(level)` API that prunes the four hot module-level
caches (`widthCache`, `wrapCache`, `sliceCache`, `lineWidthCache`) with
either a half-keep LRU pass or a full clear. Wired into:
- memoryMonitor: half-prune on 'high', full drop on 'critical', before
the heap dump / auto-restart path. Gives long sessions a shot at
recovering RSS instead of hard-exiting.
- useSessionLifecycle.resetSession: half-prune so a /new session starts
with a half-warm pool and the prior session can resume cheaply.
Also: lineWidthCache now uses LRU half-eviction on overflow instead of a
full `cache.clear()`, matching the other three caches.
Comparison vs claude-code: both forks now share the same `prevScreen`
blit + dirty-cascade machinery in render-node-to-output. Their smoothness
came from sibling-memo discipline (every chrome pane memo'd so dirty
cascade doesn't disable transcript blit) — already in place in our
appLayout.tsx (TranscriptPane / ComposerPane / StatusRulePane all memo'd).
Alt-screen is not the cause; both use it. The remaining gap was per-row
CPU on width/wrap/slice, which the previous commit closed.
CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three
hot loops in the per-frame render path:
Output.get() per-frame walk: 24% total
└─ sliceAnsi(line, from, to) per write: 18% total
stringWidth(line) chain (cached + JS): 14% total
All three were re-doing identical work every frame: same string → same
clipped slice → same width.
Fixes:
1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path
skips the cache (inline scan beats Map.get for short ASCII, the >90%
case). String.charCodeAt scan up to 64 chars is cheaper than the regex
fallback.
2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi
is pure and the same content reflows identically every frame.
3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the
end-defined hot path used by Output.get().
4. Skip the slice entirely in Output.get() when the line already fits the
clip box (startsBefore=false && endsAfter=false). Most transcript lines
never exceed their container width, and tokenizing them just to slice
(line, 0, width) was pure overhead. This single fast-path drops
sliceAnsi from 18% → ~0% in the profile.
Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20,
SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16
lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS
window still render in full so reading-zone behavior is unchanged.
Validation, real-user CPU profile, page-up scroll on 11k-line session:
Output.get() self-time: 24% → 0.3%
sliceAnsi total: 18% → not in top 25
stringWidth family: 14% → ~3%
idle: 60.7% → 77.3%
Frame timings (synthetic page-up profile harness):
dur p95: ~10ms → 4.87ms
dur p99: 25ms+ → 12.80ms
yoga p99: ~20ms → 1.87ms
The remaining CPU in the profile is Yoga layoutNode + React commit,
which is the irreducible work for this UI tree size.
Replaces the static WHEEL_SCROLL_STEP=1 multiplier on wheel events
with an adaptive accel state machine that infers user intent from
inter-event timing.
Algorithm ported straight from claude-code's
src/components/ScrollKeybindingHandler.tsx. All tuning constants,
the native/xterm.js path split, the encoder-bounce detection, the
trackpad-burst signature → all theirs. This file is a mechanical
port into our module structure.
What it does:
precision click (>500ms gap) 1 row/event (deliberate scan)
sustained mouse (40-200ms) 2-6 rows (decay curve)
detected wheel bounce ramps to 15 (sticky wheel-mode)
trackpad flick (5+ <5ms) 1 row/event (burst detect)
direction reversal reset to base
Two implementation paths:
* native terminals (ghostty, iTerm2, Kitty, WezTerm) — linear
window-ramp + optional wheel-mode curve triggered by detected
encoder bounce. SGR proportional reporting handled via the
burst-count guard.
* xterm.js (VS Code / Cursor / browser terminals) — pure
exponential-decay curve with fractional carry. Events arrive
1-per-notch with no pre-amplification, so the curve is more
aggressive.
Selected at construction via isXtermJs() from @hermes/ink (now
exported). Per-user tune via HERMES_TUI_SCROLL_SPEED (alias
CLAUDE_CODE_SCROLL_SPEED for portability).
13 unit tests covering direction flip/bounce/reversal, idle
disengage, trackpad-burst disengage, frac invariants, and the
native vs xterm.js branches.
Profiled under --rate 30 (stress test) and --rate 10 (realistic
sustained scroll): accel ramps to cap=6 at 30Hz burst, decays to
1-3 rows at sparse 10Hz clicks. Perf is comparable to baseline
because accel IS multiplying step — the win is perceptual (fast
flicks cover distance, slow clicks keep precision), not raw fps.
Companion to the earlier WHEEL_SCROLL_STEP=1 change: that set the
base; this modulates around it.
User observation: "it doesn't scroll line by line/row by row."
Was right. Two places hardcoded big deltas:
1. WHEEL_SCROLL_STEP = 6 (config/limits.ts)
Each wheel event scrolled 6 rows. A mechanical wheel notch emits
3-5 events → 18-30 rows per click, which visually teleports past
content instead of smooth-scrolling it. Drop to 1. Trackpads
emit 50-100 events per flick — at step=1 that's still a fast flick
(a whole viewport in one flick) but each intermediate frame is
visible. Porting claude-code's wheel accel state machine is the
right next step if this feels sluggish on precision scrolls.
2. pageUp/pageDown = viewport - 2 (useInputHandlers.ts)
Full-viewport jumps replace the entire screen — no visual
continuity, can't scan content — AND land right at Ink's fast-path
threshold (`delta < innerHeight`), which disqualifies the DECSTBM
blit on every press. Half-viewport keeps 50% continuity AND
drops well under the threshold. Two presses still cover the same
total distance.
Profiled against the 1106-msg session, holding the key at 30Hz for
6s:
wheel_up (step 6 → 1):
frames 142 → 163 (+15%)
throughput 10.7 → 15.8 fps (+48%)
patches tot 53018→ 36562 (-31%)
gap p50 5ms → 16ms (actual rendering ~60fps now)
<16ms frames 93 → 76
16-33ms 82 → 76
hitches 3 → 1
pageUp (viewport-2 → viewport/2):
throughput 10.7 → 9.5 fps (same ballpark — smaller delta × same
event rate = less total scroll)
Ink's proportional drain caps at `innerHeight - 1` per frame to keep
the DECSTBM fast path firing. With these smaller deltas every event
comfortably fits under that cap, so fast-path hit rate goes up and
patch volume per frame drops — the measured 31% reduction in total
patches-sent correlates with users perceiving smoother scrolling
because the outer terminal (VS Code / xterm.js / tmux) isn't drowning
in ANSI between paints.
Tests/type-check/build clean; 352 tests pass.