hermes-agent/ui-tui/src/app/useConfigSync.ts
Teknium 2517917de3
fix(cli): restore fallback paste collapse + handle long single-line pastes (#32447)
Follow-up to #32087 after community report from @ethernet that 8000-char
single-line pastes get dumped raw into the input box.

A) Fallback regression revert
   paste_collapse_threshold_fallback default: 0 -> 5
   #32087 disabled the fallback handler by default. The fallback path
   has been always-on with line_count >= 5 since #3065 (March 2026);
   the previous shape was the salvaged contributor's design and didn't
   match pre-existing behavior for terminals without bracketed paste
   support (Windows terminals, some SSH setups). Restoring the original
   on-by-default.

B) Long single-line paste guard
   New config key: paste_collapse_char_threshold (default 2000)
   Bracketed-paste handler and fallback handler now BOTH collapse when
   line count >= line threshold OR total char length >= char threshold.
   Catches the case ethernet hit: ~8000 chars of minified JSON / log
   output on a single line dumped raw into the buffer.
   TUI mirrors the same config via uiStore.pasteCollapseChars.
   Set 0 to disable.

Defaults verified:
  paste_collapse_threshold: 5
  paste_collapse_threshold_fallback: 5
  paste_collapse_char_threshold: 2000

Tests:
  tests/hermes_cli/test_config.py: 87/87 pass
  ui-tui useConfigSync.test.ts: 34/34 pass
  ui-tui useComposerState.test.ts: 9/9 pass
  tsc: 0 new errors in touched files
2026-05-25 23:49:01 -07:00

288 lines
9 KiB
TypeScript

import type { MouseTrackingMode } from '@hermes/ink'
import { useEffect, useRef } from 'react'
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
import type { GatewayClient } from '../gatewayClient.js'
import type {
ConfigFullResponse,
ConfigMtimeResponse,
ReloadMcpResponse
} from '../gatewayTypes.js'
import {
DEFAULT_VOICE_RECORD_KEY,
type ParsedVoiceRecordKey,
parseVoiceRecordKey
} from '../lib/platform.js'
import { asRpcResult } from '../lib/rpc.js'
import {
type BusyInputMode,
DEFAULT_INDICATOR_STYLE,
INDICATOR_STYLES,
type IndicatorStyle,
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 BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
// TUI defaults to `queue` even though the framework default
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
// TUI you're typically authoring the next prompt while the agent is
// still streaming, and an unintended interrupt loses work. Set
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
// opt out per-config; CLI / messaging adapters keep their `interrupt`
// default unchanged.
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
if (typeof raw !== 'string') {
return TUI_BUSY_DEFAULT
}
const v = raw.trim().toLowerCase() as BusyInputMode
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
}
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
if (typeof raw !== 'string') {
return DEFAULT_INDICATOR_STYLE
}
const v = raw.trim().toLowerCase() as IndicatorStyle
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
}
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)
// `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 'off'
}
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
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
}
}
const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => {
const raw = cfg?.config?.voice?.record_key
return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY
}
const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number => {
if (!cfg?.config) return 5
const raw = cfg.config.paste_collapse_threshold
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n >= 0) return n
}
return 5
}
const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => {
if (!cfg?.config) return 2000
const raw = cfg.config.paste_collapse_char_threshold
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return Math.round(raw)
if (typeof raw === 'string') {
const n = parseInt(raw, 10)
if (Number.isFinite(n) && n >= 0) return n
}
return 2000
}
/** Fetch ``config.get full`` and fan the result through ``applyDisplay``.
*
* Extracted so the mtime-reload path can be exercised by the test
* suite without a React runtime (Copilot round-12 review on #19835).
* Both the initial hydration and the mtime poller use this shared
* helper, so a regression in the fetch/apply plumbing now fails the
* useConfigSync tests instead of only being visible at runtime. */
export async function hydrateFullConfig(
gw: GatewayClient,
setBell: (v: boolean) => void,
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
): Promise<ConfigFullResponse | null> {
const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
applyDisplay(cfg, setBell, setVoiceRecordKey)
return cfg
}
export const applyDisplay = (
cfg: ConfigFullResponse | null,
setBell: (v: boolean) => void,
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
) => {
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
// binding after one transient RPC error until the next config edit
// (Copilot round-8 review on #19835). The mtime-poll loop advances
// ``mtimeRef`` before this call, so staying silent on null preserves
// the last-good state and lets the next successful poll refresh it.
if (setVoiceRecordKey && cfg) {
setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg))
}
patchUiState({
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
compact: !!d.tui_compact,
detailsMode: resolveDetailsMode(d),
detailsModeCommandOverride: false,
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
inlineDiffs: d.inline_diffs !== false,
mouseTracking: normalizeMouseTracking(d),
pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg),
pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg),
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,
setVoiceRecordKey,
sid
}: UseConfigSyncOptions) {
const mtimeRef = useRef(0)
useEffect(() => {
if (!sid) {
return
}
// Keep startup cheap: voice.toggle status probes optional audio/STT deps and
// can run long enough to delay prompt.submit on the single stdio RPC pipe.
// Environment flags are enough to initialize the UI bit; the heavier status
// check still runs when the user opens /voice.
setVoiceEnabled(process.env.HERMES_VOICE === '1')
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
mtimeRef.current = Number(r?.mtime ?? 0)
})
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
}, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, 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, confirm: true }).then(
r => r && turnController.pushActivity('MCP reloaded after config change')
)
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
})
}, MTIME_POLL_MS)
return () => clearInterval(id)
}, [gw, setBellOnComplete, setVoiceRecordKey, sid])
}
export interface UseConfigSyncOptions {
gw: GatewayClient
setBellOnComplete: (v: boolean) => void
setVoiceEnabled: (v: boolean) => void
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
sid: null | string
}