Files
hermes-agent/ui-tui/src/hooks/useVirtualHistory.ts

228 lines
5.9 KiB
TypeScript
Raw Normal View History

2026-04-13 21:20:55 -05:00
import type { ScrollBoxHandle } from '@hermes/ink'
2026-04-14 11:49:32 -05:00
import {
2026-04-17 11:06:25 -05:00
type RefObject,
2026-04-14 11:49:32 -05:00
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
2026-04-17 11:06:25 -05:00
useSyncExternalStore
2026-04-14 11:49:32 -05:00
} from 'react'
2026-04-13 21:20:55 -05:00
const ESTIMATE = 4
const OVERSCAN = 40
const MAX_MOUNTED = 260
const COLD_START = 40
2026-04-14 11:49:32 -05:00
const QUANTUM = OVERSCAN >> 1
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
const FREEZE_RENDERS = 2
2026-04-13 21:20:55 -05:00
const upperBound = (arr: number[], target: number) => {
let lo = 0
let hi = arr.length
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
while (lo < hi) {
const mid = (lo + hi) >> 1
2026-04-14 11:49:32 -05:00
arr[mid]! <= target ? (lo = mid + 1) : (hi = mid)
2026-04-13 21:20:55 -05:00
}
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
return lo
}
export function useVirtualHistory(
scrollRef: RefObject<ScrollBoxHandle | null>,
items: readonly { key: string }[],
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
columns: number,
2026-04-13 21:20:55 -05:00
{ estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {}
) {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const nodes = useRef(new Map<string, unknown>())
2026-04-13 21:20:55 -05:00
const heights = useRef(new Map<string, number>())
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
const refs = useRef(new Map<string, (el: unknown) => void>())
2026-04-13 21:20:55 -05:00
const [ver, setVer] = useState(0)
const [hasScrollRef, setHasScrollRef] = useState(false)
const metrics = useRef({ sticky: true, top: 0, vp: 0 })
// Width change: scale cached heights (not clear — clearing forces a
// pessimistic back-walk mounting ~190 rows at once, each a fresh
// marked.lexer + syntax highlight ≈ 3ms). Freeze mount range for 2
// renders so warm memos survive; skip one measurement so useLayoutEffect
// doesn't poison the scaled cache with pre-resize Yoga heights.
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
const prevColumns = useRef(columns)
const skipMeasurement = useRef(false)
const prevRange = useRef<null | readonly [number, number]>(null)
const freezeRenders = useRef(0)
if (prevColumns.current !== columns && prevColumns.current > 0 && columns > 0) {
const ratio = prevColumns.current / columns
prevColumns.current = columns
for (const [k, h] of heights.current) {
heights.current.set(k, Math.max(1, Math.round(h * ratio)))
}
skipMeasurement.current = true
freezeRenders.current = FREEZE_RENDERS
}
useLayoutEffect(() => {
setHasScrollRef(Boolean(scrollRef.current))
}, [scrollRef])
2026-04-13 21:20:55 -05:00
useSyncExternalStore(
useCallback(
(cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? (() => () => {}),
[hasScrollRef, scrollRef]
),
2026-04-13 21:20:55 -05:00
() => {
const s = scrollRef.current
2026-04-14 11:49:32 -05:00
if (!s) {
return NaN
}
2026-04-13 21:20:55 -05:00
const b = Math.floor(s.getScrollTop() / QUANTUM)
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
return s.isSticky() ? -b - 1 : b
},
() => NaN
)
useEffect(() => {
const keep = new Set(items.map(i => i.key))
let dirty = false
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
for (const k of heights.current.keys()) {
if (!keep.has(k)) {
heights.current.delete(k)
nodes.current.delete(k)
refs.current.delete(k)
dirty = true
}
}
2026-04-14 11:49:32 -05:00
if (dirty) {
setVer(v => v + 1)
}
2026-04-13 21:20:55 -05:00
}, [items])
const offsets = useMemo(() => {
2026-04-15 23:29:00 -05:00
void ver
2026-04-13 21:20:55 -05:00
const out = new Array<number>(items.length + 1).fill(0)
2026-04-14 11:49:32 -05:00
for (let i = 0; i < items.length; i++) {
2026-04-13 21:20:55 -05:00
out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate))
2026-04-14 11:49:32 -05:00
}
2026-04-13 21:20:55 -05:00
return out
}, [estimate, items, ver])
const n = items.length
const total = offsets[n] ?? 0
2026-04-13 21:20:55 -05:00
const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0)
const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0)
const sticky = scrollRef.current?.isSticky() ?? true
// During a freeze, drop the frozen range if items shrank past its start
// (/clear, compaction) — clamping would collapse to an empty mount and
// flash blank. Fall through to the normal path in that case.
const frozenRange =
freezeRenders.current > 0 && prevRange.current && prevRange.current[0] < n ? prevRange.current : null
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
let start = 0
let end = n
2026-04-13 21:20:55 -05:00
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
if (frozenRange) {
start = frozenRange[0]
end = Math.min(frozenRange[1], n)
} else if (n > 0) {
2026-04-13 21:20:55 -05:00
if (vp <= 0) {
start = Math.max(0, n - coldStartCount)
2026-04-13 21:20:55 -05:00
} else {
start = Math.max(0, Math.min(n - 1, upperBound(offsets, Math.max(0, top - overscan)) - 1))
end = Math.max(start + 1, Math.min(n, upperBound(offsets, top + vp + overscan)))
2026-04-13 21:20:55 -05:00
}
}
if (end - start > maxMounted) {
sticky ? (start = Math.max(0, end - maxMounted)) : (end = Math.min(n, start + maxMounted))
2026-04-13 21:20:55 -05:00
}
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
if (freezeRenders.current > 0) {
freezeRenders.current--
} else {
prevRange.current = [start, end]
}
2026-04-13 21:20:55 -05:00
const measureRef = useCallback((key: string) => {
let fn = refs.current.get(key)
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
if (!fn) {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key))
2026-04-13 21:20:55 -05:00
refs.current.set(key, fn)
}
2026-04-14 11:49:32 -05:00
2026-04-13 21:20:55 -05:00
return fn
}, [])
useLayoutEffect(() => {
let dirty = false
2026-04-14 11:49:32 -05:00
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
if (skipMeasurement.current) {
skipMeasurement.current = false
} else {
for (let i = start; i < end; i++) {
const k = items[i]?.key
2026-04-14 11:49:32 -05:00
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
if (!k) {
continue
}
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0)
2026-04-14 11:49:32 -05:00
perf(tui): debounce resize RPC + column-aware useVirtualHistory VSCode panel-drag fires 20+ SIGWINCHes/sec, each previously triggering an unthrottled `terminal.resize` gateway RPC and a full transcript re-virtualization with stale per-row height cache. ## Changes ### gateway RPC debounce (ui-tui/src/app/useMainApp.ts) - `terminal.resize` RPC now trailing-debounced at 100 ms. React `cols` state stays synchronous (needed for Yoga / in-process rendering), only the round-trip to Python coalesces. Prevents gateway flood during panel-drag / tmux-pane-resize. ### column-aware useVirtualHistory (ui-tui/src/hooks/useVirtualHistory.ts) - New required `columns` param, plumbed through from useMainApp. - On column change: scale every cached row height by `oldCols/newCols` (Math.max 1, Math.round) instead of clearing. Clearing forces a pessimistic back-walk that mounts ~190 rows at once (viewport + 2x overscan at 1-row estimate), each a fresh marked.lexer + syntax highlight ≈ 3 ms — ~600 ms React commit block. Scaled heights keep the back-walk tight. - `freezeRenders=2`: reuse pre-resize mount range for 2 renders so already-mounted MessageRows keep their warm useMemo results. Without this the first post-resize render would unmount + remount most rows (pessimistic coverage) = visible flash + 150 ms+ freeze. - `skipMeasurement` flag: first post-resize useLayoutEffect would read PRE-resize Yoga heights (Yoga's stored values are still from the frame before this render's calculateLayout with new width) and poison the scaled cache. Skip the measurement loop for that one render; next render's Yoga is correct. ## Validation - tsc `--noEmit` clean - eslint clean on touched files - `vitest run`: 15 files / 102 tests passing The renderer-level resize patterns (sync-dim-capture + microtask- coalesced React commit, atomic BSU/ESU erase-before-paint, mouse- tracking reassert) already live in hermes-ink's own `handleResize`; this patch adds the matching app-layer hygiene.
2026-04-20 18:42:15 -05:00
if (h > 0 && heights.current.get(k) !== h) {
heights.current.set(k, h)
dirty = true
}
2026-04-13 21:20:55 -05:00
}
}
2026-04-14 11:49:32 -05:00
const s = scrollRef.current
if (s) {
const next = {
sticky: s.isSticky(),
top: Math.max(0, s.getScrollTop() + s.getPendingDelta()),
vp: Math.max(0, s.getViewportHeight())
}
if (
next.sticky !== metrics.current.sticky ||
next.top !== metrics.current.top ||
next.vp !== metrics.current.vp
) {
metrics.current = next
dirty = true
}
}
2026-04-14 11:49:32 -05:00
if (dirty) {
setVer(v => v + 1)
}
}, [end, hasScrollRef, items, scrollRef, start])
2026-04-13 21:20:55 -05:00
return {
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
bottomSpacer: Math.max(0, total - (offsets[end] ?? total)),
2026-04-13 21:20:55 -05:00
end,
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
measureRef,
2026-04-14 11:49:32 -05:00
offsets,
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
start,
topSpacer: offsets[start] ?? 0
2026-04-13 21:20:55 -05:00
}
}
refactor(tui): /clean pass across ui-tui — 49 files, −217 LOC Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57
2026-04-16 22:32:53 -05:00
interface MeasuredNode {
yogaNode?: { getComputedHeight?: () => number } | null
}