fix(tui): restore resumed transcript lineage

This commit is contained in:
Brooklyn Nicholson 2026-04-26 15:16:12 -05:00
parent 350ee1bf23
commit d4dde6b5f2
11 changed files with 537 additions and 49 deletions

View file

@ -1,7 +1,26 @@
import { describe, expect, it } from 'vitest'
import { toTranscriptMessages } from '../domain/messages.js'
import { upsert } from '../lib/messages.js'
describe('toTranscriptMessages', () => {
it('preserves assistant tool-call rows so resume does not drop prior turns', () => {
const rows = [
{ role: 'user', text: 'first prompt' },
{ role: 'tool', context: 'repo', name: 'search_files', text: 'ignored raw result' },
{ role: 'assistant', text: 'first answer' },
{ role: 'user', text: 'second prompt' }
]
expect(toTranscriptMessages(rows).map(msg => [msg.role, msg.text])).toEqual([
['user', 'first prompt'],
['assistant', 'first answer'],
['user', 'second prompt']
])
expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files')
})
})
describe('upsert', () => {
it('appends when last role differs', () => {
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)

View file

@ -1,14 +1,18 @@
import { describe, expect, it } from 'vitest'
import {
boundedLiveRenderText,
buildToolTrailLine,
edgePreview,
estimateRows,
estimateTokensRough,
fmtK,
isToolTrailResultLine,
lastCotTrailIndex,
parseToolTrailResultLine,
pasteTokenLabel,
sameToolTrailGroup
sameToolTrailGroup,
splitToolDuration
} from '../lib/text.js'
describe('isToolTrailResultLine', () => {
@ -19,6 +23,16 @@ describe('isToolTrailResultLine', () => {
})
})
describe('buildToolTrailLine', () => {
it('puts completion duration inline before the result marker', () => {
const line = buildToolTrailLine('read_file', 'x', false, '', 0.94)
expect(line).toBe('Read File("x") (0.9s) ✓')
expect(parseToolTrailResultLine(line)).toEqual({ call: 'Read File("x") (0.9s)', detail: '', mark: '✓' })
expect(splitToolDuration('Read File("x") (0.9s)')).toEqual({ label: 'Read File("x")', duration: ' (0.9s)' })
})
})
describe('lastCotTrailIndex', () => {
it('finds last non-result line', () => {
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
@ -68,6 +82,28 @@ describe('estimateTokensRough', () => {
})
})
describe('boundedLiveRenderText', () => {
it('preserves short live text verbatim', () => {
expect(boundedLiveRenderText('one\ntwo', { maxChars: 100, maxLines: 10 })).toBe('one\ntwo')
})
it('keeps the live tail by character budget', () => {
const out = boundedLiveRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
expect(out).toContain('ghij')
expect(out).toContain('omitted')
expect(out).not.toContain('abcdef')
})
it('keeps the live tail by line budget', () => {
const out = boundedLiveRenderText(['a', 'b', 'c', 'd'].join('\n'), { maxChars: 100, maxLines: 2 })
expect(out).toContain('c\nd')
expect(out).toContain('omitted 2 lines')
expect(out).not.toContain('a\nb')
})
})
describe('edgePreview', () => {
it('keeps both ends for long text', () => {
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(

View file

@ -2,18 +2,20 @@ import {
REASONING_PULSE_MS,
STREAM_BATCH_MS,
STREAM_IDLE_BATCH_MS,
STREAM_SCROLL_BATCH_MS,
STREAM_TYPING_BATCH_MS
} from '../config/timing.js'
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import {
boundedLiveRenderText,
buildToolTrailLine,
estimateTokensRough,
isTransientTrailLine,
sameToolTrailGroup,
toolTrailLabel
} from '../lib/text.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
import { resetFlowOverlays } from './overlayStore.js'
import { pushSnapshot } from './spawnHistoryStore.js'
@ -40,7 +42,52 @@ const diffSegmentBody = (msg: Msg): null | string => {
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
const textSegments = (segments: Msg[]) => segments.filter(msg => msg.role === 'assistant' && msg.kind !== 'diff').map(msg => msg.text)
const isToolOnly = (msg: Msg | undefined) =>
Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length)
const mergeSequentialToolOnly = (segments: Msg[]) =>
segments.reduce<Msg[]>((acc, msg) => {
if (isToolOnly(msg) && isToolOnly(acc.at(-1))) {
const prev = acc.at(-1)!
return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }]
}
return [...acc, msg]
}, [])
const isTodoStatus = (status: unknown): status is TodoItem['status'] =>
status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled'
const parseTodos = (value: unknown): null | TodoItem[] => {
if (!Array.isArray(value)) {
return null
}
return value
.map(item => {
if (!item || typeof item !== 'object') {
return null
}
const row = item as Record<string, unknown>
const status = row.status
if (!isTodoStatus(status)) {
return null
}
return {
content: String(row.content ?? '').trim(),
id: String(row.id ?? '').trim(),
status
}
})
.filter((item): item is TodoItem => Boolean(item?.id && item.content))
}
const textSegments = (segments: Msg[]) =>
segments.filter(msg => msg.role === 'assistant' && msg.kind !== 'diff').map(msg => msg.text)
const finalTail = (finalText: string, segments: Msg[]) => {
let tail = finalText
@ -88,6 +135,7 @@ class TurnController {
turnTools: string[] = []
private activeTools: ActiveTool[] = []
private activeReasoningText = ''
private reasoningSegmentIndex: null | number = null
private activityId = 0
private reasoningStreamingTimer: Timer = null
@ -100,12 +148,18 @@ class TurnController {
this.streamDelay = STREAM_TYPING_BATCH_MS
}
boostStreamingForScroll() {
this.streamDelay = Math.max(this.streamDelay, STREAM_SCROLL_BATCH_MS)
}
relaxStreaming() {
this.streamDelay = STREAM_IDLE_BATCH_MS
}
clearReasoning() {
this.reasoningTimer = clear(this.reasoningTimer)
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
this.reasoningText = ''
this.toolTokenAcc = 0
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
@ -144,6 +198,8 @@ class TurnController {
this.interrupted = true
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sid }).catch(() => {})
this.closeReasoningSegment()
const segments = this.segmentMessages
const partial = this.bufRef.trimStart()
const tools = this.pendingSegmentTools
@ -193,7 +249,7 @@ class TurnController {
}
private syncReasoningSegment() {
const thinking = this.reasoningText.trim()
const thinking = this.activeReasoningText.trim()
if (!thinking) {
return
@ -205,8 +261,7 @@ class TurnController {
text: '',
thinking,
thinkingTokens: estimateTokensRough(thinking),
toolTokens: this.toolTokenAcc || undefined,
...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools })
toolTokens: this.toolTokenAcc || undefined
}
if (this.reasoningSegmentIndex === null) {
@ -219,13 +274,40 @@ class TurnController {
patchTurnState({ streamSegments: this.segmentMessages })
}
private closeReasoningSegment() {
this.syncReasoningSegment()
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
}
private pushSegment(msg: Msg) {
if (isToolOnly(msg) && isToolOnly(this.segmentMessages.at(-1)!)) {
const prev = this.segmentMessages.at(-1)!
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }
]
return
}
this.segmentMessages = [...this.segmentMessages, msg]
}
flushStreamingSegment() {
const raw = this.bufRef.trimStart()
const split = raw ? (hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }) : { reasoning: '', text: '' }
const split = raw
? hasReasoningTag(raw)
? splitReasoning(raw)
: { reasoning: '', text: raw }
: { reasoning: '', text: '' }
if (split.reasoning && !this.reasoningText.trim()) {
this.reasoningText = split.reasoning
this.activeReasoningText = split.reasoning
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
this.syncReasoningSegment()
}
const msg: Msg = {
@ -238,7 +320,7 @@ class TurnController {
this.streamTimer = clear(this.streamTimer)
if (split.text || hasDetails(msg)) {
this.segmentMessages = [...this.segmentMessages, msg]
this.pushSegment(msg)
}
this.pendingSegmentTools = []
@ -256,6 +338,31 @@ class TurnController {
}, REASONING_PULSE_MS)
}
recordTodos(value: unknown) {
const todos = parseTodos(value)
if (todos !== null) {
patchTurnState({ todos })
}
}
private flushPendingToolsIntoLastSegment() {
const last = this.segmentMessages[this.segmentMessages.length - 1]
if (!this.pendingSegmentTools.length || !isToolOnly(last)) {
return false
}
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...last, tools: [...(last.tools ?? []), ...this.pendingSegmentTools] }
]
this.pendingSegmentTools = []
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages })
return true
}
pushInlineDiffSegment(diffText: string, tools: string[] = []) {
// Strip CLI chrome the gateway emits before the unified diff (e.g. a
// leading "┊ review diff" header written by `_emit_inline_diff` for the
@ -283,7 +390,10 @@ class TurnController {
return
}
this.segmentMessages = [...this.segmentMessages, { kind: 'diff', role: 'assistant', text: block, ...(tools.length && { tools }) }]
this.segmentMessages = [
...this.segmentMessages,
{ kind: 'diff', role: 'assistant', text: block, ...(tools.length && { tools }) }
]
patchTurnState({ streamSegments: this.segmentMessages })
}
@ -328,13 +438,25 @@ class TurnController {
}
recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) {
this.closeReasoningSegment()
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
const finalText = finalTail(split.text, this.segmentMessages)
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedToolTokens = this.toolTokenAcc
const tools = this.pendingSegmentTools
let tools = this.pendingSegmentTools
const last = this.segmentMessages[this.segmentMessages.length - 1]
if (tools.length && isToolOnly(last)) {
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...last, tools: [...(last.tools ?? []), ...tools] }
]
this.pendingSegmentTools = []
tools = []
}
// Drop diff-only segments the agent is about to narrate in the final
// reply. Without this, a closing "here's the diff …" message would
@ -343,13 +465,19 @@ class TurnController {
// assistant narration stays put.
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
const segments = this.segmentMessages.filter(msg => {
const body = diffSegmentBody(msg)
const segments = mergeSequentialToolOnly(
this.segmentMessages.filter(msg => {
const body = diffSegmentBody(msg)
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
)
const hasReasoningSegment =
this.reasoningSegmentIndex !== null || segments.some(msg => Boolean(msg.thinking?.trim()))
const finalThinking = hasReasoningSegment ? '' : savedReasoning.trim()
const finalThinking = savedReasoning.trim()
const finalDetails: Msg = {
kind: 'trail',
role: 'system',
@ -359,8 +487,8 @@ class TurnController {
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
}
const hasReasoningSegment = this.reasoningSegmentIndex !== null
const finalMessages = hasDetails(finalDetails) && !hasReasoningSegment ? [...segments, finalDetails] : [...segments]
const finalMessages = hasDetails(finalDetails) ? [...segments, finalDetails] : [...segments]
if (finalText) {
finalMessages.push({ role: 'assistant', text: finalText })
@ -387,6 +515,7 @@ class TurnController {
this.turnTools = []
this.persistedToolLabels.clear()
this.bufRef = ''
this.interrupted = false
patchTurnState({ activity: [], outcome: '' })
return { finalMessages, finalText, wasInterrupted }
@ -419,6 +548,7 @@ class TurnController {
}
this.reasoningText = incoming
this.activeReasoningText = incoming
this.scheduleReasoning()
this.syncReasoningSegment()
this.pulseReasoningStreaming()
@ -429,30 +559,63 @@ class TurnController {
return
}
if (!this.activeReasoningText.trim() && this.pendingSegmentTools.length) {
this.flushStreamingSegment()
}
this.reasoningText += text
this.activeReasoningText += text
if (this.reasoningText.length > 80_000) {
this.reasoningText = this.reasoningText.slice(-60_000)
}
this.scheduleReasoning()
this.syncReasoningSegment()
this.pulseReasoningStreaming()
}
recordToolComplete(toolId: string, fallbackName?: string, error?: string, summary?: string) {
const line = this.completeTool(toolId, fallbackName, error, summary)
recordToolComplete(
toolId: string,
fallbackName?: string,
error?: string,
summary?: string,
duration?: number,
todos?: unknown
) {
this.recordTodos(todos)
const line = this.completeTool(toolId, fallbackName, error, summary, duration)
this.pendingSegmentTools = [...this.pendingSegmentTools, line]
this.flushPendingToolsIntoLastSegment()
this.publishToolState()
}
recordInlineDiffToolComplete(diffText: string, toolId: string, fallbackName?: string, error?: string) {
recordInlineDiffToolComplete(
diffText: string,
toolId: string,
fallbackName?: string,
error?: string,
duration?: number
) {
this.flushStreamingSegment()
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '')])
this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '', duration)])
this.publishToolState()
}
private completeTool(toolId: string, fallbackName?: string, error?: string, summary?: string) {
private completeTool(toolId: string, fallbackName?: string, error?: string, summary?: string, duration?: number) {
const done = this.activeTools.find(tool => tool.id === toolId)
const name = done?.name ?? fallbackName ?? 'tool'
const label = toolTrailLabel(name)
const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '')
const fallbackDuration = done?.startedAt ? (Date.now() - done.startedAt) / 1000 : undefined
const line = buildToolTrailLine(
name,
done?.context || '',
Boolean(error),
error || summary || '',
duration ?? fallbackDuration
)
this.activeTools = this.activeTools.filter(tool => tool.id !== toolId)
@ -496,6 +659,7 @@ class TurnController {
recordToolStart(toolId: string, name: string, context: string) {
this.flushStreamingSegment()
this.closeReasoningSegment()
this.pruneTransient()
this.endReasoningPhase()
@ -514,6 +678,7 @@ class TurnController {
this.bufRef = ''
this.interrupted = false
this.lastStatusNote = ''
this.activeReasoningText = ''
this.pendingSegmentTools = []
this.protocolWarned = false
this.reasoningSegmentIndex = null
@ -552,7 +717,7 @@ class TurnController {
this.streamTimer = null
const raw = this.bufRef.trimStart()
const visible = hasReasoningTag(raw) ? splitReasoning(raw).text : raw
patchTurnState({ streaming: visible })
patchTurnState({ streaming: boundedLiveRenderText(visible) })
}, this.streamDelay)
}
@ -560,6 +725,8 @@ class TurnController {
this.endReasoningPhase()
this.clearReasoning()
this.activeTools = []
this.activeReasoningText = ''
this.reasoningSegmentIndex = null
this.turnTools = []
this.toolTokenAcc = 0
this.persistedToolLabels.clear()

View file

@ -5,9 +5,9 @@ import { LONG_MSG } from '../config/limits.js'
import { sectionMode } from '../domain/details.js'
import { userDisplay } from '../domain/messages.js'
import { ROLE } from '../domain/roles.js'
import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
import { boundedLiveRenderText, compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js'
import type { Theme } from '../theme.js'
import type { DetailsMode, Msg, SectionVisibility } from '../types.js'
import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js'
import { Md } from './markdown.js'
import { ToolTrail } from './thinking.js'
@ -20,7 +20,8 @@ export const MessageLine = memo(function MessageLine({
isStreaming = false,
msg,
sections,
t
t,
tools = []
}: MessageLineProps) {
// Per-section overrides win over the global mode, so resolve each section
// we might consume here once and gate visibility on the *content-bearing*
@ -34,7 +35,7 @@ export const MessageLine = memo(function MessageLine({
const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride)
const thinking = msg.thinking?.trim() ?? ''
if (msg.kind === 'trail' && (msg.tools?.length || thinking)) {
if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) {
return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
<Box flexDirection="column">
<ToolTrail
@ -44,6 +45,7 @@ export const MessageLine = memo(function MessageLine({
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
tools={tools}
toolTokens={msg.toolTokens}
trail={msg.tools ?? []}
/>
@ -86,7 +88,11 @@ export const MessageLine = memo(function MessageLine({
}
if (msg.role === 'assistant') {
return isStreaming ? <Text color={body}>{msg.text}</Text> : <Md compact={compact} t={t} text={msg.text} />
return isStreaming ? (
<Text color={body}>{boundedLiveRenderText(msg.text)}</Text>
) : (
<Md compact={compact} t={t} text={msg.text} />
)
}
if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) {
@ -154,4 +160,5 @@ interface MessageLineProps {
msg: Msg
sections?: SectionVisibility
t: Theme
tools?: ActiveTool[]
}

View file

@ -16,12 +16,14 @@ import {
widthByDepth
} from '../lib/subagentTree.js'
import {
boundedLiveRenderText,
compactPreview,
estimateTokensRough,
fmtK,
formatToolCall,
parseToolTrailResultLine,
pick,
splitToolDuration,
thinkingPreview,
toolTrailLabel
} from '../lib/text.js'
@ -633,7 +635,12 @@ export const Thinking = memo(function Thinking({
streaming?: boolean
t: Theme
}) {
const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning])
const preview = useMemo(() => {
const raw = thinkingPreview(reasoning, mode, THINKING_COT_MAX)
return mode === 'full' ? boundedLiveRenderText(raw) : raw
}, [mode, reasoning])
const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview])
if (!preview && !active) {
@ -790,7 +797,7 @@ export const ToolTrail = memo(function ToolTrail({
if (parsed) {
groups.push({
color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk,
content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`,
content: parsed.call,
details: [],
key: `tr-${i}`,
label: parsed.call
@ -886,6 +893,21 @@ export const ToolTrail = memo(function ToolTrail({
const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task'))
const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null
const toolLabel = (group: Group) => {
const { duration, label } = splitToolDuration(String(group.content))
return duration ? (
<>
{label}
<Text color={t.color.dim} dim>
{duration}
</Text>
</>
) : (
group.content
)
}
// ── Backstop: floating alerts when every panel is hidden ─────────
//
// Per-section overrides win over the global details_mode (they're computed
@ -1051,7 +1073,7 @@ export const ToolTrail = memo(function ToolTrail({
content={
<>
<Text color={t.color.amber}> </Text>
{group.content}
{toolLabel(group)}
</>
}
rails={rails}

View file

@ -1,4 +1,6 @@
export const LARGE_PASTE = { chars: 8000, lines: 80 }
export const LIVE_RENDER_MAX_CHARS = 16_000
export const LIVE_RENDER_MAX_LINES = 240
export const LONG_MSG = 300
export const MAX_HISTORY = 800
export const THINKING_COT_MAX = 160

View file

@ -1,4 +1,4 @@
import { THINKING_COT_MAX } from '../config/limits.js'
import { LIVE_RENDER_MAX_CHARS, LIVE_RENDER_MAX_LINES, THINKING_COT_MAX } from '../config/limits.js'
import { VERBS } from '../content/verbs.js'
import type { ThinkingMode } from '../types.js'
@ -88,6 +88,61 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb
return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max)
}
export const boundedLiveRenderText = (
text: string,
{ maxChars = LIVE_RENDER_MAX_CHARS, maxLines = LIVE_RENDER_MAX_LINES } = {}
) => {
if (text.length <= maxChars && text.split('\n', maxLines + 1).length <= maxLines) {
return text
}
let start = 0
let idx = text.length
for (let seen = 0; seen < maxLines && idx > 0; seen++) {
idx = text.lastIndexOf('\n', idx - 1)
start = idx < 0 ? 0 : idx + 1
if (idx < 0) {
break
}
}
const lineStart = start
start = Math.max(lineStart, text.length - maxChars)
if (start > lineStart) {
const nextBreak = text.indexOf('\n', start)
if (nextBreak >= 0 && nextBreak < text.length - 1) {
start = nextBreak + 1
}
}
const tail = text.slice(start).trimStart()
const omittedLines = countNewlines(text, start)
const omittedChars = Math.max(0, text.length - tail.length)
const label =
omittedLines > 0
? `[showing live tail; omitted ${fmtK(omittedLines)} lines / ${fmtK(omittedChars)} chars]\n`
: `[showing live tail; omitted ${fmtK(omittedChars)} chars]\n`
return `${label}${tail}`
}
const countNewlines = (text: string, end: number) => {
let count = 0
for (let i = 0; i < end; i++) {
if (text.charCodeAt(i) === 10) {
count++
}
}
return count
}
export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text)
export const toolTrailLabel = (name: string) =>
@ -104,10 +159,17 @@ export const formatToolCall = (name: string, context = '') => {
return preview ? `${label}("${preview}")` : label
}
export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string) => {
export const buildToolTrailLine = (
name: string,
context: string,
error?: boolean,
note?: string,
duration?: number
) => {
const detail = compactPreview(note ?? '', 72)
const took = duration !== undefined ? ` (${duration.toFixed(1)}s)` : ''
return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}`
return `${formatToolCall(name, context)}${took}${detail ? ` :: ${detail}` : ''} ${error ? '✗' : '✓'}`
}
export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗')
@ -134,6 +196,12 @@ export const parseToolTrailResultLine = (line: string) => {
return { call: body, detail: '', mark }
}
export const splitToolDuration = (call: string) => {
const match = call.match(/^(.*?)( \(\d+(?:\.\d)?s\))$/)
return match ? { label: match[1]!, duration: match[2]! } : { label: call, duration: '' }
}
export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…'
export const sameToolTrailGroup = (label: string, entry: string) =>