feat: add inline token count etc and fix venv

This commit is contained in:
Brooklyn Nicholson 2026-04-15 10:20:56 -05:00
parent 561cea0d4a
commit 33c615504d
21 changed files with 984 additions and 459 deletions

View file

@ -865,6 +865,7 @@ def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False):
env = os.environ.copy()
env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get("HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT))
env.setdefault("HERMES_PYTHON", sys.executable)
env.setdefault("HERMES_CWD", os.getcwd())
if resume_session_id:
env["HERMES_TUI_RESUME"] = resume_session_id

View file

@ -619,9 +619,13 @@ def _on_tool_progress(
_args: dict | None = None,
**_kwargs,
):
if not _tool_progress_enabled(sid) or event_type != "tool.started" or not name:
if not _tool_progress_enabled(sid):
return
_emit("tool.progress", sid, {"name": name, "preview": preview or ""})
if event_type == "tool.started" and name:
_emit("tool.progress", sid, {"name": name, "preview": preview or ""})
return
if event_type == "reasoning.available" and preview and _reasoning_visible(sid):
_emit("reasoning.available", sid, {"text": str(preview)})
def _agent_cbs(sid: str) -> dict:

View file

@ -16,7 +16,7 @@ The client entrypoint is `src/entry.tsx`. It exits early if `stdin` is not a TTY
python -m tui_gateway.entry
```
By default it uses `venv/bin/python` from the repo root. Set `HERMES_PYTHON` to override.
Interpreter resolution order is: `HERMES_PYTHON``PYTHON``$VIRTUAL_ENV/bin/python``./.venv/bin/python``./venv/bin/python``python3` (or `python` on Windows).
The transport is newline-delimited JSON-RPC over stdio:
@ -224,6 +224,7 @@ Primary event types the client handles today:
| `message.complete` | `{ text, rendered?, usage, status }` |
| `thinking.delta` | `{ text }` |
| `reasoning.delta` | `{ text }` |
| `reasoning.available` | `{ text }` |
| `status.update` | `{ kind, text }` |
| `tool.start` | `{ tool_id, name, context? }` |
| `tool.progress` | `{ name, preview }` |

View file

@ -0,0 +1,354 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
import { resetOverlayState } from '../app/overlayStore.js'
import { resetUiState } from '../app/uiStore.js'
import { estimateTokensRough } from '../lib/text.js'
import type { Msg } from '../types.js'
const ref = <T>(current: T) => ({ current })
describe('createGatewayEventHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
})
it('persists completed tool rows when message.complete lands immediately after tool.complete', () => {
const appended: Msg[] = []
const state = {
activity: [] as unknown[],
reasoningTokens: 0,
streaming: '',
toolTokens: 0,
tools: [] as unknown[],
turnTrail: [] as string[]
}
const setTools = vi.fn((next: unknown) => {
if (typeof next !== 'function') {
state.tools = next as unknown[]
}
})
const setTurnTrail = vi.fn((next: unknown) => {
if (typeof next !== 'function') {
state.turnTrail = next as string[]
}
})
const refs = {
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
bufRef: ref(''),
interruptedRef: ref(false),
lastStatusNoteRef: ref(''),
persistedToolLabelsRef: ref(new Set<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref('mapped the page'),
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
toolTokenAccRef: ref(0),
toolCompleteRibbonRef: ref(null),
turnToolsRef: ref([] as string[])
}
const onEvent = createGatewayEventHandler({
composer: {
dequeue: () => undefined,
queueEditRef: ref<number | null>(null),
sendQueued: vi.fn()
},
gateway: {
gw: { request: vi.fn() } as any,
rpc: vi.fn(async () => null)
},
session: {
STARTUP_RESUME_ID: '',
colsRef: ref(80),
newSession: vi.fn(),
resetSession: vi.fn(),
setCatalog: vi.fn()
},
system: {
bellOnComplete: false,
sys: vi.fn()
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
setHistoryItems: vi.fn(),
setMessages: vi.fn()
},
turn: {
actions: {
clearReasoning: vi.fn(() => {
refs.reasoningRef.current = ''
refs.toolTokenAccRef.current = 0
state.toolTokens = 0
}),
endReasoningPhase: vi.fn(),
idle: vi.fn(() => {
refs.activeToolsRef.current = []
state.tools = []
}),
pruneTransient: vi.fn(),
pulseReasoningStreaming: vi.fn(),
pushActivity: vi.fn(),
pushTrail: vi.fn(),
scheduleReasoning: vi.fn(),
scheduleStreaming: vi.fn(),
setActivity: vi.fn(),
setReasoningTokens: vi.fn((next: number) => {
state.reasoningTokens = next
}),
setStreaming: vi.fn((next: string) => {
state.streaming = next
}),
setToolTokens: vi.fn((next: number) => {
state.toolTokens = next
}),
setTools,
setTurnTrail
},
refs
}
} as any)
onEvent({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(1)
expect(appended[0]).toMatchObject({
role: 'assistant',
text: 'final answer',
thinking: 'mapped the page'
})
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.tools?.[0]).toContain('hero cards')
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
})
it('keeps tool tokens across handler recreation mid-turn', () => {
const appended: Msg[] = []
const state = {
activity: [] as unknown[],
reasoningTokens: 0,
streaming: '',
toolTokens: 0,
tools: [] as unknown[],
turnTrail: [] as string[]
}
const refs = {
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
bufRef: ref(''),
interruptedRef: ref(false),
lastStatusNoteRef: ref(''),
persistedToolLabelsRef: ref(new Set<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref('mapped the page'),
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
toolTokenAccRef: ref(0),
toolCompleteRibbonRef: ref(null),
turnToolsRef: ref([] as string[])
}
const buildHandler = () =>
createGatewayEventHandler({
composer: {
dequeue: () => undefined,
queueEditRef: ref<number | null>(null),
sendQueued: vi.fn()
},
gateway: {
gw: { request: vi.fn() } as any,
rpc: vi.fn(async () => null)
},
session: {
STARTUP_RESUME_ID: '',
colsRef: ref(80),
newSession: vi.fn(),
resetSession: vi.fn(),
setCatalog: vi.fn()
},
system: {
bellOnComplete: false,
sys: vi.fn()
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
setHistoryItems: vi.fn(),
setMessages: vi.fn()
},
turn: {
actions: {
clearReasoning: vi.fn(() => {
refs.reasoningRef.current = ''
refs.toolTokenAccRef.current = 0
state.toolTokens = 0
}),
endReasoningPhase: vi.fn(),
idle: vi.fn(() => {
refs.activeToolsRef.current = []
state.tools = []
}),
pruneTransient: vi.fn(),
pulseReasoningStreaming: vi.fn(),
pushActivity: vi.fn(),
pushTrail: vi.fn(),
scheduleReasoning: vi.fn(),
scheduleStreaming: vi.fn(),
setActivity: vi.fn(),
setReasoningTokens: vi.fn((next: number) => {
state.reasoningTokens = next
}),
setStreaming: vi.fn((next: string) => {
state.streaming = next
}),
setToolTokens: vi.fn((next: number) => {
state.toolTokens = next
}),
setTools: vi.fn((next: unknown) => {
if (typeof next !== 'function') {
state.tools = next as unknown[]
}
}),
setTurnTrail: vi.fn((next: unknown) => {
if (typeof next !== 'function') {
state.turnTrail = next as string[]
}
})
},
refs
}
} as any)
buildHandler()({
payload: { context: 'home page', name: 'search', tool_id: 'tool-1' },
type: 'tool.start'
} as any)
const onEvent = buildHandler()
onEvent({
payload: { name: 'search', preview: 'hero cards' },
type: 'tool.progress'
} as any)
onEvent({
payload: { summary: 'done', tool_id: 'tool-1' },
type: 'tool.complete'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(1)
expect(appended[0]?.tools).toHaveLength(1)
expect(appended[0]?.toolTokens).toBeGreaterThan(0)
})
it('ignores fallback reasoning.available when streamed reasoning already exists', () => {
const appended: Msg[] = []
const streamed = 'short streamed reasoning'
const fallback = 'x'.repeat(400)
const refs = {
activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]),
bufRef: ref(''),
interruptedRef: ref(false),
lastStatusNoteRef: ref(''),
persistedToolLabelsRef: ref(new Set<string>()),
protocolWarnedRef: ref(false),
reasoningRef: ref(''),
statusTimerRef: ref<ReturnType<typeof setTimeout> | null>(null),
toolTokenAccRef: ref(0),
toolCompleteRibbonRef: ref(null),
turnToolsRef: ref([] as string[])
}
const onEvent = createGatewayEventHandler({
composer: {
dequeue: () => undefined,
queueEditRef: ref<number | null>(null),
sendQueued: vi.fn()
},
gateway: {
gw: { request: vi.fn() } as any,
rpc: vi.fn(async () => null)
},
session: {
STARTUP_RESUME_ID: '',
colsRef: ref(80),
newSession: vi.fn(),
resetSession: vi.fn(),
setCatalog: vi.fn()
},
system: {
bellOnComplete: false,
sys: vi.fn()
},
transcript: {
appendMessage: (msg: Msg) => appended.push(msg),
setHistoryItems: vi.fn(),
setMessages: vi.fn()
},
turn: {
actions: {
clearReasoning: vi.fn(() => {
refs.reasoningRef.current = ''
refs.toolTokenAccRef.current = 0
}),
endReasoningPhase: vi.fn(),
idle: vi.fn(() => {
refs.activeToolsRef.current = []
}),
pruneTransient: vi.fn(),
pulseReasoningStreaming: vi.fn(),
pushActivity: vi.fn(),
pushTrail: vi.fn(),
scheduleReasoning: vi.fn(),
scheduleStreaming: vi.fn(),
setActivity: vi.fn(),
setReasoningTokens: vi.fn(),
setStreaming: vi.fn(),
setToolTokens: vi.fn(),
setTools: vi.fn(),
setTurnTrail: vi.fn()
},
refs
}
} as any)
onEvent({
payload: { text: streamed },
type: 'reasoning.delta'
} as any)
onEvent({
payload: { text: fallback },
type: 'reasoning.available'
} as any)
onEvent({
payload: { text: 'final answer' },
type: 'message.complete'
} as any)
expect(appended).toHaveLength(1)
expect(appended[0]?.thinking).toBe(streamed)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed))
})
})

View file

@ -48,14 +48,14 @@ describe('fmtK', () => {
expect(fmtK(999)).toBe('999')
})
it('formats thousands as K', () => {
expect(fmtK(1000)).toBe('1K')
expect(fmtK(1500)).toBe('1.5K')
it('formats thousands as lowercase k', () => {
expect(fmtK(1000)).toBe('1k')
expect(fmtK(1500)).toBe('1.5k')
})
it('formats millions and billions', () => {
expect(fmtK(1_000_000)).toBe('1M')
expect(fmtK(1_000_000_000)).toBe('1B')
it('formats millions and billions with lowercase suffixes', () => {
expect(fmtK(1_000_000)).toBe('1m')
expect(fmtK(1_000_000_000)).toBe('1b')
})
})

View file

@ -849,11 +849,14 @@ export function App({ gw }: { gw: GatewayClient }) {
scheduleReasoning: turnActions.scheduleReasoning,
scheduleStreaming: turnActions.scheduleStreaming,
setActivity: turnActions.setActivity,
setReasoningTokens: turnActions.setReasoningTokens,
setStreaming: turnActions.setStreaming,
setToolTokens: turnActions.setToolTokens,
setTools: turnActions.setTools,
setTurnTrail: turnActions.setTurnTrail
},
refs: {
activeToolsRef: turnRefs.activeToolsRef,
bufRef: turnRefs.bufRef,
interruptedRef: turnRefs.interruptedRef,
lastStatusNoteRef: turnRefs.lastStatusNoteRef,
@ -861,6 +864,7 @@ export function App({ gw }: { gw: GatewayClient }) {
protocolWarnedRef: turnRefs.protocolWarnedRef,
reasoningRef: turnRefs.reasoningRef,
statusTimerRef: turnRefs.statusTimerRef,
toolTokenAccRef: turnRefs.toolTokenAccRef,
toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef,
turnToolsRef: turnRefs.turnToolsRef
}
@ -1014,16 +1018,7 @@ export function App({ gw }: { gw: GatewayClient }) {
dispatchSubmission([...composerState.inputBuf, value].join('\n'))
},
[
appendMessage,
composerActions,
composerRefs,
composerState,
dispatchSubmission,
gw,
sys,
turnActions
]
[appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions]
)
submitRef.current = submit
@ -1142,11 +1137,13 @@ export function App({ gw }: { gw: GatewayClient }) {
progress={{
activity: turnState.activity,
reasoning: turnState.reasoning,
reasoningTokens: turnState.reasoningTokens,
reasoningActive: turnState.reasoningActive,
reasoningStreaming: turnState.reasoningStreaming,
showProgressArea,
showStreamingArea,
streaming: turnState.streaming,
toolTokens: turnState.toolTokens,
tools: turnState.tools,
turnTrail: turnState.turnTrail
}}

View file

@ -1,72 +1,18 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { GatewayEvent } from '../gatewayClient.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { buildToolTrailLine, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js'
import {
buildToolTrailLine,
estimateTokensRough,
isToolTrailResultLine,
sameToolTrailGroup,
toolTrailLabel
} from '../lib/text.js'
import { fromSkin } from '../theme.js'
import type { Msg, SlashCatalog } from '../types.js'
import { introMsg, toTranscriptMessages } from './helpers.js'
import type { GatewayServices } from './interfaces.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import type { TurnActions, TurnRefs } from './useTurnState.js'
export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<number | null>
sendQueued: (text: string) => void
}
gateway: GatewayServices
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
resetSession: () => void
setCatalog: Dispatch<SetStateAction<SlashCatalog | null>>
}
system: {
bellOnComplete: boolean
stdout?: NodeJS.WriteStream
sys: (text: string) => void
}
transcript: {
appendMessage: (msg: Msg) => void
setHistoryItems: Dispatch<SetStateAction<Msg[]>>
setMessages: Dispatch<SetStateAction<Msg[]>>
}
turn: {
actions: Pick<
TurnActions,
| 'clearReasoning'
| 'endReasoningPhase'
| 'idle'
| 'pruneTransient'
| 'pulseReasoningStreaming'
| 'pushActivity'
| 'pushTrail'
| 'scheduleReasoning'
| 'scheduleStreaming'
| 'setActivity'
| 'setStreaming'
| 'setTools'
| 'setTurnTrail'
>
refs: Pick<
TurnRefs,
| 'bufRef'
| 'interruptedRef'
| 'lastStatusNoteRef'
| 'persistedToolLabelsRef'
| 'protocolWarnedRef'
| 'reasoningRef'
| 'statusTimerRef'
| 'toolCompleteRibbonRef'
| 'turnToolsRef'
>
}
}
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { dequeue, queueEditRef, sendQueued } = ctx.composer
@ -86,12 +32,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
scheduleReasoning,
scheduleStreaming,
setActivity,
setReasoningTokens,
setStreaming,
setToolTokens,
setTools,
setTurnTrail
} = ctx.turn.actions
const {
activeToolsRef,
bufRef,
interruptedRef,
lastStatusNoteRef,
@ -99,6 +48,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
protocolWarnedRef,
reasoningRef,
statusTimerRef,
toolTokenAccRef,
toolCompleteRibbonRef,
turnToolsRef
} = ctx.turn.refs
@ -210,8 +160,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
clearReasoning()
setActivity([])
setTurnTrail([])
activeToolsRef.current = []
setTools([])
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
toolTokenAccRef.current = 0
setToolTokens(0)
break
@ -286,21 +240,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'reasoning.delta':
if (p?.text) {
reasoningRef.current += p.text
setReasoningTokens(estimateTokensRough(reasoningRef.current))
scheduleReasoning()
pulseReasoningStreaming()
}
break
case 'reasoning.available': {
const incoming = String(p?.text ?? '').trim()
if (!incoming) {
break
}
const current = reasoningRef.current.trim()
// `reasoning.available` is a backend fallback preview that can arrive after
// streamed reasoning. Preserve the live-visible reasoning/counts if we
// already saw deltas; only hydrate from this event when streaming gave us
// nothing.
if (!current) {
reasoningRef.current = incoming
setReasoningTokens(estimateTokensRough(reasoningRef.current))
scheduleReasoning()
pulseReasoningStreaming()
}
break
}
case 'tool.progress':
if (p?.preview) {
setTools(prev => {
const index = prev.findIndex(tool => tool.name === p.name)
const index = activeToolsRef.current.findIndex(tool => tool.name === p.name)
return index >= 0
? [...prev.slice(0, index), { ...prev[index]!, context: p.preview as string }, ...prev.slice(index + 1)]
: prev
})
if (index >= 0) {
const next = [...activeToolsRef.current]
next[index] = { ...next[index]!, context: p.preview as string }
activeToolsRef.current = next
setTools(next)
}
}
break
@ -311,44 +290,47 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
break
case 'tool.start':
case 'tool.start': {
pruneTransient()
endReasoningPhase()
setTools(prev => [
...prev,
{ id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() }
])
const ctx = (p.context as string) || ''
const sample = `${String(p.name ?? '')} ${ctx}`.trim()
toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0
setToolTokens(toolTokenAccRef.current)
activeToolsRef.current = [
...activeToolsRef.current,
{ id: p.tool_id, name: p.name, context: ctx, startedAt: Date.now() }
]
setTools(activeToolsRef.current)
break
}
case 'tool.complete': {
toolCompleteRibbonRef.current = null
setTools(prev => {
const done = prev.find(tool => tool.id === p.tool_id)
const name = done?.name ?? p.name
const label = toolTrailLabel(name)
const done = activeToolsRef.current.find(tool => tool.id === p.tool_id)
const name = done?.name ?? p.name
const label = toolTrailLabel(name)
const line = buildToolTrailLine(
name,
done?.context || '',
!!p.error,
(p.error as string) || (p.summary as string) || ''
)
const line = buildToolTrailLine(
name,
done?.context || '',
!!p.error,
(p.error as string) || (p.summary as string) || ''
)
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
const remaining = prev.filter(tool => tool.id !== p.tool_id)
const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line]
toolCompleteRibbonRef.current = { label, line }
activeToolsRef.current = activeToolsRef.current.filter(tool => tool.id !== p.tool_id)
setTools(activeToolsRef.current)
toolCompleteRibbonRef.current = { label, line }
if (!remaining.length) {
next.push('analyzing tool output…')
}
if (!activeToolsRef.current.length) {
next.push('analyzing tool output…')
}
turnToolsRef.current = next.slice(-8)
setTurnTrail(turnToolsRef.current)
return remaining
})
turnToolsRef.current = next.slice(-8)
setTurnTrail(turnToolsRef.current)
if (p?.inline_diff) {
sys(p.inline_diff as string)
@ -419,6 +401,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart()
const persisted = persistedToolLabelsRef.current
const savedReasoning = reasoningRef.current.trim()
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
const savedToolTokens = toolTokenAccRef.current
const savedTools = turnToolsRef.current.filter(
line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line))
@ -426,15 +410,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const wasInterrupted = interruptedRef.current
idle()
clearReasoning()
setStreaming('')
if (!wasInterrupted) {
appendMessage({
role: 'assistant',
text: finalText,
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedTools.length ? savedToolTokens : undefined,
tools: savedTools.length ? savedTools : undefined
})
@ -443,6 +425,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
}
idle()
clearReasoning()
turnToolsRef.current = []
persistedToolLabelsRef.current.clear()
setActivity([])

View file

@ -1,57 +1,14 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import { HOTKEYS } from '../constants.js'
import { writeOsc52Clipboard } from '../lib/osc52.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { fmtK } from '../lib/text.js'
import type { DetailsMode, Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js'
import type { DetailsMode, PanelSection } from '../types.js'
import { imageTokenMeta, introMsg, nextDetailsMode, parseDetailsMode, toTranscriptMessages } from './helpers.js'
import type { GatewayServices } from './interfaces.js'
import type { SlashHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
export interface SlashHandlerContext {
composer: {
enqueue: (text: string) => void
hasSelection: boolean
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: {
copySelection: () => string
}
setInput: Dispatch<SetStateAction<string>>
}
gateway: GatewayServices
local: {
catalog: SlashCatalog | null
lastUserMsg: string
maybeWarn: (value: any) => void
messages: Msg[]
}
session: {
closeSession: (targetSid?: string | null) => Promise<unknown>
die: () => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
resetVisibleHistory: (info?: SessionInfo | null) => void
resumeById: (id: string) => void
setSessionStartedAt: Dispatch<SetStateAction<number>>
}
transcript: {
page: (text: string, title?: string) => void
panel: (title: string, sections: PanelSection[]) => void
send: (text: string) => void
setHistoryItems: Dispatch<SetStateAction<Msg[]>>
setMessages: Dispatch<SetStateAction<Msg[]>>
sys: (text: string) => void
trimLastExchange: (items: Msg[]) => Msg[]
}
voice: {
setVoiceEnabled: Dispatch<SetStateAction<boolean>>
}
}
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer
const { gw, rpc } = ctx.gateway

View file

@ -1,14 +1,9 @@
import { createContext, type ReactNode, useContext } from 'react'
import { createContext, useContext } from 'react'
import type { GatewayServices } from './interfaces.js'
import type { GatewayProviderProps, GatewayServices } from './interfaces.js'
const GatewayContext = createContext<GatewayServices | null>(null)
export interface GatewayProviderProps {
children: ReactNode
value: GatewayServices
}
export function GatewayProvider({ children, value }: GatewayProviderProps) {
return <GatewayContext.Provider value={value}>{children}</GatewayContext.Provider>
}

View file

@ -3,11 +3,6 @@ import type { DetailsMode, Msg, SessionInfo } from '../types.js'
const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded']
export interface PasteSnippet {
label: string
text: string
}
export const parseDetailsMode = (v: unknown): DetailsMode | null => {
const s = typeof v === 'string' ? v.trim().toLowerCase() : ''

View file

@ -1,6 +1,32 @@
import type { ScrollBoxHandle } from '@hermes/ink'
import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import type { RpcResult } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
import type { ApprovalReq, ClarifyReq, DetailsMode, Msg, SecretReq, SessionInfo, SudoReq, Usage } from '../types.js'
import type {
ActiveTool,
ActivityItem,
ApprovalReq,
ClarifyReq,
DetailsMode,
Msg,
PanelSection,
SecretReq,
SessionInfo,
SlashCatalog,
SudoReq,
Usage
} from '../types.js'
export interface StateSetter<T> {
(value: SetStateAction<T>): void
}
export interface SelectionApi {
copySelection: () => string
}
export interface CompletionItem {
display: string
@ -9,7 +35,7 @@ export interface CompletionItem {
}
export interface GatewayRpc {
(method: string, params?: Record<string, unknown>): Promise<any | null>
(method: string, params?: Record<string, unknown>): Promise<RpcResult | null>
}
export interface GatewayServices {
@ -17,6 +43,11 @@ export interface GatewayServices {
rpc: GatewayRpc
}
export interface GatewayProviderProps {
children: ReactNode
value: GatewayServices
}
export interface OverlayState {
approval: ApprovalReq | null
clarify: ClarifyReq | null
@ -65,3 +96,343 @@ export interface VirtualHistoryState {
start: number
topSpacer: number
}
export interface ComposerPasteResult {
cursor: number
value: string
}
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
openEditor: () => void
pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: StateSetter<number>
setHistoryIdx: StateSetter<number | null>
setInput: StateSetter<string>
setInputBuf: StateSetter<string[]>
setPasteSnips: StateSetter<PasteSnippet[]>
setQueueEdit: (index: number | null) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<number | null>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: number | null
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: number | null
queuedDisplay: string[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
submitRef: MutableRefObject<(value: string) => void>
}
export interface UseComposerStateResult {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
export interface InterruptTurnOptions {
appendMessage: (msg: Msg) => void
gw: { request: (method: string, params?: Record<string, unknown>) => Promise<unknown> }
sid: string
sys: (text: string) => void
}
export interface TurnActions {
clearReasoning: () => void
endReasoningPhase: () => void
idle: () => void
interruptTurn: (options: InterruptTurnOptions) => void
pruneTransient: () => void
pulseReasoningStreaming: () => void
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
pushTrail: (line: string) => void
scheduleReasoning: () => void
scheduleStreaming: () => void
setActivity: StateSetter<ActivityItem[]>
setReasoning: StateSetter<string>
setReasoningTokens: StateSetter<number>
setReasoningActive: StateSetter<boolean>
setToolTokens: StateSetter<number>
setReasoningStreaming: StateSetter<boolean>
setStreaming: StateSetter<string>
setTools: StateSetter<ActiveTool[]>
setTurnTrail: StateSetter<string[]>
}
export interface TurnRefs {
activeToolsRef: MutableRefObject<ActiveTool[]>
bufRef: MutableRefObject<string>
interruptedRef: MutableRefObject<boolean>
lastStatusNoteRef: MutableRefObject<string>
persistedToolLabelsRef: MutableRefObject<Set<string>>
protocolWarnedRef: MutableRefObject<boolean>
reasoningRef: MutableRefObject<string>
reasoningStreamingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
reasoningTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
statusTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
streamTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
toolTokenAccRef: MutableRefObject<number>
toolCompleteRibbonRef: MutableRefObject<ToolCompleteRibbon | null>
turnToolsRef: MutableRefObject<string[]>
}
export interface TurnState {
activity: ActivityItem[]
reasoning: string
reasoningTokens: number
reasoningActive: boolean
reasoningStreaming: boolean
streaming: string
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
export interface UseTurnStateResult {
actions: TurnActions
refs: TurnRefs
state: TurnState
}
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
sys: (text: string) => void
}
export interface InputHandlerContext {
actions: InputHandlerActions
composer: {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<ScrollBoxHandle | null>
scrollWithSelection: (delta: number) => void
selection: SelectionApi
stdout?: NodeJS.WriteStream
}
turn: {
actions: TurnActions
refs: TurnRefs
}
voice: {
recording: boolean
setProcessing: StateSetter<boolean>
setRecording: StateSetter<boolean>
}
wheelStep: number
}
export interface InputHandlerResult {
pagerPageSize: number
}
export interface GatewayEventHandlerContext {
composer: {
dequeue: () => string | undefined
queueEditRef: MutableRefObject<number | null>
sendQueued: (text: string) => void
}
gateway: GatewayServices
session: {
STARTUP_RESUME_ID: string
colsRef: MutableRefObject<number>
newSession: (msg?: string) => void
resetSession: () => void
setCatalog: StateSetter<SlashCatalog | null>
}
system: {
bellOnComplete: boolean
stdout?: NodeJS.WriteStream
sys: (text: string) => void
}
transcript: {
appendMessage: (msg: Msg) => void
setHistoryItems: StateSetter<Msg[]>
setMessages: StateSetter<Msg[]>
}
turn: {
actions: Pick<
TurnActions,
| 'clearReasoning'
| 'endReasoningPhase'
| 'idle'
| 'pruneTransient'
| 'pulseReasoningStreaming'
| 'pushActivity'
| 'pushTrail'
| 'scheduleReasoning'
| 'scheduleStreaming'
| 'setActivity'
| 'setReasoningTokens'
| 'setStreaming'
| 'setToolTokens'
| 'setTools'
| 'setTurnTrail'
>
refs: Pick<
TurnRefs,
| 'activeToolsRef'
| 'bufRef'
| 'interruptedRef'
| 'lastStatusNoteRef'
| 'persistedToolLabelsRef'
| 'protocolWarnedRef'
| 'reasoningRef'
| 'statusTimerRef'
| 'toolTokenAccRef'
| 'toolCompleteRibbonRef'
| 'turnToolsRef'
>
}
}
export interface SlashHandlerContext {
composer: {
enqueue: (text: string) => void
hasSelection: boolean
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: SelectionApi
setInput: StateSetter<string>
}
gateway: GatewayServices
local: {
catalog: SlashCatalog | null
lastUserMsg: string
maybeWarn: (value: any) => void
messages: Msg[]
}
session: {
closeSession: (targetSid?: string | null) => Promise<unknown>
die: () => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
resetVisibleHistory: (info?: SessionInfo | null) => void
resumeById: (id: string) => void
setSessionStartedAt: StateSetter<number>
}
transcript: {
page: (text: string, title?: string) => void
panel: (title: string, sections: PanelSection[]) => void
send: (text: string) => void
setHistoryItems: StateSetter<Msg[]>
setMessages: StateSetter<Msg[]>
sys: (text: string) => void
trimLastExchange: (items: Msg[]) => Msg[]
}
voice: {
setVoiceEnabled: StateSetter<boolean>
}
}
export interface AppLayoutActions {
answerApproval: (choice: string) => void
answerClarify: (answer: string) => void
answerSecret: (value: string) => void
answerSudo: (pw: string) => void
onModelSelect: (value: string) => void
resumeById: (id: string) => void
setStickyPrompt: (value: string) => void
}
export interface AppLayoutComposerProps {
cols: number
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: number | null
queuedDisplay: string[]
submit: (value: string) => void
updateInput: StateSetter<string>
}
export interface AppLayoutProgressProps {
activity: ActivityItem[]
reasoning: string
reasoningTokens: number
reasoningActive: boolean
reasoningStreaming: boolean
showProgressArea: boolean
showStreamingArea: boolean
streaming: string
toolTokens: number
tools: ActiveTool[]
turnTrail: string[]
}
export interface AppLayoutStatusProps {
cwdLabel: string
durationLabel: string
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
voiceLabel: string
}
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<ScrollBoxHandle | null>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: boolean
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps
}
export interface AppOverlaysProps {
cols: number
compIdx: number
completions: CompletionItem[]
onApprovalChoice: (choice: string) => void
onClarifyAnswer: (value: string) => void
onModelSelect: (value: string) => void
onPickerSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
}
export interface PasteSnippet {
label: string
text: string
}

View file

@ -4,74 +4,18 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { useStore } from '@nanostores/react'
import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useState } from 'react'
import { useCallback, useState } from 'react'
import type { PasteEvent } from '../components/textInput.js'
import type { GatewayClient } from '../gatewayClient.js'
import { useCompletion } from '../hooks/useCompletion.js'
import { useInputHistory } from '../hooks/useInputHistory.js'
import { useQueue } from '../hooks/useQueue.js'
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
import { LARGE_PASTE } from './constants.js'
import type { PasteSnippet } from './helpers.js'
import type { CompletionItem } from './interfaces.js'
import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js'
import { $isBlocked } from './overlayStore.js'
export interface ComposerPasteResult {
cursor: number
value: string
}
export interface ComposerActions {
clearIn: () => void
dequeue: () => string | undefined
enqueue: (text: string) => void
handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null
openEditor: () => void
pushHistory: (text: string) => void
replaceQueue: (index: number, text: string) => void
setCompIdx: Dispatch<SetStateAction<number>>
setHistoryIdx: Dispatch<SetStateAction<number | null>>
setInput: Dispatch<SetStateAction<string>>
setInputBuf: Dispatch<SetStateAction<string[]>>
setPasteSnips: Dispatch<SetStateAction<PasteSnippet[]>>
setQueueEdit: (index: number | null) => void
syncQueue: () => void
}
export interface ComposerRefs {
historyDraftRef: MutableRefObject<string>
historyRef: MutableRefObject<string[]>
queueEditRef: MutableRefObject<number | null>
queueRef: MutableRefObject<string[]>
submitRef: MutableRefObject<(value: string) => void>
}
export interface ComposerState {
compIdx: number
compReplace: number
completions: CompletionItem[]
historyIdx: number | null
input: string
inputBuf: string[]
pasteSnips: PasteSnippet[]
queueEditIdx: number | null
queuedDisplay: string[]
}
export interface UseComposerStateOptions {
gw: GatewayClient
onClipboardPaste: (quiet?: boolean) => Promise<void> | void
submitRef: MutableRefObject<(value: string) => void>
}
export interface UseComposerStateResult {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult {
const [input, setInput] = useState('')
const [inputBuf, setInputBuf] = useState<string[]>([])

View file

@ -1,57 +1,9 @@
import { type ScrollBoxHandle, useInput } from '@hermes/ink'
import { useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type { Msg } from '../types.js'
import type { GatewayServices } from './interfaces.js'
import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import type { ComposerActions, ComposerRefs, ComposerState } from './useComposerState.js'
import type { TurnActions, TurnRefs } from './useTurnState.js'
export interface InputHandlerActions {
answerClarify: (answer: string) => void
appendMessage: (msg: Msg) => void
die: () => void
dispatchSubmission: (full: string) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string) => void
sys: (text: string) => void
}
export interface InputHandlerContext {
actions: InputHandlerActions
composer: {
actions: ComposerActions
refs: ComposerRefs
state: ComposerState
}
gateway: GatewayServices
terminal: {
hasSelection: boolean
scrollRef: RefObject<ScrollBoxHandle | null>
scrollWithSelection: (delta: number) => void
selection: {
copySelection: () => string
}
stdout?: NodeJS.WriteStream
}
turn: {
actions: TurnActions
refs: TurnRefs
}
voice: {
recording: boolean
setProcessing: Dispatch<SetStateAction<boolean>>
setRecording: Dispatch<SetStateAction<boolean>>
}
wheelStep: number
}
export interface InputHandlerResult {
pagerPageSize: number
}
export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx

View file

@ -1,89 +1,26 @@
import {
type Dispatch,
type MutableRefObject,
type SetStateAction,
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js'
import type { ActiveTool, ActivityItem, Msg } from '../types.js'
import type { ActiveTool, ActivityItem } from '../types.js'
import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js'
import type { ToolCompleteRibbon } from './interfaces.js'
import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js'
import { resetOverlayState } from './overlayStore.js'
import { patchUiState } from './uiStore.js'
export interface InterruptTurnOptions {
appendMessage: (msg: Msg) => void
gw: { request: (method: string, params?: Record<string, unknown>) => Promise<unknown> }
sid: string
sys: (text: string) => void
}
export interface TurnActions {
clearReasoning: () => void
endReasoningPhase: () => void
idle: () => void
interruptTurn: (options: InterruptTurnOptions) => void
pruneTransient: () => void
pulseReasoningStreaming: () => void
pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void
pushTrail: (line: string) => void
scheduleReasoning: () => void
scheduleStreaming: () => void
setActivity: Dispatch<SetStateAction<ActivityItem[]>>
setReasoning: Dispatch<SetStateAction<string>>
setReasoningActive: Dispatch<SetStateAction<boolean>>
setReasoningStreaming: Dispatch<SetStateAction<boolean>>
setStreaming: Dispatch<SetStateAction<string>>
setTools: Dispatch<SetStateAction<ActiveTool[]>>
setTurnTrail: Dispatch<SetStateAction<string[]>>
}
export interface TurnRefs {
bufRef: MutableRefObject<string>
interruptedRef: MutableRefObject<boolean>
lastStatusNoteRef: MutableRefObject<string>
persistedToolLabelsRef: MutableRefObject<Set<string>>
protocolWarnedRef: MutableRefObject<boolean>
reasoningRef: MutableRefObject<string>
reasoningStreamingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
reasoningTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
statusTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
streamTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
toolCompleteRibbonRef: MutableRefObject<ToolCompleteRibbon | null>
turnToolsRef: MutableRefObject<string[]>
}
export interface TurnState {
activity: ActivityItem[]
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
streaming: string
tools: ActiveTool[]
turnTrail: string[]
}
export interface UseTurnStateResult {
actions: TurnActions
refs: TurnRefs
state: TurnState
}
export function useTurnState(): UseTurnStateResult {
const [activity, setActivity] = useState<ActivityItem[]>([])
const [reasoning, setReasoning] = useState('')
const [reasoningTokens, setReasoningTokens] = useState(0)
const [reasoningActive, setReasoningActive] = useState(false)
const [toolTokens, setToolTokens] = useState(0)
const [reasoningStreaming, setReasoningStreaming] = useState(false)
const [streaming, setStreaming] = useState('')
const [tools, setTools] = useState<ActiveTool[]>([])
const [turnTrail, setTurnTrail] = useState<string[]>([])
const activityIdRef = useRef(0)
const activeToolsRef = useRef<ActiveTool[]>([])
const bufRef = useRef('')
const interruptedRef = useRef(false)
const lastStatusNoteRef = useRef('')
@ -94,6 +31,7 @@ export function useTurnState(): UseTurnStateResult {
const reasoningTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const streamTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const toolTokenAccRef = useRef(0)
const toolCompleteRibbonRef = useRef<ToolCompleteRibbon | null>(null)
const turnToolsRef = useRef<string[]>([])
@ -200,11 +138,15 @@ export function useTurnState(): UseTurnStateResult {
}
reasoningRef.current = ''
toolTokenAccRef.current = 0
setReasoning('')
setReasoningTokens(0)
setToolTokens(0)
}, [])
const idle = useCallback(() => {
endReasoningPhase()
activeToolsRef.current = []
setTools([])
setTurnTrail([])
patchUiState({ busy: false })
@ -263,13 +205,16 @@ export function useTurnState(): UseTurnStateResult {
scheduleStreaming,
setActivity,
setReasoning,
setReasoningTokens,
setReasoningActive,
setToolTokens,
setReasoningStreaming,
setStreaming,
setTools,
setTurnTrail
},
refs: {
activeToolsRef,
bufRef,
interruptedRef,
lastStatusNoteRef,
@ -280,13 +225,16 @@ export function useTurnState(): UseTurnStateResult {
reasoningTimerRef,
statusTimerRef,
streamTimerRef,
toolTokenAccRef,
toolCompleteRibbonRef,
turnToolsRef
},
state: {
activity,
reasoning,
reasoningTokens,
reasoningActive,
toolTokens,
reasoningStreaming,
streaming,
tools,

View file

@ -1,84 +1,19 @@
import { AlternateScreen, Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import type { RefObject } from 'react'
import { PLACEHOLDER } from '../app/constants.js'
import type { CompletionItem, TranscriptRow, VirtualHistoryState } from '../app/interfaces.js'
import type { AppLayoutProps } from '../app/interfaces.js'
import { $isBlocked } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
import type { ActiveTool, ActivityItem, Msg } from '../types.js'
import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js'
import { AppOverlays } from './appOverlays.js'
import { Banner, Panel, SessionPanel } from './branding.js'
import { MessageLine } from './messageLine.js'
import { QueuedMessages } from './queuedMessages.js'
import type { PasteEvent } from './textInput.js'
import { TextInput } from './textInput.js'
import { ToolTrail } from './thinking.js'
export interface AppLayoutActions {
answerApproval: (choice: string) => void
answerClarify: (answer: string) => void
answerSecret: (value: string) => void
answerSudo: (pw: string) => void
onModelSelect: (value: string) => void
resumeById: (id: string) => void
setStickyPrompt: (value: string) => void
}
export interface AppLayoutComposerProps {
cols: number
compIdx: number
completions: CompletionItem[]
empty: boolean
handleTextPaste: (event: PasteEvent) => { cursor: number; value: string } | null
input: string
inputBuf: string[]
pagerPageSize: number
queueEditIdx: number | null
queuedDisplay: string[]
submit: (value: string) => void
updateInput: (next: string) => void
}
export interface AppLayoutProgressProps {
activity: ActivityItem[]
reasoning: string
reasoningActive: boolean
reasoningStreaming: boolean
showProgressArea: boolean
showStreamingArea: boolean
streaming: string
tools: ActiveTool[]
turnTrail: string[]
}
export interface AppLayoutStatusProps {
cwdLabel: string
durationLabel: string
showStickyPrompt: boolean
statusColor: string
stickyPrompt: string
voiceLabel: string
}
export interface AppLayoutTranscriptProps {
historyItems: Msg[]
scrollRef: RefObject<ScrollBoxHandle | null>
virtualHistory: VirtualHistoryState
virtualRows: TranscriptRow[]
}
export interface AppLayoutProps {
actions: AppLayoutActions
composer: AppLayoutComposerProps
mouseTracking: boolean
progress: AppLayoutProgressProps
status: AppLayoutStatusProps
transcript: AppLayoutTranscriptProps
}
export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) {
const ui = useStore($uiState)
const isBlocked = useStore($isBlocked)
@ -125,8 +60,10 @@ export function AppLayout({ actions, composer, mouseTracking, progress, status,
reasoning={progress.reasoning}
reasoningActive={progress.reasoningActive}
reasoningStreaming={progress.reasoningStreaming}
reasoningTokens={progress.reasoningTokens}
t={ui.theme}
tools={progress.tools}
toolTokens={progress.toolTokens}
trail={progress.turnTrail}
/>
)}

View file

@ -2,7 +2,7 @@ import { Box, Text } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useGateway } from '../app/gatewayContext.js'
import type { CompletionItem } from '../app/interfaces.js'
import type { AppOverlaysProps } from '../app/interfaces.js'
import { $overlayState, patchOverlayState } from '../app/overlayStore.js'
import { $uiState } from '../app/uiStore.js'
@ -12,19 +12,6 @@ import { ModelPicker } from './modelPicker.js'
import { ApprovalPrompt, ClarifyPrompt } from './prompts.js'
import { SessionPicker } from './sessionPicker.js'
export interface AppOverlaysProps {
cols: number
compIdx: number
completions: CompletionItem[]
onApprovalChoice: (choice: string) => void
onClarifyAnswer: (value: string) => void
onModelSelect: (value: string) => void
onPickerSelect: (sessionId: string) => void
onSecretSubmit: (value: string) => void
onSudoSubmit: (pw: string) => void
pagerPageSize: number
}
export function AppOverlays({
cols,
compIdx,

View file

@ -85,7 +85,14 @@ export const MessageLine = memo(function MessageLine({
>
{showDetails && (
<Box flexDirection="column" marginBottom={1}>
<ToolTrail detailsMode={detailsMode} reasoning={thinking} t={t} trail={msg.tools} />
<ToolTrail
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools}
/>
</Box>
)}

View file

@ -1,9 +1,10 @@
import { Box, Text } from '@hermes/ink'
import { memo, type ReactNode, useEffect, useState } from 'react'
import { memo, type ReactNode, useEffect, useMemo, useState } from 'react'
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
import { FACES, VERBS } from '../constants.js'
import {
estimateTokensRough,
fmtK,
formatToolCall,
parseToolTrailResultLine,
pick,
@ -89,6 +90,7 @@ function Chevron({
count,
onClick,
open,
suffix,
t,
title,
tone = 'dim'
@ -96,6 +98,7 @@ function Chevron({
count?: number
onClick: () => void
open: boolean
suffix?: string
t: Theme
title: string
tone?: 'dim' | 'error' | 'warn'
@ -108,6 +111,12 @@ function Chevron({
<Text color={t.color.amber}>{open ? '▾ ' : '▸ '}</Text>
{title}
{typeof count === 'number' ? ` (${count})` : ''}
{suffix ? (
<Text color={t.color.statusFg} dimColor>
{' '}
{suffix}
</Text>
) : null}
</Text>
</Box>
)
@ -128,29 +137,35 @@ export const Thinking = memo(function Thinking({
streaming?: boolean
t: Theme
}) {
const [tick, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick(v => v + 1), 1100)
return () => clearInterval(id)
}, [])
const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
return (
<Box flexDirection="column">
<Text color={t.color.dim}>
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'}{' '}
{VERBS[tick % VERBS.length] ?? 'thinking'}
</Text>
{preview ? (
<Text color={t.color.dim} dimColor wrap={mode === 'full' ? 'wrap-trim' : 'truncate-end'}>
<Text dimColor> </Text>
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
mode === 'full' ? (
<Box flexDirection="row">
<Text color={t.color.dim} dimColor>
{' '}
</Text>
<Box flexDirection="column" flexGrow={1}>
{lines.map((line, index) => (
<Text color={t.color.dim} dimColor key={index} wrap="wrap-trim">
{line || ' '}
{index === lines.length - 1 ? (
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
) : null}
</Text>
))}
</Box>
</Box>
) : (
<Text color={t.color.dim} dimColor wrap="truncate-end">
<Text dimColor> </Text>
{preview}
<StreamCursor color={t.color.dim} dimColor streaming={streaming} visible={active} />
</Text>
)
) : active ? (
<Text color={t.color.dim} dimColor>
<Text dimColor> </Text>
@ -175,9 +190,11 @@ export const ToolTrail = memo(function ToolTrail({
detailsMode = 'collapsed',
reasoningActive = false,
reasoning = '',
reasoningTokens,
reasoningStreaming = false,
t,
tools = [],
toolTokens,
trail = [],
activity = []
}: {
@ -185,9 +202,11 @@ export const ToolTrail = memo(function ToolTrail({
detailsMode?: DetailsMode
reasoningActive?: boolean
reasoning?: string
reasoningTokens?: number
reasoningStreaming?: boolean
t: Theme
tools?: ActiveTool[]
toolTokens?: number
trail?: string[]
activity?: ActivityItem[]
}) {
@ -311,6 +330,17 @@ export const ToolTrail = memo(function ToolTrail({
const hasTools = groups.length > 0
const hasMeta = meta.length > 0
const hasThinking = !!cot || reasoningActive || (busy && !hasTools)
const thinkingLive = reasoningActive || reasoningStreaming
const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0
const toolTokenCount = toolTokens ?? 0
const totalTokenCount = tokenCount + toolTokenCount
const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null
const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined
const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null
// ── Hidden: errors/warnings only ──────────────────────────────
@ -368,6 +398,13 @@ export const ToolTrail = memo(function ToolTrail({
))
: null
const totalBlock = totalTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
<Text color={t.color.amber}>Σ </Text>
{totalTokensLabel}
</Text>
) : null
// ── Expanded: flat, no accordions ──────────────────────────────
if (detailsMode === 'expanded') {
@ -376,6 +413,7 @@ export const ToolTrail = memo(function ToolTrail({
{thinkingBlock}
{toolBlock}
{metaBlock}
{totalBlock}
</Box>
)
}
@ -392,7 +430,20 @@ export const ToolTrail = memo(function ToolTrail({
<Box flexDirection="column">
{hasThinking && (
<>
<Chevron onClick={() => setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" />
<Box onClick={() => setOpenThinking(v => !v)}>
<Text color={t.color.dim} dimColor={!thinkingLive}>
<Text color={t.color.amber}>{openThinking ? '▾ ' : '▸ '}</Text>
<Text bold={thinkingLive} color={thinkingLive ? t.color.cornsilk : t.color.dim} dimColor={!thinkingLive}>
Thinking
</Text>
{thinkingTokensLabel ? (
<Text color={t.color.statusFg} dimColor>
{' '}
{thinkingTokensLabel}
</Text>
) : null}
</Text>
</Box>
{openThinking && thinkingBlock}
</>
)}
@ -403,6 +454,7 @@ export const ToolTrail = memo(function ToolTrail({
count={groups.length}
onClick={() => setOpenTools(v => !v)}
open={openTools}
suffix={toolTokensLabel}
t={t}
title="Tool calls"
/>
@ -423,6 +475,8 @@ export const ToolTrail = memo(function ToolTrail({
{openMeta && metaBlock}
</>
)}
{totalBlock}
</Box>
)
})

View file

@ -1,5 +1,6 @@
import { type ChildProcess, spawn } from 'node:child_process'
import { EventEmitter } from 'node:events'
import { existsSync } from 'node:fs'
import { delimiter, resolve } from 'node:path'
import { createInterface } from 'node:readline'
@ -8,6 +9,39 @@ const MAX_LOG_PREVIEW = 240
const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000)
const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
const resolvePython = (root: string) => {
const configured = process.env.HERMES_PYTHON?.trim()
if (configured) {
return configured
}
const envPython = process.env.PYTHON?.trim()
if (envPython) {
return envPython
}
const venv = process.env.VIRTUAL_ENV?.trim()
const candidates = [
venv ? resolve(venv, 'bin/python') : '',
venv ? resolve(venv, 'Scripts/python.exe') : '',
resolve(root, '.venv/bin/python'),
resolve(root, '.venv/bin/python3'),
resolve(root, 'venv/bin/python'),
resolve(root, 'venv/bin/python3')
].filter(Boolean)
const hit = candidates.find(path => existsSync(path))
if (hit) {
return hit
}
return process.platform === 'win32' ? 'python' : 'python3'
}
export interface GatewayEvent {
type: string
session_id?: string
@ -53,7 +87,7 @@ export class GatewayClient extends EventEmitter {
start() {
const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../')
const python = process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python')
const python = resolvePython(root)
const cwd = process.env.HERMES_CWD || root
const env = { ...process.env }
const pyPath = (env.PYTHONPATH ?? '').trim()

View file

@ -211,7 +211,7 @@ const COMPACT_NUMBER = new Intl.NumberFormat('en-US', {
notation: 'compact'
})
export const fmtK = (n: number) => COMPACT_NUMBER.format(n)
export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase())
export const hasInterpolation = (s: string) => {
INTERPOLATION_RE.lastIndex = 0

View file

@ -29,6 +29,8 @@ export interface Msg {
info?: SessionInfo
panelData?: PanelData
thinking?: string
thinkingTokens?: number
toolTokens?: number
tools?: string[]
}