mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: sexier cots
This commit is contained in:
parent
6e24b9947e
commit
7e813a30e0
6 changed files with 191 additions and 60 deletions
|
|
@ -20,9 +20,9 @@ describe('constants', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('TOOL_VERBS maps known tools', () => {
|
||||
expect(TOOL_VERBS.terminal).toContain('terminal')
|
||||
expect(TOOL_VERBS.read_file).toContain('reading')
|
||||
it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => {
|
||||
expect(TOOL_VERBS.terminal).toBe('terminal')
|
||||
expect(TOOL_VERBS.read_file).toBe('reading')
|
||||
})
|
||||
|
||||
it('INTERPOLATION_RE matches {!cmd}', () => {
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fmtK, sameToolTrailGroup } from '../lib/text.js'
|
||||
import { fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js'
|
||||
|
||||
describe('isToolTrailResultLine', () => {
|
||||
it('detects completion markers', () => {
|
||||
expect(isToolTrailResultLine('foo ✓')).toBe(true)
|
||||
expect(isToolTrailResultLine('foo ✗')).toBe(true)
|
||||
expect(isToolTrailResultLine('drafting x…')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastCotTrailIndex', () => {
|
||||
it('finds last non-result line', () => {
|
||||
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
|
||||
expect(lastCotTrailIndex(['only result ✓'])).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameToolTrailGroup', () => {
|
||||
it('matches bare check lines', () => {
|
||||
expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✗')).toBe(true)
|
||||
expect(sameToolTrailGroup('searching', 'searching ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('searching', 'searching ✗')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches contextual lines', () => {
|
||||
expect(sameToolTrailGroup('🔍 searching', '🔍 searching: * ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('🔍 searching', '🔍 searching: foo ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('searching', 'searching: * ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('searching', 'searching: foo ✓')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects other tools', () => {
|
||||
expect(sameToolTrailGroup('🔍 searching', '📖 reading ✓')).toBe(false)
|
||||
expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false)
|
||||
expect(sameToolTrailGroup('searching', 'reading ✓')).toBe(false)
|
||||
expect(sameToolTrailGroup('searching', 'searching extra ✓')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js'
|
|||
import { useInputHistory } from './hooks/useInputHistory.js'
|
||||
import { useQueue } from './hooks/useQueue.js'
|
||||
import { writeOsc52Clipboard } from './lib/osc52.js'
|
||||
import { compactPreview, fmtK, hasInterpolation, pick, sameToolTrailGroup } from './lib/text.js'
|
||||
import { compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js'
|
||||
import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js'
|
||||
import type {
|
||||
ActiveTool,
|
||||
|
|
@ -363,6 +363,19 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
})
|
||||
}, [])
|
||||
|
||||
const pushTrail = useCallback((line: string) => {
|
||||
setTurnTrail(prev => {
|
||||
if (prev.at(-1) === line) {
|
||||
return prev
|
||||
}
|
||||
|
||||
const next = [...prev, line].slice(-8)
|
||||
turnToolsRef.current = next
|
||||
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const rpc = useCallback(
|
||||
(method: string, params: Record<string, unknown> = {}) =>
|
||||
gw.request(method, params).catch((e: Error) => {
|
||||
|
|
@ -1067,6 +1080,13 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
|
||||
break
|
||||
|
||||
case 'tool.generating':
|
||||
if (p?.name) {
|
||||
pushTrail(`drafting ${p.name}…`)
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'tool.start':
|
||||
setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }])
|
||||
|
||||
|
|
@ -1082,11 +1102,18 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}`
|
||||
|
||||
toolCompleteRibbonRef.current = { label, line }
|
||||
const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8)
|
||||
turnToolsRef.current = next
|
||||
setTurnTrail(next)
|
||||
const remaining = prev.filter(t => t.id !== p.tool_id)
|
||||
const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line]
|
||||
|
||||
return prev.filter(t => t.id !== p.tool_id)
|
||||
if (!remaining.length) {
|
||||
next.push('analyzing tool output…')
|
||||
}
|
||||
|
||||
const pruned = next.slice(-8)
|
||||
turnToolsRef.current = pruned
|
||||
setTurnTrail(pruned)
|
||||
|
||||
return remaining
|
||||
})
|
||||
|
||||
break
|
||||
|
|
@ -1148,7 +1175,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
case 'message.complete': {
|
||||
const wasInterrupted = interruptedRef.current
|
||||
const savedReasoning = reasoningRef.current.trim()
|
||||
const savedTools = [...turnToolsRef.current]
|
||||
const savedTools = turnToolsRef.current.filter(isToolTrailResultLine)
|
||||
const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart()
|
||||
|
||||
idle()
|
||||
|
|
@ -1204,7 +1231,7 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
break
|
||||
}
|
||||
},
|
||||
[appendMessage, dequeue, newSession, pushActivity, send, sys]
|
||||
[appendMessage, dequeue, newSession, pushActivity, pushTrail, send, sys]
|
||||
)
|
||||
|
||||
onEventRef.current = onEvent
|
||||
|
|
@ -1789,9 +1816,15 @@ export function App({ gw }: { gw: GatewayClient }) {
|
|||
))}
|
||||
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<ToolTrail t={theme} tools={tools} trail={turnTrail} />
|
||||
<ToolTrail
|
||||
activity={busy ? activity : []}
|
||||
animateCot={busy && !streaming}
|
||||
t={theme}
|
||||
tools={tools}
|
||||
trail={turnTrail}
|
||||
/>
|
||||
|
||||
{thinking && !tools.length && !streaming && <Thinking key={turnKey} reasoning={reasoning} t={theme} />}
|
||||
{busy && !tools.length && !streaming && <Thinking key={turnKey} reasoning={reasoning} t={theme} />}
|
||||
|
||||
{streaming && (
|
||||
<MessageLine cols={cols} compact={compact} msg={{ role: 'assistant', text: streaming }} t={theme} />
|
||||
|
|
|
|||
|
|
@ -3,16 +3,29 @@ import { memo, useEffect, useState } from 'react'
|
|||
import spinners, { type BrailleSpinnerName } from 'unicode-animations'
|
||||
|
||||
import { FACES, TOOL_VERBS, VERBS } from '../constants.js'
|
||||
import { isToolTrailResultLine, lastCotTrailIndex } from '../lib/text.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
import type { ActiveTool } from '../types.js'
|
||||
import type { ActiveTool, ActivityItem } from '../types.js'
|
||||
|
||||
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)]!
|
||||
|
||||
const tone = (item: ActivityItem, t: Theme) =>
|
||||
item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim
|
||||
|
||||
const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '⚠' : '·')
|
||||
|
||||
const TreeFork = ({ last }: { last: boolean }) => <Text dimColor>{last ? '└─ ' : '├─ '}</Text>
|
||||
|
||||
export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) {
|
||||
const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL : THINK)])
|
||||
const [spin] = useState(() => {
|
||||
const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)]
|
||||
|
||||
return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') }
|
||||
})
|
||||
|
||||
const [frame, setFrame] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -27,30 +40,81 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?:
|
|||
export const ToolTrail = memo(function ToolTrail({
|
||||
t,
|
||||
tools = [],
|
||||
trail = []
|
||||
trail = [],
|
||||
activity = [],
|
||||
animateCot = false
|
||||
}: {
|
||||
t: Theme
|
||||
tools?: ActiveTool[]
|
||||
trail?: string[]
|
||||
activity?: ActivityItem[]
|
||||
animateCot?: boolean
|
||||
}) {
|
||||
if (!trail.length && !tools.length) {
|
||||
if (!trail.length && !tools.length && !activity.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const act = activity.slice(-4)
|
||||
const rowCount = trail.length + tools.length + act.length
|
||||
const activeCotIdx = animateCot && !tools.length ? lastCotTrailIndex(trail) : -1
|
||||
|
||||
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>
|
||||
))}
|
||||
{trail.map((line, i) => {
|
||||
const lastInBlock = i === rowCount - 1
|
||||
|
||||
{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>
|
||||
))}
|
||||
if (isToolTrailResultLine(line)) {
|
||||
return (
|
||||
<Text
|
||||
color={line.endsWith(' ✗') ? t.color.error : t.color.dim}
|
||||
dimColor={!line.endsWith(' ✗')}
|
||||
key={`t-${i}`}
|
||||
>
|
||||
<TreeFork last={lastInBlock} />
|
||||
{line}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (i === activeCotIdx) {
|
||||
return (
|
||||
<Text color={t.color.dim} key={`c-${i}`}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
<Spinner color={t.color.amber} variant="think" /> {line}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim} dimColor key={`c-${i}`}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
{line}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{tools.map((tool, j) => {
|
||||
const lastInBlock = trail.length + j === rowCount - 1
|
||||
|
||||
return (
|
||||
<Text color={t.color.dim} key={tool.id}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
<Spinner color={t.color.amber} variant="tool" /> {TOOL_VERBS[tool.name] ?? tool.name}
|
||||
{tool.context ? `: ${tool.context}` : ''}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
|
||||
{act.map((item, k) => {
|
||||
const lastInBlock = trail.length + tools.length + k === rowCount - 1
|
||||
|
||||
return (
|
||||
<Text color={tone(item, t)} dimColor={item.tone === 'info'} key={`a-${item.id}`}>
|
||||
<TreeFork last={lastInBlock} />
|
||||
{activityGlyph(item)} {item.text}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
@ -66,14 +130,18 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st
|
|||
|
||||
const tail = reasoning.slice(-160).replace(/\n/g, ' ')
|
||||
|
||||
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>
|
||||
return (
|
||||
<>
|
||||
<Text color={t.color.dim}>
|
||||
<Spinner color={t.color.dim} /> {FACES[tick % FACES.length] ?? '(•_•)'}{' '}
|
||||
{VERBS[tick % VERBS.length] ?? 'thinking'}…
|
||||
</Text>
|
||||
|
||||
{tail ? (
|
||||
<Text color={t.color.dim} dimColor wrap="truncate-end">
|
||||
💭 {tail}
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -60,23 +60,24 @@ export const ROLE: Record<Role, (t: Theme) => { body: string; glyph: string; pre
|
|||
}
|
||||
|
||||
export const TOOL_VERBS: Record<string, string> = {
|
||||
browser: '🌐 browsing',
|
||||
clarify: '❓ asking',
|
||||
create_file: '📝 creating',
|
||||
delegate_task: '🤖 delegating',
|
||||
delete_file: '🗑️ deleting',
|
||||
execute_code: '⚡ executing',
|
||||
image_generate: '🎨 generating',
|
||||
list_files: '📂 listing',
|
||||
memory: '🧠 remembering',
|
||||
patch: '🩹 patching',
|
||||
read_file: '📖 reading',
|
||||
run_command: '⚙️ running',
|
||||
search_code: '🔍 searching',
|
||||
search_files: '🔍 searching',
|
||||
terminal: '💻 terminal',
|
||||
web_search: '🌐 searching',
|
||||
write_file: '✏️ writing'
|
||||
browser: 'browsing',
|
||||
clarify: 'asking',
|
||||
create_file: 'creating',
|
||||
delegate_task: 'delegating',
|
||||
delete_file: 'deleting',
|
||||
execute_code: 'executing',
|
||||
image_generate: 'generating',
|
||||
list_files: 'listing',
|
||||
memory: 'remembering',
|
||||
patch: 'patching',
|
||||
read_file: 'reading',
|
||||
run_command: 'running',
|
||||
search_code: 'searching',
|
||||
search_files: 'searching',
|
||||
terminal: 'terminal',
|
||||
web_extract: 'extracting',
|
||||
web_search: 'searching',
|
||||
write_file: 'writing'
|
||||
}
|
||||
|
||||
export const VERBS = [
|
||||
|
|
|
|||
|
|
@ -35,10 +35,24 @@ export const compactPreview = (s: string, max: number) => {
|
|||
return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one
|
||||
}
|
||||
|
||||
/** Tool completed / failed row in the inline trail (not CoT prose). */
|
||||
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
|
||||
|
||||
/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */
|
||||
export const sameToolTrailGroup = (label: string, entry: string) =>
|
||||
entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`)
|
||||
|
||||
/** Index of the last non-result trail line, or -1. */
|
||||
export const lastCotTrailIndex = (trail: readonly string[]) => {
|
||||
for (let i = trail.length - 1; i >= 0; i--) {
|
||||
if (!isToolTrailResultLine(trail[i]!)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
export const estimateRows = (text: string, w: number, compact = false) => {
|
||||
let inCode = false
|
||||
let rows = 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue