diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 655e3903db..19d0633951 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 78cac4f880..e172cabc27 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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: diff --git a/ui-tui/README.md b/ui-tui/README.md index 19d162e6da..b4417d3cc3 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -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 }` | diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts new file mode 100644 index 0000000000..86489e334a --- /dev/null +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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 = (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()), + protocolWarnedRef: ref(false), + reasoningRef: ref('mapped the page'), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const onEvent = createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(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()), + protocolWarnedRef: ref(false), + reasoningRef: ref('mapped the page'), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const buildHandler = () => + createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(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()), + protocolWarnedRef: ref(false), + reasoningRef: ref(''), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const onEvent = createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(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)) + }) +}) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 181b96b43f..0a11e3cc06 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -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') }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 79edcce289..d071ba7867 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -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 }} diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 8c3158017c..6afd5c094f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 - sendQueued: (text: string) => void - } - gateway: GatewayServices - session: { - STARTUP_RESUME_ID: string - colsRef: MutableRefObject - newSession: (msg?: string) => void - resetSession: () => void - setCatalog: Dispatch> - } - system: { - bellOnComplete: boolean - stdout?: NodeJS.WriteStream - sys: (text: string) => void - } - transcript: { - appendMessage: (msg: Msg) => void - setHistoryItems: Dispatch> - setMessages: Dispatch> - } - 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([]) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 9f5df4ca92..eb8fd7eb5f 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -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 - selection: { - copySelection: () => string - } - setInput: Dispatch> - } - gateway: GatewayServices - local: { - catalog: SlashCatalog | null - lastUserMsg: string - maybeWarn: (value: any) => void - messages: Msg[] - } - session: { - closeSession: (targetSid?: string | null) => Promise - die: () => void - guardBusySessionSwitch: (what?: string) => boolean - newSession: (msg?: string) => void - resetVisibleHistory: (info?: SessionInfo | null) => void - resumeById: (id: string) => void - setSessionStartedAt: Dispatch> - } - transcript: { - page: (text: string, title?: string) => void - panel: (title: string, sections: PanelSection[]) => void - send: (text: string) => void - setHistoryItems: Dispatch> - setMessages: Dispatch> - sys: (text: string) => void - trimLastExchange: (items: Msg[]) => Msg[] - } - voice: { - setVoiceEnabled: Dispatch> - } -} - export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer const { gw, rpc } = ctx.gateway diff --git a/ui-tui/src/app/gatewayContext.tsx b/ui-tui/src/app/gatewayContext.tsx index cdd9347fb0..9187f15a3a 100644 --- a/ui-tui/src/app/gatewayContext.tsx +++ b/ui-tui/src/app/gatewayContext.tsx @@ -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(null) -export interface GatewayProviderProps { - children: ReactNode - value: GatewayServices -} - export function GatewayProvider({ children, value }: GatewayProviderProps) { return {children} } diff --git a/ui-tui/src/app/helpers.ts b/ui-tui/src/app/helpers.ts index 350687d741..8496008c7a 100644 --- a/ui-tui/src/app/helpers.ts +++ b/ui-tui/src/app/helpers.ts @@ -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() : '' diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c4611f9dc4..549e85fe24 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -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 { + (value: SetStateAction): 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): Promise + (method: string, params?: Record): Promise } 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 + setHistoryIdx: StateSetter + setInput: StateSetter + setInputBuf: StateSetter + setPasteSnips: StateSetter + setQueueEdit: (index: number | null) => void + syncQueue: () => void +} + +export interface ComposerRefs { + historyDraftRef: MutableRefObject + historyRef: MutableRefObject + queueEditRef: MutableRefObject + queueRef: MutableRefObject + 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 + 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) => Promise } + 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 + setReasoning: StateSetter + setReasoningTokens: StateSetter + setReasoningActive: StateSetter + setToolTokens: StateSetter + setReasoningStreaming: StateSetter + setStreaming: StateSetter + setTools: StateSetter + setTurnTrail: StateSetter +} + +export interface TurnRefs { + activeToolsRef: MutableRefObject + bufRef: MutableRefObject + interruptedRef: MutableRefObject + lastStatusNoteRef: MutableRefObject + persistedToolLabelsRef: MutableRefObject> + protocolWarnedRef: MutableRefObject + reasoningRef: MutableRefObject + reasoningStreamingTimerRef: MutableRefObject | null> + reasoningTimerRef: MutableRefObject | null> + statusTimerRef: MutableRefObject | null> + streamTimerRef: MutableRefObject | null> + toolTokenAccRef: MutableRefObject + toolCompleteRibbonRef: MutableRefObject + turnToolsRef: MutableRefObject +} + +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 + scrollWithSelection: (delta: number) => void + selection: SelectionApi + stdout?: NodeJS.WriteStream + } + turn: { + actions: TurnActions + refs: TurnRefs + } + voice: { + recording: boolean + setProcessing: StateSetter + setRecording: StateSetter + } + wheelStep: number +} + +export interface InputHandlerResult { + pagerPageSize: number +} + +export interface GatewayEventHandlerContext { + composer: { + dequeue: () => string | undefined + queueEditRef: MutableRefObject + sendQueued: (text: string) => void + } + gateway: GatewayServices + session: { + STARTUP_RESUME_ID: string + colsRef: MutableRefObject + newSession: (msg?: string) => void + resetSession: () => void + setCatalog: StateSetter + } + system: { + bellOnComplete: boolean + stdout?: NodeJS.WriteStream + sys: (text: string) => void + } + transcript: { + appendMessage: (msg: Msg) => void + setHistoryItems: StateSetter + setMessages: StateSetter + } + 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 + selection: SelectionApi + setInput: StateSetter + } + gateway: GatewayServices + local: { + catalog: SlashCatalog | null + lastUserMsg: string + maybeWarn: (value: any) => void + messages: Msg[] + } + session: { + closeSession: (targetSid?: string | null) => Promise + die: () => void + guardBusySessionSwitch: (what?: string) => boolean + newSession: (msg?: string) => void + resetVisibleHistory: (info?: SessionInfo | null) => void + resumeById: (id: string) => void + setSessionStartedAt: StateSetter + } + transcript: { + page: (text: string, title?: string) => void + panel: (title: string, sections: PanelSection[]) => void + send: (text: string) => void + setHistoryItems: StateSetter + setMessages: StateSetter + sys: (text: string) => void + trimLastExchange: (items: Msg[]) => Msg[] + } + voice: { + setVoiceEnabled: StateSetter + } +} + +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 +} + +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 + 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 +} diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 7e8b317534..8d3df69ee0 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -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> - setHistoryIdx: Dispatch> - setInput: Dispatch> - setInputBuf: Dispatch> - setPasteSnips: Dispatch> - setQueueEdit: (index: number | null) => void - syncQueue: () => void -} - -export interface ComposerRefs { - historyDraftRef: MutableRefObject - historyRef: MutableRefObject - queueEditRef: MutableRefObject - queueRef: MutableRefObject - 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 - 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([]) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 1db4594b94..3f23d3e6c1 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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 - scrollWithSelection: (delta: number) => void - selection: { - copySelection: () => string - } - stdout?: NodeJS.WriteStream - } - turn: { - actions: TurnActions - refs: TurnRefs - } - voice: { - recording: boolean - setProcessing: Dispatch> - setRecording: Dispatch> - } - wheelStep: number -} - -export interface InputHandlerResult { - pagerPageSize: number -} export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts index a6a611bc60..e78b7f489c 100644 --- a/ui-tui/src/app/useTurnState.ts +++ b/ui-tui/src/app/useTurnState.ts @@ -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) => Promise } - 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> - setReasoning: Dispatch> - setReasoningActive: Dispatch> - setReasoningStreaming: Dispatch> - setStreaming: Dispatch> - setTools: Dispatch> - setTurnTrail: Dispatch> -} - -export interface TurnRefs { - bufRef: MutableRefObject - interruptedRef: MutableRefObject - lastStatusNoteRef: MutableRefObject - persistedToolLabelsRef: MutableRefObject> - protocolWarnedRef: MutableRefObject - reasoningRef: MutableRefObject - reasoningStreamingTimerRef: MutableRefObject | null> - reasoningTimerRef: MutableRefObject | null> - statusTimerRef: MutableRefObject | null> - streamTimerRef: MutableRefObject | null> - toolCompleteRibbonRef: MutableRefObject - turnToolsRef: MutableRefObject -} - -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([]) 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([]) const [turnTrail, setTurnTrail] = useState([]) const activityIdRef = useRef(0) + const activeToolsRef = useRef([]) const bufRef = useRef('') const interruptedRef = useRef(false) const lastStatusNoteRef = useRef('') @@ -94,6 +31,7 @@ export function useTurnState(): UseTurnStateResult { const reasoningTimerRef = useRef | null>(null) const statusTimerRef = useRef | null>(null) const streamTimerRef = useRef | null>(null) + const toolTokenAccRef = useRef(0) const toolCompleteRibbonRef = useRef(null) const turnToolsRef = useRef([]) @@ -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, diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index be33502ee9..46bd330c1a 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -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 - 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} /> )} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index e3b646edde..35927f0bdd 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -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, diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index dbcfeb6071..392b01c49a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -85,7 +85,14 @@ export const MessageLine = memo(function MessageLine({ > {showDetails && ( - + )} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 04f42ec162..8d75713d01 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -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({ {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} + {suffix ? ( + + {' '} + {suffix} + + ) : null} ) @@ -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 ( - - {FACES[tick % FACES.length] ?? '(•_•)'}{' '} - {VERBS[tick % VERBS.length] ?? 'thinking'}… - - {preview ? ( - - - {preview} - - + mode === 'full' ? ( + + + └{' '} + + + {lines.map((line, index) => ( + + {line || ' '} + {index === lines.length - 1 ? ( + + ) : null} + + ))} + + + ) : ( + + + {preview} + + + ) ) : active ? ( @@ -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 ? ( + + Σ + {totalTokensLabel} + + ) : null + // ── Expanded: flat, no accordions ────────────────────────────── if (detailsMode === 'expanded') { @@ -376,6 +413,7 @@ export const ToolTrail = memo(function ToolTrail({ {thinkingBlock} {toolBlock} {metaBlock} + {totalBlock} ) } @@ -392,7 +430,20 @@ export const ToolTrail = memo(function ToolTrail({ {hasThinking && ( <> - setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" /> + setOpenThinking(v => !v)}> + + {openThinking ? '▾ ' : '▸ '} + + Thinking + + {thinkingTokensLabel ? ( + + {' '} + {thinkingTokensLabel} + + ) : null} + + {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} ) }) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index a35f3c417b..ffa06377b4 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -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() diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index b17eff3eea..9d6a9a58e0 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -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 diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index aac00d667a..90eef0c630 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -29,6 +29,8 @@ export interface Msg { info?: SessionInfo panelData?: PanelData thinking?: string + thinkingTokens?: number + toolTokens?: number tools?: string[] }