* 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>
This commit is contained in:
1
ui-tui/packages/hermes-ink/index.d.ts
vendored
1
ui-tui/packages/hermes-ink/index.d.ts
vendored
@@ -7,6 +7,7 @@ export { Ansi } from './src/ink/Ansi.tsx'
|
||||
export { evictInkCaches } from './src/ink/cache-eviction.ts'
|
||||
export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||
export type { MouseTrackingMode } from './src/ink/termio/dec.ts'
|
||||
export { default as Box } from './src/ink/components/Box.tsx'
|
||||
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||
export { default as Link } from './src/ink/components/Link.tsx'
|
||||
|
||||
@@ -28,4 +28,5 @@ export { createRoot, forceRedraw, default as render, renderSync } from './ink/ro
|
||||
export { stringWidth } from './ink/stringWidth.js'
|
||||
export { wrapAnsi } from './ink/wrapAnsi.js'
|
||||
export { isXtermJs } from './ink/terminal.js'
|
||||
export type { MouseTrackingMode } from './ink/termio/dec.js'
|
||||
export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -97,9 +97,10 @@ import {
|
||||
DBP,
|
||||
DFE,
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
ENABLE_MOUSE_TRACKING,
|
||||
enableMouseTrackingFor,
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
type MouseTrackingMode,
|
||||
SHOW_CURSOR
|
||||
} from './termio/dec.js'
|
||||
import {
|
||||
@@ -267,9 +268,11 @@ export default class Ink {
|
||||
// LF-induced scroll when screen.height === terminalRows) and gates
|
||||
// alt-screen-aware SIGCONT/resize/unmount handling.
|
||||
private altScreenActive = false
|
||||
// Set alongside altScreenActive so SIGCONT resume knows whether to
|
||||
// re-enable mouse tracking (not all <AlternateScreen> uses want it).
|
||||
private altScreenMouseTracking = false
|
||||
// Set alongside altScreenActive so SIGCONT resume knows which mouse
|
||||
// tracking preset to re-enable (not all <AlternateScreen> uses want
|
||||
// tracking, and tmux users routinely opt into the hover-free 'wheel'
|
||||
// subset to silence prompt-row clipboard probes).
|
||||
private altScreenMouseTracking: MouseTrackingMode = 'off'
|
||||
// True when the previous frame's screen buffer cannot be trusted for
|
||||
// blit — selection overlay mutated it, resetFramesForAltScreen()
|
||||
// replaced it with blanks, or forceRedraw() reset it to 0×0. Forces
|
||||
@@ -570,9 +573,11 @@ export default class Ink {
|
||||
this.resizeSettleTimer = null
|
||||
}
|
||||
|
||||
if (this.altScreenMouseTracking) {
|
||||
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
|
||||
}
|
||||
// Mouse tracking — DISABLE first so we land in the exact preset state
|
||||
// even if an external app/terminal/tmux left DEC 1003 hover asserted.
|
||||
// DISABLE_MOUSE_TRACKING is idempotent (resets all four modes
|
||||
// unconditionally), safe to send even when current preset is 'off'.
|
||||
this.options.stdout.write(DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(this.altScreenMouseTracking))
|
||||
|
||||
this.resetFramesForAltScreen()
|
||||
this.needsEraseBeforePaint = true
|
||||
@@ -609,7 +614,7 @@ export default class Ink {
|
||||
// kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables.
|
||||
DISABLE_KITTY_KEYBOARD +
|
||||
DISABLE_MODIFY_OTHER_KEYS +
|
||||
(this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') +
|
||||
(this.altScreenMouseTracking !== 'off' ? DISABLE_MOUSE_TRACKING : '') +
|
||||
// disable mouse (no-op if off)
|
||||
(this.altScreenActive ? '' : '\x1b[?1049h') +
|
||||
// enter alt (already in alt if fullscreen)
|
||||
@@ -645,7 +650,11 @@ export default class Ink {
|
||||
// clear screen (now alt if fullscreen)
|
||||
'\x1b[H' +
|
||||
// cursor home
|
||||
(this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') +
|
||||
// DISABLE first so external editors/tmux that left DEC 1003 hover
|
||||
// on can't survive the handoff back — same pattern as
|
||||
// setAltScreenMouseTracking / reenterAltScreen.
|
||||
DISABLE_MOUSE_TRACKING +
|
||||
enableMouseTrackingFor(this.altScreenMouseTracking) +
|
||||
(this.altScreenActive ? '' : '\x1b[?1049l') +
|
||||
// exit alt (non-fullscreen only)
|
||||
'\x1b[?25l' // hide cursor (Ink manages)
|
||||
@@ -1249,13 +1258,13 @@ export default class Ink {
|
||||
* the first alt-screen frame (and first main-screen frame on exit) is
|
||||
* a full redraw with no stale diff state.
|
||||
*/
|
||||
setAltScreenActive(active: boolean, mouseTracking = false): void {
|
||||
setAltScreenActive(active: boolean, mouseTracking: MouseTrackingMode = 'off'): void {
|
||||
if (this.altScreenActive === active) {
|
||||
return
|
||||
}
|
||||
|
||||
this.altScreenActive = active
|
||||
this.altScreenMouseTracking = active && mouseTracking
|
||||
this.altScreenMouseTracking = active ? mouseTracking : 'off'
|
||||
|
||||
// Hover state is alt-screen-scoped: dispatchHover is gated on
|
||||
// altScreenActive, so once we leave the alt screen there's no path to
|
||||
@@ -1269,25 +1278,29 @@ export default class Ink {
|
||||
|
||||
if (active) {
|
||||
this.resetFramesForAltScreen()
|
||||
this.scheduleRender()
|
||||
} else {
|
||||
this.repaint()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mouse tracking at runtime while the alt screen is active.
|
||||
* Writes the appropriate DEC reset/set sequences so the terminal
|
||||
* (and ConPTY on Windows WSL2) reflects the change immediately.
|
||||
* Switch mouse tracking preset at runtime while the alt screen is
|
||||
* active. Always issues DISABLE first so switching between subsets (e.g.
|
||||
* 'all' → 'wheel') clears mode 1003 instead of leaving it asserted —
|
||||
* DEC private modes have no "set this exact bitmask" form, only
|
||||
* individual set/reset, and tmux's mouse-mode bookkeeping does honor the
|
||||
* reset so the prompt-row "No image in clipboard" spam stops.
|
||||
*/
|
||||
setAltScreenMouseTracking(enabled: boolean): void {
|
||||
if (this.altScreenMouseTracking === enabled) {
|
||||
setAltScreenMouseTracking(mode: MouseTrackingMode): void {
|
||||
if (this.altScreenMouseTracking === mode) {
|
||||
return
|
||||
}
|
||||
|
||||
this.altScreenMouseTracking = enabled
|
||||
this.altScreenMouseTracking = mode
|
||||
|
||||
if (this.altScreenActive) {
|
||||
this.options.stdout.write(enabled ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
|
||||
this.options.stdout.write(DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(mode))
|
||||
}
|
||||
}
|
||||
get isAltScreenActive(): boolean {
|
||||
@@ -1340,9 +1353,10 @@ export default class Ink {
|
||||
}
|
||||
|
||||
// Mouse tracking — idempotent, safe to re-assert on every stdin gap.
|
||||
if (this.altScreenMouseTracking) {
|
||||
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
|
||||
}
|
||||
// DISABLE first so we land in the exact preset state even if an
|
||||
// external app or tmux left DEC 1003 hover asserted out from under us
|
||||
// since the last assertion.
|
||||
this.options.stdout.write(DISABLE_MOUSE_TRACKING + enableMouseTrackingFor(this.altScreenMouseTracking))
|
||||
|
||||
// Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that
|
||||
// have a strong signal the terminal actually dropped mode 1049.
|
||||
@@ -1398,10 +1412,28 @@ export default class Ink {
|
||||
* stays true. ENTER_ALT_SCREEN is a terminal-side no-op if already in alt.
|
||||
*/
|
||||
private reenterAltScreen(): void {
|
||||
// DISABLE_MOUSE_TRACKING before enableMouseTrackingFor — same as
|
||||
// setAltScreenMouseTracking / AlternateScreen mount / handleResize.
|
||||
// DEC private modes have no atomic "set this bitmask" sequence, only
|
||||
// per-mode set/reset, so for 'wheel'/'buttons' presets we must reset
|
||||
// first to drop any lingering DEC 1003 hover from before re-entry.
|
||||
this.options.stdout.write(
|
||||
ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '')
|
||||
ENTER_ALT_SCREEN +
|
||||
ERASE_SCREEN +
|
||||
CURSOR_HOME +
|
||||
DISABLE_MOUSE_TRACKING +
|
||||
enableMouseTrackingFor(this.altScreenMouseTracking)
|
||||
)
|
||||
this.resetFramesForAltScreen()
|
||||
// ERASE_SCREEN above leaves the physical alt screen blank, and
|
||||
// resetFramesForAltScreen() seeds prev/back as blank rows×cols, so
|
||||
// nothing on the front frame survives the re-entry. Callers
|
||||
// (handleResume on SIGCONT, the resize self-heal, the stdin-gap
|
||||
// re-assertion) all return early after invoking us, so without an
|
||||
// explicit render schedule the alt screen sits blank until some
|
||||
// unrelated state change fires the next commit. queueing one
|
||||
// microtask matches scheduleRender's normal cadence.
|
||||
this.scheduleRender()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,8 +47,53 @@ export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR)
|
||||
// Mouse tracking: 1000 reports button press/release/wheel, 1002 adds drag
|
||||
// events (button-motion), 1003 adds all-motion (no button held — for
|
||||
// hover), 1006 uses SGR format (CSI < btn;col;row M/m) instead of legacy
|
||||
// X10 bytes. Combined: wheel + click/drag for selection + hover.
|
||||
export const ENABLE_MOUSE_TRACKING =
|
||||
decset(DEC.MOUSE_NORMAL) + decset(DEC.MOUSE_BUTTON) + decset(DEC.MOUSE_ANY) + decset(DEC.MOUSE_SGR)
|
||||
// X10 bytes.
|
||||
//
|
||||
// Modes are addressable as a preset so users can opt out of 1003 (hover),
|
||||
// which is the noisy one inside tmux — every cursor cross of the prompt
|
||||
// row triggers a clipboard probe that surfaces as "No image in clipboard".
|
||||
// Presets:
|
||||
// - 'off' — no DECSET, terminal/tmux native selection + scroll work
|
||||
// - 'wheel' — 1000 + 1006: click + wheel only, no drag, no hover
|
||||
// - 'buttons' — 1000 + 1002 + 1006: adds drag (text selection), no hover
|
||||
// - 'all' — 1000 + 1002 + 1003 + 1006: legacy behavior, hover-driven
|
||||
// UI (scrollbar paginate-on-hover, link mouseenter, etc.)
|
||||
export type MouseTrackingMode = 'all' | 'buttons' | 'off' | 'wheel'
|
||||
|
||||
const MOUSE_NORMAL = decset(DEC.MOUSE_NORMAL)
|
||||
const MOUSE_BUTTON = decset(DEC.MOUSE_BUTTON)
|
||||
const MOUSE_ANY = decset(DEC.MOUSE_ANY)
|
||||
const MOUSE_SGR = decset(DEC.MOUSE_SGR)
|
||||
|
||||
/** Sequence to enable the requested mouse tracking preset, or '' for 'off'. */
|
||||
export function enableMouseTrackingFor(mode: MouseTrackingMode): string {
|
||||
switch (mode) {
|
||||
case 'all':
|
||||
return MOUSE_NORMAL + MOUSE_BUTTON + MOUSE_ANY + MOUSE_SGR
|
||||
|
||||
case 'buttons':
|
||||
return MOUSE_NORMAL + MOUSE_BUTTON + MOUSE_SGR
|
||||
|
||||
case 'wheel':
|
||||
return MOUSE_NORMAL + MOUSE_SGR
|
||||
|
||||
case 'off':
|
||||
return ''
|
||||
|
||||
default:
|
||||
// Defensive fallback: the type system guarantees exhaustiveness, but
|
||||
// JS callers / corrupted config / hot-reloads in dev could reach this
|
||||
// with an unknown value. Without a default, an unmatched mode 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.
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy alias for the maximal preset (1000 + 1002 + 1003 + 1006). */
|
||||
export const ENABLE_MOUSE_TRACKING = enableMouseTrackingFor('all')
|
||||
/** Reset every mouse mode unconditionally — safe to send when any subset is on. */
|
||||
export const DISABLE_MOUSE_TRACKING =
|
||||
decreset(DEC.MOUSE_SGR) + decreset(DEC.MOUSE_ANY) + decreset(DEC.MOUSE_BUTTON) + decreset(DEC.MOUSE_NORMAL)
|
||||
|
||||
Reference in New Issue
Block a user