perf(tui): cache stringWidth/wrapText/sliceAnsi + skip-slice when line fits clip
CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three
hot loops in the per-frame render path:
Output.get() per-frame walk: 24% total
└─ sliceAnsi(line, from, to) per write: 18% total
stringWidth(line) chain (cached + JS): 14% total
All three were re-doing identical work every frame: same string → same
clipped slice → same width.
Fixes:
1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path
skips the cache (inline scan beats Map.get for short ASCII, the >90%
case). String.charCodeAt scan up to 64 chars is cheaper than the regex
fallback.
2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi
is pure and the same content reflows identically every frame.
3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the
end-defined hot path used by Output.get().
4. Skip the slice entirely in Output.get() when the line already fits the
clip box (startsBefore=false && endsAfter=false). Most transcript lines
never exceed their container width, and tokenizing them just to slice
(line, 0, width) was pure overhead. This single fast-path drops
sliceAnsi from 18% → ~0% in the profile.
Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20,
SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16
lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS
window still render in full so reading-zone behavior is unchanged.
Validation, real-user CPU profile, page-up scroll on 11k-line session:
Output.get() self-time: 24% → 0.3%
sliceAnsi total: 18% → not in top 25
stringWidth family: 14% → ~3%
idle: 60.7% → 77.3%
Frame timings (synthetic page-up profile harness):
dur p95: ~10ms → 4.87ms
dur p99: 25ms+ → 12.80ms
yoga p99: ~20ms → 1.87ms
The remaining CPU in the profile is Yoga layoutNode + React commit,
which is the irreducible work for this UI tree size.
2026-04-26 19:28:09 -05:00
|
|
|
import {
|
|
|
|
|
HISTORY_RENDER_MAX_CHARS,
|
|
|
|
|
HISTORY_RENDER_MAX_LINES,
|
|
|
|
|
LIVE_RENDER_MAX_CHARS,
|
|
|
|
|
LIVE_RENDER_MAX_LINES,
|
|
|
|
|
THINKING_COT_MAX
|
|
|
|
|
} from '../config/limits.js'
|
2026-04-26 14:03:36 -05:00
|
|
|
import { VERBS } from '../content/verbs.js'
|
2026-04-12 20:08:12 -05:00
|
|
|
import type { ThinkingMode } from '../types.js'
|
2026-04-02 20:39:52 -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
|
|
|
const ESC = String.fromCharCode(27)
|
|
|
|
|
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
|
|
|
|
|
const WS_RE = /\s+/g
|
2026-04-05 18:50:41 -05:00
|
|
|
|
|
|
|
|
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
|
|
|
|
|
|
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
|
|
|
export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`)
|
2026-04-05 18:50:41 -05:00
|
|
|
|
2026-04-06 18:38:13 -05:00
|
|
|
const renderEstimateLine = (line: string) => {
|
|
|
|
|
const trimmed = line.trim()
|
|
|
|
|
|
|
|
|
|
if (trimmed.startsWith('|')) {
|
|
|
|
|
return trimmed
|
|
|
|
|
.split('|')
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(cell => cell.trim())
|
|
|
|
|
.join(' ')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return line
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]')
|
2026-04-06 18:38:13 -05:00
|
|
|
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
|
|
|
|
|
.replace(/`([^`]+)`/g, '$1')
|
|
|
|
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
2026-04-21 15:32:59 -03:00
|
|
|
.replace(/(?<!\w)__(.+?)__(?!\w)/g, '$1')
|
2026-04-06 18:38:13 -05:00
|
|
|
.replace(/\*(.+?)\*/g, '$1')
|
2026-04-21 15:32:59 -03:00
|
|
|
.replace(/(?<!\w)_(.+?)_(?!\w)/g, '$1')
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/~~(.+?)~~/g, '$1')
|
|
|
|
|
.replace(/==(.+?)==/g, '$1')
|
|
|
|
|
.replace(/\[\^([^\]]+)\]/g, '[$1]')
|
|
|
|
|
.replace(/^#{1,6}\s+/, '')
|
|
|
|
|
.replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, (_m, checked: string) => `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `)
|
|
|
|
|
.replace(/^\s*[-*+]\s+/, '• ')
|
2026-04-06 18:38:13 -05:00
|
|
|
.replace(/^\s*(\d+)\.\s+/, '$1. ')
|
2026-04-11 17:15:36 -05:00
|
|
|
.replace(/^\s*(?:>\s*)+/, '│ ')
|
2026-04-06 18:38:13 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:39:52 -05:00
|
|
|
export const compactPreview = (s: string, max: 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 one = s.replace(WS_RE, ' ').trim()
|
2026-04-02 20:39:52 -05:00
|
|
|
|
|
|
|
|
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 14:49:10 -05:00
|
|
|
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
|
|
|
|
|
|
|
|
|
|
export const edgePreview = (s: string, head = 16, tail = 28) => {
|
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 one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]')
|
2026-04-13 14:49:10 -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
|
|
|
return !one
|
|
|
|
|
? ''
|
|
|
|
|
: one.length <= head + tail + 4
|
|
|
|
|
? one
|
|
|
|
|
: `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
|
2026-04-13 14:49:10 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 22:39:03 -05:00
|
|
|
export const pasteTokenLabel = (text: string, lineCount: number) => {
|
2026-04-13 14:49:10 -05:00
|
|
|
const preview = edgePreview(text)
|
|
|
|
|
|
|
|
|
|
if (!preview) {
|
2026-04-13 22:39:03 -05:00
|
|
|
return `[[ [${fmtK(lineCount)} lines] ]]`
|
2026-04-13 14:49:10 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [head = preview, tail = ''] = preview.split('.. ', 2)
|
|
|
|
|
|
2026-04-13 22:39:03 -05:00
|
|
|
return tail
|
|
|
|
|
? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]`
|
|
|
|
|
: `[[ ${preview} [${fmtK(lineCount)} lines] ]]`
|
2026-04-13 14:49:10 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 14:03:36 -05:00
|
|
|
const THINKING_STATUS_RE = new RegExp(`^(?:${VERBS.join('|')})\\.{0,3}$`, 'i')
|
|
|
|
|
const THINKING_STATUS_CHUNK_RE = new RegExp(`[^A-Za-z\n]+\\s*(?:${VERBS.join('|')})\\.{0,3}\\s*`, 'giu')
|
2026-04-26 13:59:56 -05:00
|
|
|
|
2026-04-26 15:38:18 -05:00
|
|
|
const normalizeThinkingParagraphs = (text: string) =>
|
|
|
|
|
text
|
|
|
|
|
.replace(/([^\n])(?=\*\*[^*\n][^\n]*?\*\*)/g, '$1\n\n')
|
|
|
|
|
.replace(/\n{3,}/g, '\n\n')
|
2026-04-26 13:59:56 -05:00
|
|
|
.trim()
|
|
|
|
|
|
2026-04-26 15:38:18 -05:00
|
|
|
export const cleanThinkingText = (reasoning: string) =>
|
|
|
|
|
normalizeThinkingParagraphs(
|
|
|
|
|
reasoning
|
|
|
|
|
.split('\n')
|
|
|
|
|
.map(line => line.replace(THINKING_STATUS_CHUNK_RE, '').trim())
|
|
|
|
|
.filter(line => line && !THINKING_STATUS_RE.test(line.replace(/\.\.\.$/, '').trim()))
|
|
|
|
|
.join('\n')
|
|
|
|
|
)
|
|
|
|
|
|
2026-04-16 12:18:56 -05:00
|
|
|
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
|
2026-04-26 13:59:56 -05:00
|
|
|
const raw = cleanThinkingText(reasoning)
|
2026-04-12 20:08:12 -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
|
|
|
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
|
2026-04-12 20:08:12 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 15:16:12 -05:00
|
|
|
export const boundedLiveRenderText = (
|
|
|
|
|
text: string,
|
|
|
|
|
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
|
perf(tui): cache stringWidth/wrapText/sliceAnsi + skip-slice when line fits clip
CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three
hot loops in the per-frame render path:
Output.get() per-frame walk: 24% total
└─ sliceAnsi(line, from, to) per write: 18% total
stringWidth(line) chain (cached + JS): 14% total
All three were re-doing identical work every frame: same string → same
clipped slice → same width.
Fixes:
1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path
skips the cache (inline scan beats Map.get for short ASCII, the >90%
case). String.charCodeAt scan up to 64 chars is cheaper than the regex
fallback.
2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi
is pure and the same content reflows identically every frame.
3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the
end-defined hot path used by Output.get().
4. Skip the slice entirely in Output.get() when the line already fits the
clip box (startsBefore=false && endsAfter=false). Most transcript lines
never exceed their container width, and tokenizing them just to slice
(line, 0, width) was pure overhead. This single fast-path drops
sliceAnsi from 18% → ~0% in the profile.
Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20,
SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16
lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS
window still render in full so reading-zone behavior is unchanged.
Validation, real-user CPU profile, page-up scroll on 11k-line session:
Output.get() self-time: 24% → 0.3%
sliceAnsi total: 18% → not in top 25
stringWidth family: 14% → ~3%
idle: 60.7% → 77.3%
Frame timings (synthetic page-up profile harness):
dur p95: ~10ms → 4.87ms
dur p99: 25ms+ → 12.80ms
yoga p99: ~20ms → 1.87ms
The remaining CPU in the profile is Yoga layoutNode + React commit,
which is the irreducible work for this UI tree size.
2026-04-26 19:28:09 -05:00
|
|
|
) => boundedRenderText(text, 'showing live tail', { maxChars, maxLines })
|
|
|
|
|
|
|
|
|
|
export const boundedHistoryRenderText = (
|
|
|
|
|
text: string,
|
|
|
|
|
{ maxChars = HISTORY_RENDER_MAX_CHARS, maxLines = HISTORY_RENDER_MAX_LINES } = {}
|
|
|
|
|
) => boundedRenderText(text, 'showing tail', { maxChars, maxLines })
|
|
|
|
|
|
|
|
|
|
const boundedRenderText = (
|
|
|
|
|
text: string,
|
|
|
|
|
labelPrefix: string,
|
|
|
|
|
{ maxChars, maxLines }: { maxChars: number; maxLines: number }
|
2026-04-26 15:16:12 -05:00
|
|
|
) => {
|
|
|
|
|
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let start = 0
|
|
|
|
|
let idx = text.length
|
|
|
|
|
|
|
|
|
|
for (let seen = 0; seen < maxLines && idx > 0; seen++) {
|
|
|
|
|
idx = text.lastIndexOf('\n', idx - 1)
|
|
|
|
|
start = idx < 0 ? 0 : idx + 1
|
|
|
|
|
|
|
|
|
|
if (idx < 0) {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lineStart = start
|
|
|
|
|
start = Math.max(lineStart, text.length - maxChars)
|
|
|
|
|
|
|
|
|
|
if (start > lineStart) {
|
|
|
|
|
const nextBreak = text.indexOf('\n', start)
|
|
|
|
|
|
|
|
|
|
if (nextBreak >= 0 && nextBreak < text.length - 1) {
|
|
|
|
|
start = nextBreak + 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tail = text.slice(start).trimStart()
|
|
|
|
|
const omittedLines = countNewlines(text, start)
|
|
|
|
|
const omittedChars = Math.max(0, text.length - tail.length)
|
|
|
|
|
|
|
|
|
|
const label =
|
|
|
|
|
omittedLines > 0
|
perf(tui): cache stringWidth/wrapText/sliceAnsi + skip-slice when line fits clip
CPU profile (Apr 2026, real-user scroll on 11k-line session) showed three
hot loops in the per-frame render path:
Output.get() per-frame walk: 24% total
└─ sliceAnsi(line, from, to) per write: 18% total
stringWidth(line) chain (cached + JS): 14% total
All three were re-doing identical work every frame: same string → same
clipped slice → same width.
Fixes:
1. Memoize stringWidth (8k-entry LRU) for non-ASCII strings; ASCII fast-path
skips the cache (inline scan beats Map.get for short ASCII, the >90%
case). String.charCodeAt scan up to 64 chars is cheaper than the regex
fallback.
2. Memoize wrapText (4k-entry LRU keyed by maxWidth|wrapType|text) — wrapAnsi
is pure and the same content reflows identically every frame.
3. Memoize sliceAnsi (4k-entry LRU keyed by start|end|str) for the
end-defined hot path used by Output.get().
4. Skip the slice entirely in Output.get() when the line already fits the
clip box (startsBefore=false && endsAfter=false). Most transcript lines
never exceed their container width, and tokenizing them just to slice
(line, 0, width) was pure overhead. This single fast-path drops
sliceAnsi from 18% → ~0% in the profile.
Also tighten virtualization constants (MAX_MOUNTED 260→120, OVERSCAN 40→20,
SLIDE_STEP 25→12) and cap historical-message render at 800 chars / 16
lines via HISTORY_RENDER_MAX_*; messages inside the FULL_RENDER_TAIL_ITEMS
window still render in full so reading-zone behavior is unchanged.
Validation, real-user CPU profile, page-up scroll on 11k-line session:
Output.get() self-time: 24% → 0.3%
sliceAnsi total: 18% → not in top 25
stringWidth family: 14% → ~3%
idle: 60.7% → 77.3%
Frame timings (synthetic page-up profile harness):
dur p95: ~10ms → 4.87ms
dur p99: 25ms+ → 12.80ms
yoga p99: ~20ms → 1.87ms
The remaining CPU in the profile is Yoga layoutNode + React commit,
which is the irreducible work for this UI tree size.
2026-04-26 19:28:09 -05:00
|
|
|
? `[${labelPrefix}; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
|
|
|
|
|
: `[${labelPrefix}; omitted ${fmtK(omittedChars)} chars]\n`
|
2026-04-26 15:16:12 -05:00
|
|
|
|
|
|
|
|
return `${label}${tail}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const countNewlines = (text: string, end: number) => {
|
|
|
|
|
let count = 0
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < end; i++) {
|
|
|
|
|
if (text.charCodeAt(i) === 10) {
|
|
|
|
|
count++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return count
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 12:54:48 -05:00
|
|
|
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
|
|
|
|
|
|
2026-04-12 17:39:17 -05:00
|
|
|
export const toolTrailLabel = (name: string) =>
|
|
|
|
|
name
|
|
|
|
|
.split('_')
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.map(p => p[0]!.toUpperCase() + p.slice(1))
|
|
|
|
|
.join(' ') || name
|
2026-04-11 13:14:32 -05:00
|
|
|
|
2026-04-12 17:39:17 -05:00
|
|
|
export const formatToolCall = (name: string, context = '') => {
|
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 label = toolTrailLabel(name)
|
2026-04-12 17:39:17 -05:00
|
|
|
const preview = compactPreview(context, 64)
|
|
|
|
|
|
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
|
|
|
return preview ? `${label}("${preview}")` : label
|
2026-04-12 17:39:17 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 15:16:12 -05:00
|
|
|
export const buildToolTrailLine = (
|
|
|
|
|
name: string,
|
|
|
|
|
context: string,
|
|
|
|
|
error?: boolean,
|
|
|
|
|
note?: string,
|
|
|
|
|
duration?: number
|
|
|
|
|
) => {
|
2026-04-12 17:39:17 -05:00
|
|
|
const detail = compactPreview(note ?? '', 72)
|
2026-04-26 15:16:12 -05:00
|
|
|
const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : ''
|
2026-04-12 17:39:17 -05:00
|
|
|
|
2026-04-26 15:16:12 -05:00
|
|
|
return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}`
|
2026-04-11 06:35:00 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-09 18:33:25 -05:00
|
|
|
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
|
|
|
|
|
|
2026-04-12 17:39:17 -05:00
|
|
|
export const parseToolTrailResultLine = (line: string) => {
|
|
|
|
|
if (!isToolTrailResultLine(line)) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const mark = line.endsWith(' ✗') ? '✗' : '✓'
|
|
|
|
|
const body = line.slice(0, -2)
|
|
|
|
|
const [call, detail] = body.split(' :: ', 2)
|
|
|
|
|
|
|
|
|
|
if (detail != null) {
|
|
|
|
|
return { call, detail, mark }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const legacy = body.indexOf(': ')
|
|
|
|
|
|
|
|
|
|
if (legacy > 0) {
|
|
|
|
|
return { call: body.slice(0, legacy), detail: body.slice(legacy + 2), mark }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { call: body, detail: '', mark }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 15:16:12 -05:00
|
|
|
export const splitToolDuration = (call: string) => {
|
|
|
|
|
const match = call.match(/^(.*?)( \(\d+(?:\.\d)?s\))$/)
|
|
|
|
|
|
|
|
|
|
return match ? { label: match[1]!, duration: match[2]! } : { label: call, duration: '' }
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 16:33:25 -05:00
|
|
|
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
|
2026-04-12 16:06:39 -05:00
|
|
|
|
2026-04-09 00:36:53 -05:00
|
|
|
export const sameToolTrailGroup = (label: string, entry: string) =>
|
2026-04-12 17:39:17 -05:00
|
|
|
entry === `${label} ✓` ||
|
|
|
|
|
entry === `${label} ✗` ||
|
|
|
|
|
entry.startsWith(`${label}(`) ||
|
|
|
|
|
entry.startsWith(`${label} ::`) ||
|
|
|
|
|
entry.startsWith(`${label}:`)
|
2026-04-09 00:36:53 -05:00
|
|
|
|
2026-04-09 18:33:25 -05:00
|
|
|
export const lastCotTrailIndex = (trail: readonly string[]) => {
|
|
|
|
|
for (let i = trail.length - 1; i >= 0; i--) {
|
|
|
|
|
if (!isToolTrailResultLine(trail[i]!)) {
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return -1
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:38:13 -05:00
|
|
|
export const estimateRows = (text: string, w: number, compact = false) => {
|
2026-04-11 17:15:36 -05:00
|
|
|
let fence: { char: '`' | '~'; len: number } | null = null
|
2026-04-06 18:38:13 -05:00
|
|
|
let rows = 0
|
|
|
|
|
|
|
|
|
|
for (const raw of text.split('\n')) {
|
|
|
|
|
const line = stripAnsi(raw)
|
2026-04-11 17:15:36 -05:00
|
|
|
const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
|
2026-04-06 18:38:13 -05:00
|
|
|
|
2026-04-11 17:15:36 -05:00
|
|
|
if (maybeFence) {
|
|
|
|
|
const marker = maybeFence[1]!
|
|
|
|
|
const lang = maybeFence[2]!.trim()
|
|
|
|
|
|
|
|
|
|
if (!fence) {
|
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
|
|
|
fence = { char: marker[0] as '`' | '~', len: marker.length }
|
2026-04-06 18:38:13 -05:00
|
|
|
|
|
|
|
|
if (lang) {
|
|
|
|
|
rows += Math.ceil((`─ ${lang}`.length || 1) / w)
|
|
|
|
|
}
|
2026-04-11 17:15:36 -05:00
|
|
|
} else if (marker[0] === fence.char && marker.length >= fence.len) {
|
|
|
|
|
fence = null
|
2026-04-06 18:38:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:15:36 -05:00
|
|
|
const inCode = Boolean(fence)
|
2026-04-06 18:38:13 -05:00
|
|
|
const trimmed = line.trim()
|
|
|
|
|
|
|
|
|
|
if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rendered = inCode ? line : renderEstimateLine(line)
|
|
|
|
|
|
|
|
|
|
if (compact && !rendered.trim()) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows += Math.ceil((rendered.length || 1) / w)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Math.max(1, rows)
|
|
|
|
|
}
|
2026-04-02 20:39:52 -05:00
|
|
|
|
|
|
|
|
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
|
|
|
|
|
|
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 COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })
|
2026-04-09 00:46:35 -05:00
|
|
|
|
2026-04-15 10:20:56 -05:00
|
|
|
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
|
2026-04-02 20:39:52 -05:00
|
|
|
|
|
|
|
|
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
|
|
|
|
|
|
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
|
|
|
export const isPasteBackedText = (text: string) =>
|
2026-04-13 18:29:24 -05:00
|
|
|
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)
|