hermes-agent/ui-tui/src/app/useConfigSync.ts
Vesper (on behalf of Director) 1c8ce33d51 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
2026-04-24 20:32:12 -07:00

112 lines
3.2 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
ConfigFullResponse,
ConfigMtimeResponse,
ReloadMcpResponse,
VoiceToggleResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js'
import type { StatusBarMode } from './interfaces.js'
import { turnController } from './turnController.js'
import { patchUiState } from './uiStore.js'
const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
bottom: 'bottom',
off: 'off',
on: 'top',
top: 'top'
}
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
const MTIME_POLL_MS = 5000
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
gw: GatewayClient,
method: string,
params: Record<string, unknown> = {}
): Promise<null | T> => {
try {
return asRpcResult<T>(await gw.request<T>(method, params))
} catch {
return null
}
}
export const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => {
const d = cfg?.config?.display ?? {}
setBell(!!d.bell_on_complete)
patchUiState({
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,
statusBar: normalizeStatusBar(d.tui_statusbar),
streaming: d.streaming !== false
})
}
export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
useEffect(() => {
if (!sid) {
return
}
quietRpc<VoiceToggleResponse>(gw, 'voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled))
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
}, [gw, setBellOnComplete, setVoiceEnabled, sid])
useEffect(() => {
if (!sid) {
return
}
const id = setInterval(() => {
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
const next = Number(r?.mtime ?? 0)
if (!mtimeRef.current) {
if (next) {
mtimeRef.current = next
}
return
}
if (!next || next === mtimeRef.current) {
return
}
mtimeRef.current = next
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete))
})
}, MTIME_POLL_MS)
return () => clearInterval(id)
}, [gw, setBellOnComplete, sid])
}
export interface UseConfigSyncOptions {
gw: GatewayClient
setBellOnComplete: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
sid: null | string
}