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.).
This commit is contained in:
Nat Thrower 2026-05-15 20:47:49 -04:00 committed by Brooklyn Nicholson
parent 4cc18877c6
commit 6e5b8650cc
12 changed files with 283 additions and 82 deletions

View file

@ -845,19 +845,51 @@ def _coerce_statusbar(raw) -> str:
return "top"
def _display_mouse_tracking(display: dict) -> bool:
"""Return canonical display.mouse_tracking with legacy tui_mouse fallback."""
_MOUSE_TRACKING_MODES = frozenset({"off", "wheel", "buttons", "all"})
_MOUSE_TRACKING_ALIASES = {
"0": "off",
"1": "all",
"all": "all",
"any": "all",
"button": "buttons",
"buttons": "buttons",
"click": "buttons",
"false": "off",
"full": "all",
"no": "off",
"off": "off",
"on": "all",
"scroll": "wheel",
"true": "all",
"wheel": "wheel",
"yes": "all",
}
def _display_mouse_tracking(display: dict) -> str:
"""Resolve display.mouse_tracking to one of ``off|wheel|buttons|all``.
Boolean values keep their legacy meaning (``True`` ``all``, ``False``
``off``). The ``wheel`` preset (DEC 1000+1006) is the tmux-friendly
subset wheel + click only, no hover events to trigger prompt-row
clipboard probes. Legacy ``tui_mouse`` is honored only when
``mouse_tracking`` is absent.
"""
if not isinstance(display, dict):
return True
return "all"
if "mouse_tracking" in display:
raw = display.get("mouse_tracking")
else:
raw = display.get("tui_mouse", True)
if raw is False or raw == 0:
return False
return "off"
if raw is True or raw is None:
return "all"
if isinstance(raw, (int, float)):
return "all"
if isinstance(raw, str):
return raw.strip().lower() not in {"0", "false", "no", "off"}
return True
return _MOUSE_TRACKING_ALIASES.get(raw.strip().lower(), "all")
return "all"
def _load_reasoning_config() -> dict | None:
@ -4084,16 +4116,14 @@ def _(rid, params: dict) -> dict:
current = _display_mouse_tracking(display)
if raw in {"", "toggle"}:
nv = not current
elif raw == "on":
nv = True
elif raw == "off":
nv = False
nv = "all" if current == "off" else "off"
elif raw in _MOUSE_TRACKING_ALIASES:
nv = _MOUSE_TRACKING_ALIASES[raw]
else:
return _err(rid, 4002, f"unknown mouse value: {value}")
_write_config_key("display.mouse_tracking", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
return _ok(rid, {"key": key, "value": nv})
if key == "indicator":
# Use an explicit None check rather than `value or ""` so falsy
@ -4266,8 +4296,7 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mouse":
display = _load_cfg().get("display")
on = _display_mouse_tracking(display)
return _ok(rid, {"value": "on" if on else "off"})
return _ok(rid, {"value": _display_mouse_tracking(display)})
if key == "mtime":
cfg_path = _hermes_home / "config.yaml"
try:
@ -4402,7 +4431,11 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
_TUI_EXTRA: list[tuple[str, str, str]] = [
("/compact", "Toggle compact display mode", "TUI"),
("/logs", "Show recent gateway log lines", "TUI"),
("/mouse", "Toggle mouse/wheel tracking [on|off|toggle]", "TUI"),
(
"/mouse",
"Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
"TUI",
),
]
# Commands that queue messages onto _pending_input in the CLI.
@ -5280,7 +5313,7 @@ def _(rid, params: dict) -> dict:
{
"text": "/mouse",
"display": "/mouse",
"meta": "Toggle mouse/wheel tracking [on|off|toggle]",
"meta": "Set mouse tracking preset [on|off|toggle|wheel|buttons|all]",
},
]
for extra in extras:

View file

@ -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'

View file

@ -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

View file

@ -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,8 +573,8 @@ export default class Ink {
this.resizeSettleTimer = null
}
if (this.altScreenMouseTracking) {
this.options.stdout.write(ENABLE_MOUSE_TRACKING)
if (this.altScreenMouseTracking !== 'off') {
this.options.stdout.write(enableMouseTrackingFor(this.altScreenMouseTracking))
}
this.resetFramesForAltScreen()
@ -609,7 +612,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 +648,7 @@ export default class Ink {
// clear screen (now alt if fullscreen)
'\x1b[H' +
// cursor home
(this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') +
enableMouseTrackingFor(this.altScreenMouseTracking) +
(this.altScreenActive ? '' : '\x1b[?1049l') +
// exit alt (non-fullscreen only)
'\x1b[?25l' // hide cursor (Ink manages)
@ -1249,13 +1252,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
@ -1275,19 +1278,22 @@ export default class Ink {
}
/**
* 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,8 +1346,8 @@ 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)
if (this.altScreenMouseTracking !== 'off') {
this.options.stdout.write(enableMouseTrackingFor(this.altScreenMouseTracking))
}
// Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that
@ -1399,7 +1405,7 @@ export default class Ink {
*/
private reenterAltScreen(): void {
this.options.stdout.write(
ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '')
ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + enableMouseTrackingFor(this.altScreenMouseTracking)
)
this.resetFramesForAltScreen()
}

View file

@ -47,8 +47,43 @@ 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 ''
}
}
/** 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)

View file

@ -77,13 +77,26 @@ describe('applyDisplay', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { mouse_tracking: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe(false)
expect($uiState.get().mouseTracking).toBe('off')
applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe(true)
expect($uiState.get().mouseTracking).toBe('all')
applyDisplay({ config: { display: { tui_mouse: false } } }, setBell)
expect($uiState.get().mouseTracking).toBe(false)
expect($uiState.get().mouseTracking).toBe('off')
})
it('threads mouse_tracking presets through to $uiState', () => {
const setBell = vi.fn()
applyDisplay({ config: { display: { mouse_tracking: 'wheel' } } }, setBell)
expect($uiState.get().mouseTracking).toBe('wheel')
applyDisplay({ config: { display: { mouse_tracking: 'buttons' } } }, setBell)
expect($uiState.get().mouseTracking).toBe('buttons')
applyDisplay({ config: { display: { mouse_tracking: 'all' } } }, setBell)
expect($uiState.get().mouseTracking).toBe('all')
})
it('parses display.sections into per-section overrides', () => {
@ -183,15 +196,30 @@ describe('normalizeStatusBar', () => {
})
describe('normalizeMouseTracking', () => {
it('defaults on and prefers canonical mouse_tracking over legacy tui_mouse', () => {
expect(normalizeMouseTracking({})).toBe(true)
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe(false)
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe(true)
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe(true)
expect(normalizeMouseTracking({ tui_mouse: false })).toBe(false)
it('defaults to all and prefers canonical mouse_tracking over legacy tui_mouse', () => {
expect(normalizeMouseTracking({})).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe('off')
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe('all')
expect(normalizeMouseTracking({ tui_mouse: false })).toBe('off')
})
it('accepts preset strings (wheel/buttons/all) and their aliases', () => {
expect(normalizeMouseTracking({ mouse_tracking: 'wheel' })).toBe('wheel')
expect(normalizeMouseTracking({ mouse_tracking: 'scroll' })).toBe('wheel')
expect(normalizeMouseTracking({ mouse_tracking: 'buttons' })).toBe('buttons')
expect(normalizeMouseTracking({ mouse_tracking: 'click' })).toBe('buttons')
expect(normalizeMouseTracking({ mouse_tracking: 'all' })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: 'full' })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: 'on' })).toBe('all')
expect(normalizeMouseTracking({ mouse_tracking: ' WHEEL ' })).toBe('wheel')
})
it('falls back to all for unknown strings', () => {
expect(normalizeMouseTracking({ mouse_tracking: 'rainbows' })).toBe('all')
})
})

View file

@ -1,4 +1,4 @@
import type { ScrollBoxHandle } from '@hermes/ink'
import type { MouseTrackingMode, ScrollBoxHandle } from '@hermes/ink'
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
import type { PasteEvent } from '../components/textInput.js'
@ -104,7 +104,7 @@ export interface UiState {
detailsModeCommandOverride: boolean
info: null | SessionInfo
inlineDiffs: boolean
mouseTracking: boolean
mouseTracking: MouseTrackingMode
sections: SectionVisibility
showCost: boolean
showReasoning: boolean
@ -351,7 +351,7 @@ export interface AppLayoutTranscriptProps {
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: boolean
mouseTracking: MouseTrackingMode
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps

View file

@ -1,9 +1,9 @@
import { forceRedraw } from '@hermes/ink'
import { forceRedraw, type MouseTrackingMode } from '@hermes/ink'
import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { SECTION_NAMES, isSectionName, nextDetailsMode, parseDetailsMode } from '../../../domain/details.js'
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
import type {
ConfigGetValueResponse,
ConfigSetResponse,
@ -44,6 +44,30 @@ const flagFromArg = (arg: string, current: boolean): boolean | null => {
return null
}
// `/mouse` toggles between full tracking and off when called bare so the
// old binary muscle-memory still works. Explicit presets (wheel / buttons /
// all) target the tmux-friendly hover-free subsets.
const MOUSE_MODE_ALIASES: Record<string, MouseTrackingMode> = {
all: 'all',
any: 'all',
button: 'buttons',
buttons: 'buttons',
click: 'buttons',
full: 'all',
off: 'off',
on: 'all',
scroll: 'wheel',
wheel: 'wheel'
}
const mouseModeFromArg = (arg: string, current: MouseTrackingMode): MouseTrackingMode | null => {
if (!arg || arg.trim().toLowerCase() === 'toggle') {
return current === 'off' ? 'all' : 'off'
}
return MOUSE_MODE_ALIASES[arg.trim().toLowerCase()] ?? null
}
const RESET_WORDS = new Set(['reset', 'clear', 'default'])
const CYCLE_WORDS = new Set(['cycle', 'toggle'])
@ -105,20 +129,20 @@ export const coreCommands: SlashCommand[] = [
{
aliases: ['scroll'],
help: 'toggle mouse/wheel tracking [on|off|toggle]',
help: 'set mouse tracking preset [on|off|toggle|wheel|buttons|all]',
name: 'mouse',
run: (arg, ctx) => {
const current = ctx.ui.mouseTracking
const next = flagFromArg(arg, current)
const next = mouseModeFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /mouse [on|off|toggle]')
return ctx.transcript.sys('usage: /mouse [on|off|toggle|wheel|buttons|all]')
}
patchUiState({ mouseTracking: next })
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' }).catch(() => {})
ctx.gateway.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next }).catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`))
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next}`))
}
},

View file

@ -1,3 +1,4 @@
import type { MouseTrackingMode } from '@hermes/ink'
import { useEffect, useRef } from 'react'
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
@ -9,8 +10,8 @@ import type {
} from '../gatewayTypes.js'
import {
DEFAULT_VOICE_RECORD_KEY,
parseVoiceRecordKey,
type ParsedVoiceRecordKey
type ParsedVoiceRecordKey,
parseVoiceRecordKey
} from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
@ -68,16 +69,57 @@ export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
}
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
const TRUTHY_MOUSE_ALL = new Set(['1', 'true', 'yes', 'on', 'all', 'full', 'any'])
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
export const normalizeMouseTracking = (display: { mouse_tracking?: unknown; tui_mouse?: unknown }): boolean => {
// `display.mouse_tracking` accepts boolean (`true` ⇒ all modes, `false` ⇒ off)
// for back-compat, plus the string presets `off|wheel|buttons|all` (aliases:
// `on`/`full`/`any`/`1`/`true`/... → `all`; `0`/`false`/`no`/`off` → `off`).
// `wheel` enables 1000+1006 — scroll wheel + click only, no drag or hover,
// which silences tmux's "No image in clipboard" spam over the prompt row.
// `buttons` adds 1002 so terminal-side text selection drags still register.
// Legacy `tui_mouse` is honored only if `mouse_tracking` is absent.
export const normalizeMouseTracking = (display: {
mouse_tracking?: unknown
tui_mouse?: unknown
}): MouseTrackingMode => {
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
if (raw === false || raw === 0) {
return false
return 'off'
}
return typeof raw === 'string' ? !FALSEY_MOUSE.has(raw.trim().toLowerCase()) : true
if (raw === true || raw === undefined || raw === null) {
return 'all'
}
if (typeof raw === 'number') {
return 'all'
}
if (typeof raw !== 'string') {
return 'all'
}
const v = raw.trim().toLowerCase()
if (FALSEY_MOUSE.has(v)) {
return 'off'
}
if (TRUTHY_MOUSE_ALL.has(v)) {
return 'all'
}
if (v === 'wheel' || v === 'scroll') {
return 'wheel'
}
if (v === 'buttons' || v === 'button' || v === 'click') {
return 'buttons'
}
return 'all'
}
const MTIME_POLL_MS = 5000
@ -114,6 +156,7 @@ export async function hydrateFullConfig(
): Promise<ConfigFullResponse | null> {
const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
applyDisplay(cfg, setBell, setVoiceRecordKey)
return cfg
}
@ -125,6 +168,7 @@ export const applyDisplay = (
const d = cfg?.config?.display ?? {}
setBell(!!d.bell_on_complete)
// Only push the voice record key when the RPC actually returned a
// config payload. ``quietRpc()`` collapses failures to ``null``; if we
// reset the cached shortcut on every null we would clobber a custom
@ -135,6 +179,7 @@ export const applyDisplay = (
if (setVoiceRecordKey && cfg) {
setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg))
}
patchUiState({
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,

View file

@ -1,3 +1,4 @@
import type { MouseTrackingMode } from '@hermes/ink'
import { isTermuxTuiMode } from '../lib/termux.js'
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
@ -27,13 +28,20 @@ export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim()
export const STARTUP_QUERY = (process.env.HERMES_TUI_QUERY ?? '').trim()
export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
// Mouse tracking mode resolution at startup. Per-mode selection (off|wheel|
// buttons|all) lives in display.mouse_tracking in config.yaml — these env
// vars only set the boot-time default before that config is applied:
//
// - HERMES_TUI_DISABLE_MOUSE=1 keeps its kill-switch semantics → 'off'.
// - HERMES_TUI_MOUSE_TRACKING accepts truthy/falsy → 'all'/'off'.
// - On Termux we default mouse tracking OFF so touch selection isn't
// intercepted by terminal mouse protocols. Desktop defaults to 'all'
// to preserve prior behavior.
const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING)
const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
// Mobile selection UX: on Termux default mouse tracking OFF so touch selection
// is less likely to be intercepted by terminal mouse protocols. Desktop keeps
// prior behavior unless explicitly overridden.
export const MOUSE_TRACKING =
const resolvedBootMouseEnabled =
mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy)
export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off'
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)

View file

@ -106,7 +106,7 @@ All slash commands work unchanged. A few are TUI-owned — they produce richer o
| `/usage` | Rich token / cost / context panel |
| `/agents` (alias `/tasks`) | Observability overlay — live subagent tree with kill/pause controls, per-branch cost / token / file rollups, turn-by-turn history |
| `/reload` | Re-reads `~/.hermes/.env` into the running TUI process so newly added API keys take effect without a restart |
| `/mouse` | Toggle mouse tracking on/off at runtime (also persists to `display.mouse_tracking` in `config.yaml`) |
| `/mouse [on\|off\|toggle\|wheel\|buttons\|all]` | Pick a mouse tracking preset at runtime (also persists to `display.mouse_tracking` in `config.yaml`). `wheel` (1000+1006) keeps scroll-wheel scrolling without the hover events that make tmux spam "No image in clipboard" over the prompt row; `buttons` adds drag-to-select; `all` is the default with hover-driven UI. |
Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md).
@ -190,7 +190,13 @@ display:
thinking: expanded # always open
tools: expanded # always open
activity: collapsed # opt back IN to the activity panel (hidden by default)
mouse_tracking: true # disable if your terminal conflicts with mouse reporting
mouse_tracking: all # off | wheel | buttons | all (or true/false for back-compat).
# wheel — 1000+1006 (scroll + click; no drag, no hover —
# recommended inside tmux to silence the prompt-row
# "No image in clipboard" spam from hover events)
# buttons — adds 1002 for terminal-side drag selection
# all — adds 1003 for hover (scrollbar paginate-on-hover,
# link mouseenter, etc.)
```
Runtime toggles: