chore: cleanup

This commit is contained in:
Brooklyn Nicholson 2026-04-15 10:35:08 -05:00
parent cc15b55bb9
commit 9931d1d814
13 changed files with 250 additions and 111 deletions

View file

@ -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
```
---

View file

@ -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.

View file

@ -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 ───────────────────────────────────────────────

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -143,7 +143,7 @@ export function AppOverlays({
<Box
backgroundColor={active ? (ui.theme.color.completionCurrentBg as any) : undefined}
flexDirection="row"
key={item.text}
key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`}
width="100%"
>
<Text bold={active} color={ui.theme.color.bronze as any}>

View file

@ -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

View file

@ -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 (
<Box flexDirection="column">
{heading}
@ -126,7 +128,7 @@ export function ClarifyPrompt({
<TextInput columns={Math.max(20, cols - 6)} onChange={setCustom} onSubmit={onAnswer} value={custom} />
</Box>
<Text color={t.color.dim}>Enter send · Esc back · Ctrl+C cancel</Text>
<Text color={t.color.dim}>{hint}</Text>
</Box>
)
}

View file

@ -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 ? (
<Text color={color} dimColor={dimColor}>

View file

@ -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()

View file

@ -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<CompletionItem[]>([])
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 }
}

View file

@ -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<number | null>(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 }
}

View file

@ -1,4 +1,4 @@
import { useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
export function useQueue() {
const queueRef = useRef<string[]>([])
@ -6,30 +6,47 @@ export function useQueue() {
const queueEditRef = useRef<number | null>(null)
const [queueEditIdx, setQueueEditIdx] = useState<number | null>(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
}
}