From 1c8ce33d51088ada132f15c6fdf34a6b7247ba5d Mon Sep 17 00:00:00 2001 From: "Vesper (on behalf of Director)" Date: Fri, 24 Apr 2026 20:32:12 -0700 Subject: [PATCH] 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 --- tui_gateway/server.py | 21 +++++++++++++++++++ .../src/ink/components/AlternateScreen.tsx | 2 +- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 17 +++++++++++++++ ui-tui/src/app.tsx | 7 +++++-- ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/slash/commands/core.ts | 21 +++++++++++++++++++ ui-tui/src/app/uiStore.ts | 2 ++ ui-tui/src/app/useConfigSync.ts | 1 + ui-tui/src/gatewayTypes.ts | 1 + 9 files changed, 70 insertions(+), 3 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7bc0fb2e09..891b6128e3 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2789,6 +2789,23 @@ def _(rid, params: dict) -> dict: _write_config_key("display.tui_statusbar", 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"): try: cfg = _load_cfg() @@ -2917,6 +2934,10 @@ def _(rid, params: dict) -> dict: display.get("tui_statusbar", "top") if isinstance(display, dict) else "top" ) 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": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index f135d70c68..f5fb660bed 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -53,7 +53,7 @@ export function AlternateScreen(t0: Props) { } 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) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 8e43f60ea6..7422cf4637 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1121,6 +1121,23 @@ export default class Ink { 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 { return this.altScreenActive } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 631bd7a350..522e982958 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,18 +1,21 @@ +import { useStore } from '@nanostores/react' + import { GatewayProvider } from './app/gatewayContext.js' import { useMainApp } from './app/useMainApp.js' +import { $uiState } from './app/uiStore.js' import { AppLayout } from './components/appLayout.js' -import { MOUSE_TRACKING } from './config/env.js' import type { GatewayClient } from './gatewayClient.js' export function App({ gw }: { gw: GatewayClient }) { const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw) + const { mouseTracking } = useStore($uiState) return ( 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('config.set', { key: 'mouse', value: next ? 'on' : 'off' }) + .catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`mouse tracking ${next ? 'on' : 'off'}`)) + } + }, + { aliases: ['new'], help: 'start a new session', diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 0b3fd97402..260b26ab5a 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -2,6 +2,7 @@ import { atom } from 'nanostores' import { ZERO } from '../domain/usage.js' import { DEFAULT_THEME } from '../theme.js' +import { MOUSE_TRACKING } from '../config/env.js' import type { UiState } from './interfaces.js' @@ -12,6 +13,7 @@ const buildUiState = (): UiState => ({ detailsMode: 'collapsed', info: null, inlineDiffs: true, + mouseTracking: MOUSE_TRACKING, sections: {}, showCost: false, showReasoning: false, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index cb98eed819..3ceb8c635a 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -46,6 +46,7 @@ export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolea compact: !!d.tui_compact, detailsMode: resolveDetailsMode(d), inlineDiffs: d.inline_diffs !== false, + mouseTracking: d.tui_mouse !== false, sections: resolveSections(d.sections), showCost: !!d.show_cost, showReasoning: !!d.show_reasoning, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 91fced32a8..50ef505e61 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -61,6 +61,7 @@ export interface ConfigDisplayConfig { streaming?: boolean thinking_mode?: string tui_compact?: boolean + tui_mouse?: boolean tui_statusbar?: 'bottom' | 'off' | 'on' | 'top' | boolean }