From 9931d1d814a432768ec897d05fba0b24ee1c6991 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 10:35:08 -0500 Subject: [PATCH] chore: cleanup --- AGENTS.md | 24 +++--- ui-tui/README.md | 103 +++++++++++++++++++------- ui-tui/src/app.tsx | 35 +++++---- ui-tui/src/app/useComposerState.ts | 43 +++++++++-- ui-tui/src/app/useTurnState.ts | 42 +++++++++-- ui-tui/src/components/appOverlays.tsx | 2 +- ui-tui/src/components/branding.tsx | 6 +- ui-tui/src/components/prompts.tsx | 6 +- ui-tui/src/components/thinking.tsx | 8 +- ui-tui/src/gatewayClient.ts | 1 + ui-tui/src/hooks/useCompletion.ts | 34 +++++---- ui-tui/src/hooks/useInputHistory.ts | 6 +- ui-tui/src/hooks/useQueue.ts | 51 ++++++++----- 13 files changed, 250 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ad664370..83c32cc80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,10 @@ hermes-agent/ │ ├── src/entry.tsx # TTY gate + render() │ ├── src/app.tsx # Main state machine and UI │ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge -│ ├── src/components/ # Ink components (branding, markdown, prompts, etc.) -│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue -│ └── src/lib/ # Pure helpers (history, osc52, text) +│ ├── src/app/ # Decomposed app logic (event handler, slash handler, stores, hooks) +│ ├── src/components/ # Ink components (branding, markdown, prompts, pickers, etc.) +│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory +│ └── src/lib/ # Pure helpers (history, osc52, text, rpc, messages) ├── tui_gateway/ # Python JSON-RPC backend for Ink TUI │ ├── entry.py # stdio entrypoint │ ├── server.py # RPC handlers and session logic @@ -215,7 +216,7 @@ Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. Se | Surface | Ink component | Gateway method | |---------|---------------|----------------| | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` | -| Tool activity | `activityLane.tsx` | `tool.start/progress/complete` | +| Tool activity | `thinking.tsx` | `tool.start/progress/complete` | | Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` | | Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` | | Session picker | `sessionPicker.tsx` | `session.list/resume` | @@ -232,13 +233,14 @@ Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. Se ```bash cd ui-tui -npm install # first time -npm run dev # watch mode -npm start # production -npm run build # typecheck -npm run lint # eslint -npm run fmt # prettier -npm test # vitest +npm install # first time +npm run dev # watch mode (rebuilds hermes-ink + tsx --watch) +npm start # production +npm run build # full build (hermes-ink + tsc) +npm run type-check # typecheck only (tsc --noEmit) +npm run lint # eslint +npm run fmt # prettier +npm test # vitest ``` --- diff --git a/ui-tui/README.md b/ui-tui/README.md index b4417d3cc..38d206baf 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -59,11 +59,29 @@ npm run fmt npm run fix ``` -There is no package-local test script today. +Tests use vitest: + +```bash +npm test # single run +npm run test:watch +``` ## App model -`src/app.tsx` is the center of the UI. It holds: +`src/app.tsx` is the center of the UI. Heavy logic is split into `src/app/`: + +- `createGatewayEventHandler.ts` — maps gateway events to state updates +- `createSlashHandler.ts` — local slash command dispatch +- `useComposerState.ts` — draft, multiline buffer, queue editing +- `useInputHandlers.ts` — keypress routing +- `useTurnState.ts` — agent turn lifecycle +- `overlayStore.ts` / `uiStore.ts` — nanostores for overlay and UI state +- `gatewayContext.tsx` — React context for the gateway client +- `constants.ts`, `helpers.ts`, `interfaces.ts` + +The top-level `app.tsx` composes these into the Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list. + +State managed at the top level includes: - transcript and streaming state - queued messages and input history @@ -260,30 +278,62 @@ Current color overrides: ## File map ```text -ui-tui/src/ - entry.tsx TTY gate + render() - app.tsx main state machine and UI - gatewayClient.ts child process + JSON-RPC bridge - theme.ts default palette + skin merge - constants.ts display constants, hotkeys, tool labels - types.ts shared client-side types - banner.ts ASCII art data +ui-tui/ + packages/hermes-ink/ forked Ink renderer (local dep) + src/ + entry.tsx TTY gate + render() + app.tsx top-level Ink tree, composes src/app/* + gatewayClient.ts child process + JSON-RPC bridge + theme.ts default palette + skin merge + constants.ts display constants, hotkeys, tool labels + types.ts shared client-side types + banner.ts ASCII art data - components/ - branding.tsx banner + session summary - markdown.tsx Markdown-to-Ink renderer - maskedPrompt.tsx masked input for sudo / secrets - messageLine.tsx transcript rows - prompts.tsx approval + clarify flows - queuedMessages.tsx queued input preview - sessionPicker.tsx session resume picker - textInput.tsx custom line editor - thinking.tsx spinner, reasoning, tool activity + app/ + createGatewayEventHandler.ts event → state mapping + createSlashHandler.ts local slash dispatch + useComposerState.ts draft + multiline + queue editing + useInputHandlers.ts keypress routing + useTurnState.ts agent turn lifecycle + overlayStore.ts nanostores for overlays + uiStore.ts nanostores for UI flags + gatewayContext.tsx React context for gateway client + constants.ts app-level constants + helpers.ts pure helpers + interfaces.ts internal interfaces - lib/ - history.ts persistent input history - osc52.ts OSC 52 clipboard copy - text.ts text helpers, ANSI detection, previews + components/ + appChrome.tsx status bar, input row, completions + appLayout.tsx top-level layout composition + appOverlays.tsx overlay routing (pickers, prompts) + branding.tsx banner + session summary + markdown.tsx Markdown-to-Ink renderer + maskedPrompt.tsx masked input for sudo / secrets + messageLine.tsx transcript rows + modelPicker.tsx model switch picker + prompts.tsx approval + clarify flows + queuedMessages.tsx queued input preview + sessionPicker.tsx session resume picker + textInput.tsx custom line editor + thinking.tsx spinner, reasoning, tool activity + + hooks/ + useCompletion.ts tab completion (slash + path) + useInputHistory.ts persistent history navigation + useQueue.ts queued message management + useVirtualHistory.ts in-memory history for pickers + + lib/ + history.ts persistent input history + messages.ts message formatting helpers + osc52.ts OSC 52 clipboard copy + rpc.ts JSON-RPC type helpers + text.ts text helpers, ANSI detection, previews + + types/ + hermes-ink.d.ts type declarations for @hermes/ink + + __tests__/ vitest suite ``` Related Python side: @@ -293,8 +343,5 @@ tui_gateway/ entry.py stdio entrypoint server.py RPC handlers and session logic render.py optional rich/ANSI bridge + slash_worker.py persistent HermesCLI subprocess for slash commands ``` - -## Notes - -- No dead code: `main.tsx`, `altScreen.tsx`, `commandPalette.tsx`, and `lib/slash.ts` have been removed. diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index d071ba786..08b415276 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -173,21 +173,6 @@ export function App({ gw }: { gw: GatewayClient }) { [selection] ) - // ── Resize RPC ─────────────────────────────────────────────────── - - useEffect(() => { - if (!ui.sid || !stdout) { - return - } - - const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) - stdout.on('resize', onResize) - - return () => { - stdout.off('resize', onResize) - } - }, [stdout, ui.sid]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { const id = setInterval(() => setClockNow(Date.now()), 1000) @@ -256,6 +241,21 @@ export function App({ gw }: { gw: GatewayClient }) { const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + // ── Resize RPC ─────────────────────────────────────────────────── + + useEffect(() => { + if (!ui.sid || !stdout) { + return + } + + const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) + stdout.on('resize', onResize) + + return () => { + stdout.off('resize', onResize) + } + }, [rpc, stdout, ui.sid]) + const answerClarify = useCallback( (answer: string) => { const clarify = overlay.clarify @@ -729,8 +729,8 @@ export function App({ gw }: { gw: GatewayClient }) { return } - composerActions.clearIn() const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() if (editIdx !== null) { composerActions.replaceQueue(editIdx, full) @@ -769,8 +769,7 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, composerActions, composerRefs] + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] ) // ── Input handling ─────────────────────────────────────────────── diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 8d3df69ee..467b01614 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { useStore } from '@nanostores/react' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' import { useCompletion } from '../hooks/useCompletion.js' @@ -104,8 +104,8 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose } }, [input, inputBuf, submitRef]) - return { - actions: { + const actions = useMemo( + () => ({ clearIn, dequeue, enqueue, @@ -120,15 +120,35 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose setPasteSnips, setQueueEdit, syncQueue - }, - refs: { + }), + [ + clearIn, + dequeue, + enqueue, + handleTextPaste, + openEditor, + pushHistory, + replaceQ, + setCompIdx, + setHistoryIdx, + setQueueEdit, + syncQueue + ] + ) + + const refs = useMemo( + () => ({ historyDraftRef, historyRef, queueEditRef, queueRef, submitRef - }, - state: { + }), + [historyDraftRef, historyRef, queueEditRef, queueRef, submitRef] + ) + + const state = useMemo( + () => ({ compIdx, compReplace, completions, @@ -138,6 +158,13 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose pasteSnips, queueEditIdx, queuedDisplay - } + }), + [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay] + ) + + return { + actions, + refs, + state } } diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts index e78b7f489..d20e25292 100644 --- a/ui-tui/src/app/useTurnState.ts +++ b/ui-tui/src/app/useTurnState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' import type { ActiveTool, ActivityItem } from '../types.js' @@ -191,8 +191,8 @@ export function useTurnState(): UseTurnStateResult { [clearReasoning, idle, streaming] ) - return { - actions: { + const actions = useMemo( + () => ({ clearReasoning, endReasoningPhase, idle, @@ -212,8 +212,23 @@ export function useTurnState(): UseTurnStateResult { setStreaming, setTools, setTurnTrail - }, - refs: { + }), + [ + clearReasoning, + endReasoningPhase, + idle, + interruptTurn, + pruneTransient, + pulseReasoningStreaming, + pushActivity, + pushTrail, + scheduleReasoning, + scheduleStreaming + ] + ) + + const refs = useMemo( + () => ({ activeToolsRef, bufRef, interruptedRef, @@ -228,8 +243,12 @@ export function useTurnState(): UseTurnStateResult { toolTokenAccRef, toolCompleteRibbonRef, turnToolsRef - }, - state: { + }), + [] + ) + + const state = useMemo( + () => ({ activity, reasoning, reasoningTokens, @@ -239,6 +258,13 @@ export function useTurnState(): UseTurnStateResult { streaming, tools, turnTrail - } + }), + [activity, reasoning, reasoningTokens, reasoningActive, toolTokens, reasoningStreaming, streaming, tools, turnTrail] + ) + + return { + actions, + refs, + state } } diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 35927f0bd..9b7f7b9db 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -143,7 +143,7 @@ export function AppOverlays({ diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index d37f86f71..46f6b667f 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -52,15 +52,17 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string const truncLine = (pfx: string, items: string[]) => { let line = '' + let shown = 0 - for (const item of items.sort()) { + for (const item of [...items].sort()) { const next = line ? `${line}, ${item}` : item if (pfx.length + next.length > lineBudget) { - return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` + return line ? `${line}, …+${items.length - shown}` : `${item}, …` } line = next + shown++ } return line diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 4e546f3d8..3dd8a9d75 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -93,7 +93,7 @@ export function ClarifyPrompt({ return } - if (typing) { + if (typing || !choices.length) { return } @@ -117,6 +117,8 @@ export function ClarifyPrompt({ }) if (typing || !choices.length) { + const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel' + return ( {heading} @@ -126,7 +128,7 @@ export function ClarifyPrompt({ - Enter send · Esc back · Ctrl+C cancel + {hint} ) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 8d75713d0..7d0717c7a 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -74,10 +74,16 @@ function StreamCursor({ const [on, setOn] = useState(true) useEffect(() => { + if (!visible || !streaming) { + setOn(true) + + return + } + const id = setInterval(() => setOn(v => !v), 420) return () => clearInterval(id) - }, []) + }, [streaming, visible]) return visible ? ( diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index ffa06377b..caf851220 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -93,6 +93,7 @@ export class GatewayClient extends EventEmitter { const pyPath = (env.PYTHONPATH ?? '').trim() env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root this.ready = false + this.bufferedEvents = [] this.pendingExit = undefined this.stdoutRl?.close() this.stderrRl?.close() diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index aae199324..24f931770 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,33 +1,39 @@ import { useEffect, useRef, useState } from 'react' +import type { CompletionItem } from '../app/interfaces.js' import type { GatewayClient } from '../gatewayClient.js' const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ +interface CompletionResult { + items?: CompletionItem[] + replace_from?: number +} + export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { - const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) + const [completions, setCompletions] = useState([]) const [compIdx, setCompIdx] = useState(0) const [compReplace, setCompReplace] = useState(0) const ref = useRef('') useEffect(() => { const clear = () => { - if (!completions.length) { - return - } - - setCompletions([]) - setCompIdx(0) + setCompletions(prev => (prev.length ? [] : prev)) + setCompIdx(prev => (prev ? 0 : prev)) + setCompReplace(prev => (prev ? 0 : prev)) } - if (blocked || input === ref.current) { - if (blocked) { - clear() - } + if (blocked) { + ref.current = '' + clear() return } + if (input === ref.current) { + return + } + ref.current = input const isSlash = input.startsWith('/') @@ -49,7 +55,9 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient : gw.request('complete.path', { word: pathWord }) req - .then((r: any) => { + .then(raw => { + const r = raw as CompletionResult | null | undefined + if (ref.current !== input) { return } @@ -76,7 +84,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient }, 60) return () => clearTimeout(t) - }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps + }, [blocked, gw, input]) return { completions, compIdx, setCompIdx, compReplace } } diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts index 0793178fd..369a9f50f 100644 --- a/ui-tui/src/hooks/useInputHistory.ts +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import * as inputHistory from '../lib/history.js' @@ -7,7 +7,9 @@ export function useInputHistory() { const [historyIdx, setHistoryIdx] = useState(null) const historyDraftRef = useRef('') - const pushHistory = (text: string) => inputHistory.append(text) + const pushHistory = useCallback((text: string) => { + inputHistory.append(text) + }, []) return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } } diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index c0df224ff..21bdd51c9 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' export function useQueue() { const queueRef = useRef([]) @@ -6,30 +6,47 @@ export function useQueue() { const queueEditRef = useRef(null) const [queueEditIdx, setQueueEditIdx] = useState(null) - const syncQueue = () => setQueuedDisplay([...queueRef.current]) + const syncQueue = useCallback(() => { + setQueuedDisplay([...queueRef.current]) + }, []) - const setQueueEdit = (idx: number | null) => { + const setQueueEdit = useCallback((idx: number | null) => { queueEditRef.current = idx setQueueEditIdx(idx) - } + }, []) - const enqueue = (text: string) => { - queueRef.current.push(text) - syncQueue() - } + const enqueue = useCallback( + (text: string) => { + queueRef.current.push(text) + syncQueue() + }, + [syncQueue] + ) - const dequeue = () => { - const [head, ...rest] = queueRef.current - queueRef.current = rest + const dequeue = useCallback(() => { + const head = queueRef.current.shift() syncQueue() return head - } + }, [syncQueue]) - const replaceQ = (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() - } + const replaceQ = useCallback( + (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + }, + [syncQueue] + ) - return { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } + return { + queueRef, + queueEditRef, + queuedDisplay, + queueEditIdx, + enqueue, + dequeue, + replaceQ, + setQueueEdit, + syncQueue + } }