mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add inline token count etc and fix venv
This commit is contained in:
parent
561cea0d4a
commit
33c615504d
21 changed files with 984 additions and 459 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 }` |
|
||||
|
|
|
|||
354
ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Normal file
354
ui-tui/src/__tests__/createGatewayEventHandler.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() : ''
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]>([])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export interface Msg {
|
|||
info?: SessionInfo
|
||||
panelData?: PanelData
|
||||
thinking?: string
|
||||
thinkingTokens?: number
|
||||
toolTokens?: number
|
||||
tools?: string[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue