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