hermes-agent-features/ui-tui/src/lib/text.ts
Brooklyn Nicholson 39231f29c6 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

198 lines
5.8 KiB
TypeScript

import { THINKING_COT_MAX } from '../config/limits.js'
import type { ThinkingMode } from '../types.js'
const ESC = String.fromCharCode(27)
const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
const WS_RE = /\s+/g
export const stripAnsi = (s: string) => s.replace(ANSI_RE, '')
export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`)
const renderEstimateLine = (line: string) => {
const trimmed = line.trim()
if (trimmed.startsWith('|')) {
return trimmed
.split('|')
.filter(Boolean)
.map(cell => cell.trim())
.join(' ')
}
return line
.replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]')
.replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/__(.+?)__/g, '$1')
.replace(/\*(.+?)\*/g, '$1')
.replace(/_(.+?)_/g, '$1')
.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+/, '• ')
.replace(/^\s*(\d+)\.\s+/, '$1. ')
.replace(/^\s*(?:>\s*)+/, '│ ')
}
export const compactPreview = (s: string, max: number) => {
const one = s.replace(WS_RE, ' ').trim()
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
}
export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2)
export const edgePreview = (s: string, head = 16, tail = 28) => {
const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]')
return !one
? ''
: one.length <= head + tail + 4
? one
: `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}`
}
export const pasteTokenLabel = (text: string, lineCount: number) => {
const preview = edgePreview(text)
if (!preview) {
return `[[ [${fmtK(lineCount)} lines] ]]`
}
const [head = preview, tail = ''] = preview.split('.. ', 2)
return tail
? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]`
: `[[ ${preview} [${fmtK(lineCount)} lines] ]]`
}
export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => {
const raw = reasoning.trim()
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
}
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
export const toolTrailLabel = (name: string) =>
name
.split('_')
.filter(Boolean)
.map(p => p[0]!.toUpperCase() + p.slice(1))
.join(' ') || name
export const formatToolCall = (name: string, context = '') => {
const label = toolTrailLabel(name)
const preview = compactPreview(context, 64)
return preview ? `${label}("${preview}")` : label
}
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string) => {
const detail = compactPreview(note ?? '', 72)
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}`
}
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
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 }
}
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
export const sameToolTrailGroup = (label: string, entry: string) =>
entry === `${label}` ||
entry === `${label}` ||
entry.startsWith(`${label}(`) ||
entry.startsWith(`${label} ::`) ||
entry.startsWith(`${label}:`)
export const lastCotTrailIndex = (trail: readonly string[]) => {
for (let i = trail.length - 1; i >= 0; i--) {
if (!isToolTrailResultLine(trail[i]!)) {
return i
}
}
return -1
}
export const estimateRows = (text: string, w: number, compact = false) => {
let fence: { char: '`' | '~'; len: number } | null = null
let rows = 0
for (const raw of text.split('\n')) {
const line = stripAnsi(raw)
const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/)
if (maybeFence) {
const marker = maybeFence[1]!
const lang = maybeFence[2]!.trim()
if (!fence) {
fence = { char: marker[0] as '`' | '~', len: marker.length }
if (lang) {
rows += Math.ceil((`${lang}`.length || 1) / w)
}
} else if (marker[0] === fence.char && marker.length >= fence.len) {
fence = null
}
continue
}
const inCode = Boolean(fence)
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)
}
export const flat = (r: Record<string, string[]>) => Object.values(r).flat()
const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' })
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
export const pick = <T>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
export const isPasteBackedText = (text: string) =>
/\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text)