fix(tui): refresh virtual transcript on viewport resize (#31077)
* fix(tui): refresh virtual transcript on viewport resize
Notify scroll subscribers when ScrollBox viewport bounds change and key virtual-history updates on viewport height so resize/keyboard changes remount the tail rows instead of leaving stale spacers visible.
* test(tui): isolate viewport-height remount regression
Keep the resize delta below the virtual history scroll quantum so the regression test specifically depends on viewport height entering the snapshot key.
* test(tui): clarify virtual history resize snapshot
Update the resize regression and comments so the test specifically guards viewport-height changes in the virtual-history snapshot key.
* docs(tui): clarify scrollbox subscription signals
Document that ScrollBox subscribers are notified for renderer-computed viewport and content bound changes, not only imperative scrolls.
* fix(tui): recompute virtual tail after width resize
Avoid preserving a frozen virtual transcript range when wrapped rows shrink enough that the old tail window no longer covers the viewport.
* fix(tui): preserve transcript tail across resizes
Wraps + heights are column-dependent, so a width change must remeasure
every row and the renderer must repaint the full viewport.
- Key virtualRows on cols so React remounts wrapped rows on resize.
- Snap back to bottom after sticky-mode resize once React rerenders.
- Reserve a scrollbar + gap column in transcriptBodyWidth (non-termux).
- Full repaint on any viewport height change (was: shrink-only).
- ScrollBox scrollHeight uses deepest child bottom so sticky-bottom
math can reach the real final rendered row after reflow.
- DECSTBM fast-path now requires full container rect match.
* feat(tui): responsive banner tiers
Terminals can't scale glyphs, so the banner now picks a layout per
column width instead of always rendering the full 101-col logo:
- Wide (>= logo width): full ASCII logo + tagline.
- Mid (>= 58 cols): centered rule banner that expands with viewport.
- Narrow (>= 34 cols): brand line + tagline, both width-aware.
- < 34 cols: hidden.
SessionPanel surfaces model/cwd/sid inline when the hero column is
hidden, so narrow layouts don't lose that info. Logo width constants
derive from the art itself.
* fix(tui): re-check sticky inside resize debounce + document remount
Addresses Copilot review on PR #31077:
- onResize now re-checks isSticky() inside the 100ms timer so manual
scrolls during the debounce window don't get snapped back to tail.
- Comment on the virtualRows cols-keying calls out the deliberate
trade-off: per-row local state (e.g. systemOpen) resets on resize so
yoga can remeasure off live geometry. The hook's scale-by-ratio path
is too approximate for mixed markdown widths.
fix(tui): stop slash dropdown from chopping last char of /goal (#31311)
Two independent bugs caused the slash-command autocomplete to render
/goal as /goa (and /gquota as /gquot for that matter) in the TUI:
1. tui_gateway/server.py was forwarding c.display from
prompt_toolkit's Completion straight into the JSON-RPC payload.
prompt_toolkit normalizes display= into FormattedText (a list
subclass), so the wire format became [["", "/goal"]] instead of
the string that CompletionItem.display in the TUI declares.
meta already went through to_plain_text — display did not.
2. The dropdown row in appOverlays.tsx used flexDirection="row"
with the display <Text> and the (very long) meta <Text> as
siblings. When the meta overflows the row width, Ink/Yoga shrinks
the *first* column by one cell, lopping the trailing character off
the command name. /goal triggers it reliably because its meta
string is the longest of any built-in command (description +
embedded [text | pause | resume | clear | status] usage hint).
Wrapping the display column in <Box flexShrink={0}> keeps it at
its natural width and lets the meta wrap or truncate instead.
feat(tui): mouse_tracking DEC mode presets (salvage of #26681) (#30084)
* feat(tui): make display.mouse_tracking pick which DEC modes to enable
Previously the boolean flag was all-or-nothing across modes 1000+1002+1003+1006.
Inside tmux, mode 1003 (any-motion) makes every mouse cross of the prompt row
fire a clipboard probe that surfaces as "No image in clipboard" — sometimes
dozens in a row. Disabling tracking entirely killed scroll-wheel scrolling too,
since tmux's own scrollback is preempted by the alt-screen TUI.
display.mouse_tracking (and /mouse <preset>) now accepts `off | wheel |
buttons | all in addition to the legacy booleans. wheel` is 1000+1006:
scroll wheel + click only, no drag, no hover — the tmux-friendly subset.
buttons adds 1002 for drag-to-select. all (= legacy true) keeps the
hover-driven UI (scrollbar paginate-on-hover, link mouseenter, etc.).
* fix(tui): repaint + sync mouse mode when display.mouse_tracking changes
Two interacting bugs left the TUI blank when display.mouse_tracking
switched at runtime (config edit, /mouse <preset>):
1. AlternateScreen's effect re-runs on every mouseTracking change,
tearing down and re-entering the alt screen. After re-entry, ink's
frame buffers are reset by resetFramesForAltScreen() but nothing
schedules the follow-up render — the alt screen sits blank until
some other state change happens to trigger one. Add a
scheduleRender() in setAltScreenActive's active=true branch so
the freshly-entered alt screen gets a full repaint immediately.
2. setAltScreenActive early-returns when active hasn't changed,
which silently drops a mouseTracking change if the cleanup→setup
pair somehow leaves altScreenActive already true. Call
setAltScreenMouseTracking explicitly from the AlternateScreen
effect so the in-memory mode and terminal DECSET sequence stay in
sync regardless of how setAltScreenActive resolved (the call is a
no-op when the mode is unchanged).
* fix(tui): address copilot review #4341269705
- tui_gateway/server.py: drop the never-referenced _MOUSE_TRACKING_MODES
frozenset (comment #3284802434). _MOUSE_TRACKING_ALIASES already
centralizes the canonical preset set via its values; the separate
constant added no behavior.
- tests/test_tui_gateway_server.py: update the existing
test_config_mouse_uses_documented_key_with_legacy_fallback to assert
the new preset strings ('all'/'off' instead of 'on'/'off',
display.mouse_tracking persisted as 'all' instead of True) and add
test_config_mouse_accepts_preset_strings_and_aliases covering /mouse
set with wheel/click/unknown (comment #3284802453). The on/off legacy
config.set return shape was an implementation detail of the boolean
flag, not a stable API — the slash command, gateway help text, and
docs all advertise the preset values now.
- ui-tui/packages/hermes-ink/src/ink/ink.tsx: schedule a render at the
end of reenterAltScreen() (comment #3284802461). Mirrors the same fix
in setAltScreenActive() from ece0a2f4c — without it, SIGCONT/resize
self-heal/stdin-gap re-entry leaves the alt screen blank because
every caller returns early after invoking us.
* fix(tui): address copilot review #4341308478 round 2
- ui-tui/src/config/env.ts (comment #3284837577): the precedence
comment was misleading. Actual behavior on origin/main is
HERMES_TUI_MOUSE_TRACKING (explicit override) > Termux default >
HERMES_TUI_DISABLE_MOUSE legacy kill-switch. This is preserved from
main; the only change here was the wrong comment that claimed
DISABLE_MOUSE kept kill-switch semantics. Rewrote the comment block
to document the actual precedence ladder.
- tui_gateway/server.py /mouse set (comment #3284837607): replaced
'str(value or "").strip().lower()' with the explicit None idiom
already used for /indicator, so programmatic callers can pass 0 /
False and have them route through _MOUSE_TRACKING_ALIASES → 'off'
instead of collapsing to '' and triggering the toggle path.
- ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx
(comment #3284837620): always prepend DISABLE_MOUSE_TRACKING before
enableMouseTrackingFor(...) on mount. Otherwise selecting
'wheel'/'buttons' from a state where DEC 1003 was already asserted
(crash, another app, debugger) would silently leave hover on. Also
unconditionally DISABLE on unmount so a crash mid-mount can't leak
DEC modes back to the host shell.
* chore(release): map nat@nthrow.io to @nthrow for #26681 salvage
* fix(tui): drop redundant setAltScreenMouseTracking in AlternateScreen
Copilot review #4341356637 (comment #3284880417). The explicit
setAltScreenMouseTracking(mouseTracking) after setAltScreenActive(true,
mouseTracking) was defensive paranoia added in the previous fix commit
that's not actually reachable in practice:
- React's cleanup always runs before the next setup, so on any prop
change (mouseTracking or writeRaw) the cleanup sets active=false
first. Setup then sees active was false and applies the new mode
via setAltScreenActive without early-returning.
- On the impossible 'active stayed true' path, the writeRaw above has
already sent DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(newMode)
to the terminal, so the in-memory mode would lag but the visible
state is already correct.
Removing the redundant call means a single DEC sequence per mount.
If the 'active stayed true' path ever manifests in practice, the
right fix is in setAltScreenActive (track mode regardless of the
active early-return), not here.
* fix(tui): always DISABLE before enableMouseTrackingFor in ink.tsx
Copilot review #4341379994 (comments #3284900825, #3284900840,
#3284900852). Three remaining call sites in ink.tsx still re-enabled
mouse tracking without first sending DISABLE_MOUSE_TRACKING:
- handleResize alt-screen recovery (line ~577)
- reassertTerminalModes stdin-gap re-assertion (line ~1351)
- reenterAltScreen SIGCONT/resize/stdin-gap self-heal (line ~1408)
For 'wheel'/'buttons' presets, omitting DISABLE leaves any externally-
asserted DEC 1003 (other apps, prior crash, tmux state) still active
and the hover-free preset silently has hover on. DISABLE_MOUSE_TRACKING
is idempotent and safe to send unconditionally — it resets all four
modes. Matches the pattern already in setAltScreenMouseTracking and
the AlternateScreen mount path.
* fix(tui): always DISABLE before enableMouseTrackingFor in exitAlternateScreen
Copilot review #4341452823 (comment #3284959762). exitAlternateScreen()
was the last call site in ink.tsx still re-enabling mouse tracking
without DISABLE first. Editors (vim/nvim/less) and tmux can leave
DEC 1003 hover asserted across the handoff back; without DISABLE,
'wheel'/'buttons' presets silently kept hover on after the editor
quit. Now all five enableMouseTrackingFor() call sites in ink.tsx
prepend DISABLE_MOUSE_TRACKING — handleResize, reassertTerminalModes,
reenterAltScreen, setAltScreenMouseTracking, exitAlternateScreen.
* fix(tui): add defensive default to enableMouseTrackingFor switch
Copilot review #4341485231 (comment #3284979323). TS exhaustive switch
returns string per the type system, but a JS caller / corrupted config
/ hot-reload-in-dev could reach the function with an unknown value at
runtime. Without a default, that path returns undefined which then
concatenates as the literal string 'undefined' into the terminal byte
stream — visibly garbling output. Treat unknown as 'off' (no DEC
sequences) so the worst case is silent input loss rather than a
wrecked screen.
---------
Co-authored-by: Nat Thrower <nat@nthrow.io>
fix(tui): copilot review on #16707 — naming, label consistency, esc priority
- 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.
fix(tui): keep Ink displayCursor in sync with fast-echo writes so cursor stops drifting (#26717)
* fix(tui): keep Ink displayCursor in sync with fast-echo writes so cursor stops drifting
TextInput's fast-echo bypass writes characters directly to stdout to
avoid waiting on a React re-render for each keystroke. The hardware
cursor advances by text.length cells, but Ink's cached displayCursor
(the basis for the next frame's relative cursor-move preamble in
log-update) stayed unchanged. When ANY unrelated component re-rendered
between the fast-echo write and the deferred composer setCur/setParent
flush — status bar timer, streaming reasoning, etc. — the next frame's
preamble emitted a relative cursor move from a stale parked position
and the hardware cursor parked N cells offset from the actual caret.
Visible symptom: extra whitespace between the just-typed character and
the cursor block, intermittent, worse on long sessions during streaming.
Alt-screen was immune because frames begin with absolute CSI H.
This adds a small API in @hermes/ink:
- Ink.noteExternalCursorAdvance(dx, dy?) — bumps displayCursor if
set, otherwise seeds from frontFrame.cursor so the next preamble's
relative move correctly cancels the external advance. No-op on
alt-screen.
- CursorAdvanceContext + useCursorAdvance() hook to expose it.
TextInput then calls noteCursorAdvance(text.length) after the
fast-echo stdout.write(text) append, and noteCursorAdvance(-1)
after the fast-backspace \b \b sequence.
Tests: 4 new vitest cases pin the API contract (bumps when set, seeds
from frontFrame.cursor when null, alt-screen no-op, zero-delta no-op).
All 751 ui-tui tests pass; tests/test_tui_gateway_server.py (177) pass.
* fix(tui): also advance cursorDeclaration so fast-echo survives deferred React state
Copilot review on PR #26717 flagged a gap in the original fix:
TextInput's fast-echo path defers the React cur state update by
16ms (perf optimization that batches re-renders during heavy typing).
Inside that window, useDeclaredCursor still publishes a target
computed from the PRE-keystroke cur — `cursorLayout(display, cur,
columns). Advancing only displayCursor` would let any unrelated
re-render in that 16ms window run onRender's cursor-park branch with
the stale declaration and visually undo the fast-echo's advance.
The fix is symmetric: noteExternalCursorAdvance now bumps BOTH
displayCursor (the log-update relative-move basis) AND, if non-null,
cursorDeclaration.relativeX/Y (the target the cursor parks at after
every frame). When React finally flushes setCur, useDeclaredCursor
publishes a fresh declaration that supersedes our bumped one — exactly
what we want.
Adds two new vitest cases covering both halves:
- active declaration advances in lock-step with displayCursor
- null declaration stays null (no spurious bump)
All 753 ui-tui tests pass; tests/test_tui_gateway_server.py (177) pass.
Closes review threads:
PRRT_kwDOPRF1G86ChKtD (textInput.tsx:1016 fast-echo append)
PRRT_kwDOPRF1G86ChKtF (textInput.tsx:924 fast-backspace)
PRRT_kwDOPRF1G86ChKtG (ink-cursor-advance.test.ts:57 missing coverage)
* fix(tui): make fast-echo survive TextInput rerenders + alt-screen (Copilot round 2)
Round 2 of PR #26717 review. Three real holes Copilot flagged after the
initial cursorDeclaration bump:
1. alt-screen early-return skipped BOTH halves of the notifier. But the
default TUI wraps the composer in <AlternateScreen> — that IS the
production path. CSI H resets log-update's relative-move basis, but
the alt-screen park branch uses absolute CUP =
rect.x + decl.relativeX, so a stale declaration there still parks
the cursor at the pre-keystroke caret. Fix: skip ONLY the
displayCursor half on alt-screen; still bump cursorDeclaration.
2. TextInput's own rerender could clobber the Ink-level bump. The fast-
echo path defers setCur by 16ms; if a parent state change rerenders
TextInput in that window, the layout effect inside useDeclaredCursor
reads the stale React cur state and re-publishes a declaration at
the OLD column. Fix:
cursorLayout(display, curRef.current, columns) — read the always-
up-to-date ref, not the deferred state. useMemo dropped (compute is
cheap, single-line wrap-text in the common case).
3. Tests bypassed the production wiring. Added two structural tests:
- still advances cursorDeclaration on alt-screen in the Ink-level
suite, asserting displayCursor stays put but the declaration
advances by the delta.
- textInputCursorSourceOfTruth.test.ts pins three structural
invariants: layout reads curRef.current, never the bare cur
state, and the fast-echo stdout.write calls remain paired with
noteCursorAdvance(±N). Source-grep invariants > flaky Ink mount
tests for this kind of regression.
757/757 ui-tui tests pass (+3 over round 1). type-check clean. lint
introduces zero new errors on touched files. tests/test_tui_gateway_server.py
(177) pass.
Closes review threads:
PRRT_kwDOPRF1G86ChOG2 (ink.tsx alt-screen guard)
PRRT_kwDOPRF1G86ChOG9 (textInput.tsx fast-backspace rerender window)
PRRT_kwDOPRF1G86ChOHC (textInput.tsx fast-append rerender window)
PRRT_kwDOPRF1G86ChOHJ (alt-screen test asserts wrong invariant)
PRRT_kwDOPRF1G86ChOHP (missing integration-style coverage)
* fix(tui): reject fast-backspace at soft-wrap boundary (Copilot round 3)
PR #26717 round 3. Copilot caught two real things:
1. \b \b cannot move the terminal cursor onto the previous visual
row across a soft-wrap boundary. When the caret sits at visual
column 0 of a wrapped row (e.g. value 'hello ' at width 6 →
cursorLayout produces (line 1, col 0)), backspace would leave the
physical cursor in place while the logical caret moves up to the
end of the previous visual line. noteCursorAdvance(-1) would then
feed Ink a wrong delta. Fix: canFastBackspaceShape now takes the
composer width and rejects when cursorLayout(value, cursor, columns).column === 0.
The fast path falls through to the normal Ink render, which
correctly lays out the new caret position. The PR-description
inconsistency about alt-screen is fixed in a separate gh pr edit.
Adds 4 new tests in textInputFastEcho.test.ts pinning the rejection at
exact-multiple wrap boundaries plus a positive control inside a
wrapped line and a back-compat case where columns is omitted.
761/761 ui-tui tests pass. type-check / lint clean. 177/177 Python
tests/test_tui_gateway_server.py pass.
Closes review threads:
PRRT_kwDOPRF1G86ChxE5 (textInput.tsx:933 wrap-boundary regression)
* fix(tui): polish doc + tests after Copilot round 4
Three polish points Copilot raised:
1. canFastBackspaceShape doc comment overstated the legacy contract —
said it conservatively rejects potential wrap boundaries when
columns is omitted, but the implementation actually skips the
wrap-boundary check entirely. Reworded to make the legacy behavior
explicit and warn callers not to rely on protection they don't get.
2. ink-cursor-advance.test.ts rationale comment for the
'advances cursorDeclaration in lock-step' case still referenced
the pre-fix cursorLayout(display, cur, columns) expression. Now
accurately describes the current source of truth — curRef.current
in textInput.tsx — and explains the window the bump is bridging.
3. Removed the three __get*ForTest accessors from Ink. The test
file already cast the instance to inspect private state in the
couple of tests that needed declaration mutation; the rest now use
a small peek(ink) helper that does the same cast for reads. No
test-only API surface ships in production.
761/761 ui-tui tests pass. type-check clean. lint introduces zero new
errors on touched files. 177/177 tests/test_tui_gateway_server.py pass.
Closes review threads:
PRRT_kwDOPRF1G86Ch23W (canFastBackspaceShape doc accuracy)
PRRT_kwDOPRF1G86Ch23f (stale test rationale)
PRRT_kwDOPRF1G86Ch23p (test-only API surface in production)
* fix(tui): tighten doc + add dy test coverage (Copilot round 5)
Two polish points from round 5:
1. canFastBackspaceShape doc had two paragraphs that conflicted —
the main 'Additionally rejects when the physical cursor sits at
visual column 0' was stated unconditionally, then the columns-param
paragraph qualified that it only happens when columns is passed.
Reworked into clear 'When supplied / When omitted' branches with a
concrete example value ('hello ' returns true without columns even
though it would be unsafe at width 6). No more inconsistency.
2. Added a test asserting cursorDeclaration.relativeY advances when dy
is non-zero. Existing tests exercised dy on displayCursor only.
Newlines in fast-echoed text don't currently hit the bypass
(canFastAppendShape rejects '\n'), but dy is part of the public
notifier contract and must propagate symmetrically with dx so
future callers get a fully-implemented contract.
762/762 ui-tui tests pass (+1). type-check / lint / build clean.
Closes review threads:
PRRT_kwDOPRF1G86Ch6Sz (doc inconsistency)
PRRT_kwDOPRF1G86Ch6TE (missing dy coverage on declaration)
* fix(tui): doc polish (Copilot round 6)
Four small but valid points:
1. textInputCursorSourceOfTruth.test.ts used bare 'fs'/'path'/'url'
imports; the rest of ui-tui consistently uses the 'node:' prefix
(see src/__tests__/useSessionLifecycle.test.ts, src/lib/editor.test.ts).
Switched to node:fs / node:path / node:url to match convention.
2. CursorAdvanceContext.ts type-level doc described only displayCursor.
The notifier intentionally also mutates the active cursorDeclaration
and that's the only part that matters on alt-screen. Reworked the
doc into a two-part 'updates both' summary with the alt-screen
asymmetry called out explicitly.
3. use-cursor-advance.ts hook doc had the same problem. Same fix —
document both pieces of state, both screen modes.
4. App.tsx onCursorAdvance prop comment was incomplete. Same fix —
describe both state updates and the screen-mode asymmetry.
No behavior change. 762/762 ui-tui tests pass. type-check / lint /
build clean.
Closes review threads (auto-resolved on PR but valid critiques):
PRRT_kwDOPRF1G86Ch926 (node: prefix on built-in imports)
PRRT_kwDOPRF1G86Ch92_ (use-cursor-advance.ts doc)
PRRT_kwDOPRF1G86Ch93H (CursorAdvanceContext.ts type doc)
PRRT_kwDOPRF1G86Ch93J (App.tsx prop comment)
fix(tui): surface verbose tool details (#30225)
* fix(tui): surface verbose tool details
Emit redacted structured verbose args/results to the TUI so /verbose verbose can show full tool detail without reopening stdout, and fail closed if redaction is unavailable.
Salvages #29011.
Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
* fix(tui): address verbose detail review
Label verbose tool failures as errors, cover forced verbose reasoning, and avoid new diff type warnings from the redaction regression tests.
* fix(tui): bound verbose tool payloads
Cap verbose tool detail text before emitting JSON-RPC events and preserve verbose results on inline diff completions.
* fix(tui): align termux argv test with gc flag
Update the stale TUI launch expectation so the Termux freshness path matches the current direct Node argv.
---------
Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
fix(tui): surface verbose tool details (#30225)
* fix(tui): surface verbose tool details
Emit redacted structured verbose args/results to the TUI so /verbose verbose can show full tool detail without reopening stdout, and fail closed if redaction is unavailable.
Salvages #29011.
Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>
* fix(tui): address verbose detail review
Label verbose tool failures as errors, cover forced verbose reasoning, and avoid new diff type warnings from the redaction regression tests.
* fix(tui): bound verbose tool payloads
Cap verbose tool detail text before emitting JSON-RPC events and preserve verbose results on inline diff completions.
* fix(tui): align termux argv test with gc flag
Update the stale TUI launch expectation so the Termux freshness path matches the current direct Node argv.
---------
Co-authored-by: helix4u <4317663+helix4u@users.noreply.github.com>