feat(ui-tui): render tool calls inline in message flow instead of activity lane

This commit is contained in:
Brooklyn Nicholson 2026-04-09 17:40:30 -05:00
parent 99fd3b518d
commit 6e24b9947e
3 changed files with 66 additions and 65 deletions

View file

@ -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>
)
})

View file

@ -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>
)
})