fix(tui): anchor details to stream timeline

This commit is contained in:
Brooklyn Nicholson 2026-04-26 04:59:44 -05:00
parent 7143d22a83
commit a0aebad673
3 changed files with 64 additions and 77 deletions

View file

@ -379,6 +379,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const inlineDiffText =
ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : ''
if (inlineDiffText) {
turnController.flushStreamingSegment()
}
turnController.recordToolComplete(
ev.payload.tool_id,
ev.payload.name,
@ -386,17 +390,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
inlineDiffText ? '' : ev.payload.summary
)
if (!inlineDiffText) {
return
if (inlineDiffText) {
turnController.pushInlineDiffSegment(inlineDiffText)
}
// Anchor the diff to where the edit happened in the turn — between
// the narration that preceded the tool call and whatever the agent
// streams afterwards. The previous end-merge put the diff at the
// bottom of the final message even when the edit fired mid-turn,
// which read as "the agent wrote this after saying that".
turnController.pushInlineDiffSegment(inlineDiffText)
return
}

View file

@ -38,11 +38,7 @@ const diffSegmentBody = (msg: Msg): null | string => {
return m ? m[1]! : null
}
const insertBeforeFirstDiff = (segments: Msg[], msg: Msg): Msg[] => {
const index = segments.findIndex(segment => segment.kind === 'diff')
return index < 0 ? [...segments, msg] : [...segments.slice(0, index), msg, ...segments.slice(index)]
}
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
export interface InterruptDeps {
appendMessage: (msg: Msg) => void
@ -69,6 +65,7 @@ class TurnController {
persistSpawnTree?: (subagents: SubagentProgress[], sessionId: null | string) => Promise<void>
protocolWarned = false
reasoningText = ''
reasoningSegmentOffset = 0
segmentMessages: Msg[] = []
pendingSegmentTools: string[] = []
statusTimer: Timer = null
@ -94,6 +91,7 @@ class TurnController {
clearReasoning() {
this.reasoningTimer = clear(this.reasoningTimer)
this.reasoningText = ''
this.reasoningSegmentOffset = 0
this.toolTokenAcc = 0
patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 })
}
@ -181,29 +179,33 @@ class TurnController {
flushStreamingSegment() {
const raw = this.bufRef.trimStart()
if (!raw) {
return
}
const split = hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }
const split = raw ? (hasReasoningTag(raw) ? splitReasoning(raw) : { reasoning: '', text: raw }) : { reasoning: '', text: '' }
if (split.reasoning && !this.reasoningText.trim()) {
this.reasoningText = split.reasoning
patchTurnState({ reasoning: this.reasoningText, reasoningTokens: estimateTokensRough(this.reasoningText) })
}
const text = split.text
const thinking = this.reasoningText.slice(this.reasoningSegmentOffset).trim()
const msg: Msg = {
role: split.text ? 'assistant' : 'system',
text: split.text,
...(!split.text && { kind: 'trail' as const }),
...(thinking && {
thinking,
thinkingTokens: estimateTokensRough(thinking)
}),
...(this.pendingSegmentTools.length && { tools: this.pendingSegmentTools })
}
this.streamTimer = clear(this.streamTimer)
if (text) {
const tools = this.pendingSegmentTools
this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }]
this.pendingSegmentTools = []
if (split.text || hasDetails(msg)) {
this.segmentMessages = [...this.segmentMessages, msg]
}
this.reasoningSegmentOffset = this.reasoningText.length
this.pendingSegmentTools = []
this.bufRef = ''
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' })
}
@ -295,7 +297,6 @@ class TurnController {
const finalText = split.text
const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoning = [existingReasoning, existingReasoning ? '' : split.reasoning].filter(Boolean).join('\n\n')
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
const savedToolTokens = this.toolTokenAcc
const tools = this.pendingSegmentTools
@ -312,32 +313,20 @@ class TurnController {
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
const hasDiffSegment = segments.some(msg => msg.kind === 'diff')
const detailsBelongBeforeDiff = hasDiffSegment && (tools.length > 0 || Boolean(savedReasoning))
const finalMessages = detailsBelongBeforeDiff
? insertBeforeFirstDiff(segments, {
kind: 'trail',
role: 'system',
text: '',
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
})
: [...segments]
const finalThinking = savedReasoning.slice(this.reasoningSegmentOffset).trim()
const finalDetails: Msg = {
kind: 'trail',
role: 'system',
text: '',
thinking: finalThinking || undefined,
thinkingTokens: finalThinking ? estimateTokensRough(finalThinking) : undefined,
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
}
const finalMessages = hasDetails(finalDetails) ? [...segments, finalDetails] : [...segments]
if (finalText) {
finalMessages.push({
role: 'assistant',
text: finalText,
...(!detailsBelongBeforeDiff && {
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
})
})
finalMessages.push({ role: 'assistant', text: finalText })
}
const wasInterrupted = this.interrupted