mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(ui-tui): render tool calls inline in message flow instead of activity lane
This commit is contained in:
parent
99fd3b518d
commit
6e24b9947e
3 changed files with 66 additions and 65 deletions
|
|
@ -6,7 +6,6 @@ import { join } from 'node:path'
|
|||
import { Box, Text, useApp, useInput, useStdout } from 'ink'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ActivityLane } from './components/activityLane.js'
|
||||
import { Banner, SessionPanel } from './components/branding.js'
|
||||
import { MaskedPrompt } from './components/maskedPrompt.js'
|
||||
import { MessageLine } from './components/messageLine.js'
|
||||
|
|
@ -14,7 +13,7 @@ import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js'
|
|||
import { QueuedMessages } from './components/queuedMessages.js'
|
||||
import { SessionPicker } from './components/sessionPicker.js'
|
||||
import { type PasteEvent, TextInput } from './components/textInput.js'
|
||||
import { Thinking } from './components/thinking.js'
|
||||
import { Thinking, ToolTrail } from './components/thinking.js'
|
||||
import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js'
|
||||
import { type GatewayClient, type GatewayEvent } from './gatewayClient.js'
|
||||
import { useCompletion } from './hooks/useCompletion.js'
|
||||
|
|
@ -278,6 +277,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const [pastes, setPastes] = useState<PendingPaste[]>([])
|
||||
const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null)
|
||||
const [streaming, setStreaming] = useState('')
|
||||
const [turnTrail, setTurnTrail] = useState<string[]>([])
|
||||
const [bgTasks, setBgTasks] = useState<Set<string>>(new Set())
|
||||
const [catalog, setCatalog] = useState<SlashCatalog | null>(null)
|
||||
|
||||
|
|
@ -374,6 +374,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const idle = () => {
|
||||
setThinking(false)
|
||||
setTools([])
|
||||
setTurnTrail([])
|
||||
setBusy(false)
|
||||
setClarify(null)
|
||||
setApproval(null)
|
||||
|
|
@ -1005,6 +1006,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
setBusy(true)
|
||||
setReasoning('')
|
||||
setActivity([])
|
||||
setTurnTrail([])
|
||||
turnToolsRef.current = []
|
||||
|
||||
break
|
||||
|
|
@ -1021,7 +1023,11 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info'
|
||||
)
|
||||
}
|
||||
if (statusTimerRef.current) clearTimeout(statusTimerRef.current)
|
||||
|
||||
if (statusTimerRef.current) {
|
||||
clearTimeout(statusTimerRef.current)
|
||||
}
|
||||
|
||||
statusTimerRef.current = setTimeout(() => {
|
||||
statusTimerRef.current = null
|
||||
setStatus(busyRef.current ? 'running…' : 'ready')
|
||||
|
|
@ -1067,7 +1073,6 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
break
|
||||
case 'tool.complete': {
|
||||
const mark = p.error ? '✗' : '✓'
|
||||
const tone = p.error ? 'error' : 'info'
|
||||
|
||||
toolCompleteRibbonRef.current = null
|
||||
setTools(prev => {
|
||||
|
|
@ -1077,16 +1082,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}`
|
||||
|
||||
toolCompleteRibbonRef.current = { label, line }
|
||||
turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8)
|
||||
const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8)
|
||||
turnToolsRef.current = next
|
||||
setTurnTrail(next)
|
||||
|
||||
return prev.filter(t => t.id !== p.tool_id)
|
||||
})
|
||||
|
||||
if (toolCompleteRibbonRef.current) {
|
||||
const { line, label } = toolCompleteRibbonRef.current
|
||||
pushActivity(line, tone, label)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -1787,16 +1789,14 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
))}
|
||||
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<ToolTrail t={theme} tools={tools} trail={turnTrail} />
|
||||
|
||||
{thinking && !tools.length && !streaming && <Thinking key={turnKey} reasoning={reasoning} t={theme} />}
|
||||
|
||||
{streaming && (
|
||||
<MessageLine cols={cols} compact={compact} msg={{ role: 'assistant', text: streaming }} t={theme} />
|
||||
)}
|
||||
|
||||
{(thinking || tools.length > 0) && (!streaming || tools.length > 0) && (
|
||||
<Thinking key={turnKey} reasoning={reasoning} t={theme} tools={tools} />
|
||||
)}
|
||||
|
||||
{busy && <ActivityLane items={activity} t={theme} />}
|
||||
|
||||
{pasteReview && (
|
||||
<PromptBox color={theme.color.warn}>
|
||||
<Text bold color={theme.color.warn}>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Theme } from '../theme.js'
|
|||
import type { Msg } from '../types.js'
|
||||
|
||||
import { Md } from './markdown.js'
|
||||
import { ToolTrail } from './thinking.js'
|
||||
|
||||
export const MessageLine = memo(function MessageLine({
|
||||
cols,
|
||||
|
|
@ -19,8 +20,6 @@ export const MessageLine = memo(function MessageLine({
|
|||
msg: Msg
|
||||
t: Theme
|
||||
}) {
|
||||
const { body, glyph, prefix } = ROLE[msg.role](t)
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
return (
|
||||
<Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
|
||||
|
|
@ -29,6 +28,8 @@ export const MessageLine = memo(function MessageLine({
|
|||
)
|
||||
}
|
||||
|
||||
const { body, glyph, prefix } = ROLE[msg.role](t)
|
||||
|
||||
const content = (() => {
|
||||
if (msg.role === 'assistant') {
|
||||
return hasAnsi(msg.text) ? <Text wrap="wrap">{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
|
||||
|
|
@ -59,6 +60,12 @@ export const MessageLine = memo(function MessageLine({
|
|||
</Text>
|
||||
)}
|
||||
|
||||
{msg.tools?.length ? (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<ToolTrail t={t} trail={msg.tools} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Box>
|
||||
<Box flexShrink={0} width={3}>
|
||||
<Text bold={msg.role === 'user'} color={prefix}>
|
||||
|
|
@ -68,20 +75,6 @@ export const MessageLine = memo(function MessageLine({
|
|||
|
||||
<Box width={Math.max(20, cols - 5)}>{content}</Box>
|
||||
</Box>
|
||||
|
||||
{!!msg.tools?.length && (
|
||||
<Box flexDirection="column" marginBottom={1} marginTop={1}>
|
||||
{msg.tools.map((tool, i) => (
|
||||
<Text
|
||||
color={tool.endsWith(' ✗') ? t.color.error : t.color.dim}
|
||||
dimColor={!tool.endsWith(' ✗')}
|
||||
key={`${tool}-${i}`}
|
||||
>
|
||||
{t.brand.tool} {tool}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import { FACES, TOOL_VERBS, VERBS } from '../constants.js'
|
|||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool } from '../types.js'
|
||||
|
||||
const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse']
|
||||
const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle']
|
||||
|
||||
const pick = <T,>(a: T[]) => a[Math.floor(Math.random() * a.length)]!
|
||||
|
||||
function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
||||
const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)])
|
||||
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
||||
const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL : THINK)])
|
||||
const [frame, setFrame] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -24,15 +24,38 @@ function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think
|
|||
return <Text color={color}>{spin.frames[frame]}</Text>
|
||||
}
|
||||
|
||||
export const Thinking = memo(function Thinking({
|
||||
reasoning,
|
||||
export const ToolTrail = memo(function ToolTrail({
|
||||
t,
|
||||
tools
|
||||
tools = [],
|
||||
trail = []
|
||||
}: {
|
||||
reasoning: string
|
||||
t: Theme
|
||||
tools: ActiveTool[]
|
||||
tools?: ActiveTool[]
|
||||
trail?: string[]
|
||||
}) {
|
||||
if (!trail.length && !tools.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{trail.map((line, i) => (
|
||||
<Text color={line.endsWith(' ✗') ? t.color.error : t.color.dim} dimColor={!line.endsWith(' ✗')} key={`t-${i}`}>
|
||||
{t.brand.tool} {line}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{tools.map(tool => (
|
||||
<Text color={t.color.dim} key={tool.id}>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
|
||||
{tool.context ? `: ${tool.context}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) {
|
||||
const [tick, setTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -41,31 +64,16 @@ export const Thinking = memo(function Thinking({
|
|||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
const verb = VERBS[tick % VERBS.length] ?? 'thinking'
|
||||
const face = FACES[tick % FACES.length] ?? '(•_•)'
|
||||
const tail = reasoning.slice(-160).replace(/\n/g, ' ')
|
||||
const hasReasoning = !!tail
|
||||
|
||||
return (
|
||||
<>
|
||||
{tools.map(tool => (
|
||||
<Text color={t.color.dim} key={tool.id}>
|
||||
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
|
||||
{tool.context ? `: ${tool.context}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
{!tools.length && !hasReasoning && (
|
||||
<Text color={t.color.dim}>
|
||||
<Spinner color={t.color.dim} /> {face} {verb}…
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{tail && (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
💭 {tail}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
return tail ? (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
💭 {tail}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={t.color.dim}>
|
||||
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'} {VERBS[tick % VERBS.length] ?? 'thinking'}
|
||||
…
|
||||
</Text>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue