fix(tui): proactive mouse disable on ConPTY + /mouse toggle command

On Windows WSL2, ConPTY implicitly enables mouse event injection when
the alternate screen buffer (DEC 1049) is entered, causing raw escape
sequences to appear in the transcript as ghost characters.

Fix (two parts):
1. ConPTY fix: send DISABLE_MOUSE_TRACKING immediately after entering
   alt screen when mouse tracking is off (AlternateScreen.tsx)
2. Runtime toggle: add /mouse [on|off|toggle] slash command with config
   persistence (display.tui_mouse) so users can manage this at runtime

The env var HERMES_TUI_DISABLE_MOUSE continues to work as the initial
default, but can now be overridden via /mouse and persisted to config.

Closes: upstream ConPTY mouse injection issue
Credits: OutThisLife / PR #13716 for the toggle concept
This commit is contained in:
Vesper (on behalf of Director) 2026-04-24 20:32:12 -07:00
parent 2182de55bb
commit 1c8ce33d51
9 changed files with 70 additions and 3 deletions

View file

@ -2789,6 +2789,23 @@ def _(rid, params: dict) -> dict:
_write_config_key("display.tui_statusbar", nv) _write_config_key("display.tui_statusbar", nv)
return _ok(rid, {"key": key, "value": nv}) return _ok(rid, {"key": key, "value": nv})
if key == "mouse":
raw = str(value or "").strip().lower()
display = _load_cfg().get("display") if isinstance(_load_cfg().get("display"), dict) else {}
current = bool(display.get("tui_mouse", True))
if raw in ("", "toggle"):
nv = not current
elif raw == "on":
nv = True
elif raw == "off":
nv = False
else:
return _err(rid, 4002, f"unknown mouse value: {value}")
_write_config_key("display.tui_mouse", nv)
return _ok(rid, {"key": key, "value": "on" if nv else "off"})
if key in ("prompt", "personality", "skin"): if key in ("prompt", "personality", "skin"):
try: try:
cfg = _load_cfg() cfg = _load_cfg()
@ -2917,6 +2934,10 @@ def _(rid, params: dict) -> dict:
display.get("tui_statusbar", "top") if isinstance(display, dict) else "top" display.get("tui_statusbar", "top") if isinstance(display, dict) else "top"
) )
return _ok(rid, {"value": _coerce_statusbar(raw)}) return _ok(rid, {"value": _coerce_statusbar(raw)})
if key == "mouse":
display = _load_cfg().get("display")
on = display.get("tui_mouse", True) if isinstance(display, dict) else True
return _ok(rid, {"value": "on" if on else "off"})
if key == "mtime": if key == "mtime":
cfg_path = _hermes_home / "config.yaml" cfg_path = _hermes_home / "config.yaml"
try: try:

View file

@ -53,7 +53,7 @@ export function AlternateScreen(t0: Props) {
} }
writeRaw( writeRaw(
ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : '') ENTER_ALT_SCREEN + ERASE_SCROLLBACK + ERASE_SCREEN + CURSOR_HOME + (mouseTracking ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
) )
ink?.setAltScreenActive(true, mouseTracking) ink?.setAltScreenActive(true, mouseTracking)

View file

@ -1121,6 +1121,23 @@ export default class Ink {
this.repaint() 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.
*/
setAltScreenMouseTracking(enabled: boolean): void {
if (this.altScreenMouseTracking === enabled) {
return
}
this.altScreenMouseTracking = enabled
if (this.altScreenActive) {
this.options.stdout.write(enabled ? ENABLE_MOUSE_TRACKING : DISABLE_MOUSE_TRACKING)
}
}
get isAltScreenActive(): boolean { get isAltScreenActive(): boolean {
return this.altScreenActive return this.altScreenActive
} }

View file

@ -1,18 +1,21 @@
import { useStore } from '@nanostores/react'
import { GatewayProvider } from './app/gatewayContext.js' import { GatewayProvider } from './app/gatewayContext.js'
import { useMainApp } from './app/useMainApp.js' import { useMainApp } from './app/useMainApp.js'
import { $uiState } from './app/uiStore.js'
import { AppLayout } from './components/appLayout.js' import { AppLayout } from './components/appLayout.js'
import { MOUSE_TRACKING } from './config/env.js'
import type { GatewayClient } from './gatewayClient.js' import type { GatewayClient } from './gatewayClient.js'
export function App({ gw }: { gw: GatewayClient }) { export function App({ gw }: { gw: GatewayClient }) {
const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw) const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw)
const { mouseTracking } = useStore($uiState)
return ( return (
<GatewayProvider value={gateway}> <GatewayProvider value={gateway}>
<AppLayout <AppLayout
actions={appActions} actions={appActions}
composer={appComposer} composer={appComposer}
mouseTracking={MOUSE_TRACKING} mouseTracking={mouseTracking}
progress={appProgress} progress={appProgress}
status={appStatus} status={appStatus}
transcript={appTranscript} transcript={appTranscript}

View file

@ -88,6 +88,7 @@ export interface UiState {
detailsMode: DetailsMode detailsMode: DetailsMode
info: null | SessionInfo info: null | SessionInfo
inlineDiffs: boolean inlineDiffs: boolean
mouseTracking: boolean
sections: SectionVisibility sections: SectionVisibility
showCost: boolean showCost: boolean
showReasoning: boolean showReasoning: boolean

View file

@ -84,6 +84,27 @@ export const coreCommands: SlashCommand[] = [
run: (_arg, ctx) => ctx.session.die() run: (_arg, ctx) => ctx.session.die()
}, },
{
aliases: ['scroll'],
help: 'toggle mouse/wheel tracking [on|off|toggle]',
name: 'mouse',
run: (arg, ctx) => {
const current = ctx.ui.mouseTracking
const next = flagFromArg(arg, current)
if (next === null) {
return ctx.transcript.sys('usage: /mouse [on|off|toggle]')
}
patchUiState({ mouseTracking: next })
ctx.gateway
.rpc<ConfigSetResponse>('config.set', { key: 'mouse', value: next ? 'on' : 'off' })
.catch(() => {})
queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`))
}
},
{ {
aliases: ['new'], aliases: ['new'],
help: 'start a new session', help: 'start a new session',

View file

@ -2,6 +2,7 @@ import { atom } from 'nanostores'
import { ZERO } from '../domain/usage.js' import { ZERO } from '../domain/usage.js'
import { DEFAULT_THEME } from '../theme.js' import { DEFAULT_THEME } from '../theme.js'
import { MOUSE_TRACKING } from '../config/env.js'
import type { UiState } from './interfaces.js' import type { UiState } from './interfaces.js'
@ -12,6 +13,7 @@ const buildUiState = (): UiState => ({
detailsMode: 'collapsed', detailsMode: 'collapsed',
info: null, info: null,
inlineDiffs: true, inlineDiffs: true,
mouseTracking: MOUSE_TRACKING,
sections: {}, sections: {},
showCost: false, showCost: false,
showReasoning: false, showReasoning: false,

View file

@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea
compact: !!d.tui_compact, compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d), detailsMode: resolveDetailsMode(d),
inlineDiffs: d.inline_diffs !== false, inlineDiffs: d.inline_diffs !== false,
mouseTracking: d.tui_mouse !== false,
sections: resolveSections(d.sections), sections: resolveSections(d.sections),
showCost: !!d.show_cost, showCost: !!d.show_cost,
showReasoning: !!d.show_reasoning, showReasoning: !!d.show_reasoning,

View file

@ -61,6 +61,7 @@ export interface ConfigDisplayConfig {
streaming?: boolean streaming?: boolean
thinking_mode?: string thinking_mode?: string
tui_compact?: boolean tui_compact?: boolean
tui_mouse?: boolean
tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean
} }