fix(tui): render tool trail before anchored inline diffs

Inline diff segments were anchored relative to assistant narration, but the
turn details pane still rendered after streamSegments. On completion that put
the diff before the tool telemetry that produced it. When a turn has anchored
diff segments, commit the accumulated thinking/tool trail as a pre-diff trail
message, then render the diff and final summary.
This commit is contained in:
Brooklyn Nicholson 2026-04-24 15:07:02 -05:00
parent 6f1eed3968
commit de596aca1c
3 changed files with 64 additions and 32 deletions

View file

@ -175,14 +175,16 @@ describe('createGatewayEventHandler', () => {
onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any)
// Three transcript messages: pre-tool narration → diff (kind='diff',
// so MessageLine gives it blank-line breathing room) → post-tool
// narration. The final message does NOT contain a diff.
expect(appended).toHaveLength(3)
// Four transcript messages: pre-tool narration → tool trail → diff
// (kind='diff', so MessageLine gives it blank-line breathing room) →
// post-tool narration. The final message does NOT contain a diff.
expect(appended).toHaveLength(4)
expect(appended[0]?.text).toBe('Editing the file')
expect(appended[1]).toMatchObject({ kind: 'diff', text: block })
expect(appended[2]?.text).toBe('patch applied')
expect(appended[2]?.text).not.toContain('```diff')
expect(appended[1]).toMatchObject({ kind: 'trail' })
expect(appended[1]?.tools?.[0]).toContain('Patch')
expect(appended[2]).toMatchObject({ kind: 'diff', text: block })
expect(appended[3]?.text).toBe('patch applied')
expect(appended[3]?.text).not.toContain('```diff')
})
it('drops the diff segment when the final assistant text narrates the same diff', () => {
@ -209,12 +211,13 @@ describe('createGatewayEventHandler', () => {
onEvent({ payload: { inline_diff: raw, summary: 'patched', tool_id: 'tool-1' }, type: 'tool.complete' } as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// diff segment first (kind='diff'), final narration second
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).not.toContain('┊ review diff')
expect(appended[0]?.text).toContain('--- a/foo.ts')
expect(appended[1]?.text).toBe('done')
// Tool trail first, then diff segment (kind='diff'), then final narration.
expect(appended).toHaveLength(3)
expect(appended[0]?.kind).toBe('trail')
expect(appended[1]?.kind).toBe('diff')
expect(appended[1]?.text).not.toContain('┊ review diff')
expect(appended[1]?.text).toContain('--- a/foo.ts')
expect(appended[2]?.text).toBe('done')
})
it('drops the diff segment when assistant writes its own ```diff fence', () => {
@ -242,16 +245,17 @@ describe('createGatewayEventHandler', () => {
} as any)
onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any)
// Two segments: the diff block (kind='diff', no tool row) and the final
// narration (tool row belongs here since pendingSegmentTools carries
// across the flushStreamingSegment call).
expect(appended).toHaveLength(2)
expect(appended[0]?.kind).toBe('diff')
expect(appended[0]?.text).toContain('```diff')
expect(appended[0]?.tools ?? []).toEqual([])
expect(appended[1]?.text).toBe('done')
expect(appended[1]?.tools?.[0]).toContain('Review Diff')
expect(appended[1]?.tools?.[0]).not.toContain('--- a/foo.ts')
// Tool row is now placed before the diff, so telemetry does not render
// below the patch that came from that tool.
expect(appended).toHaveLength(3)
expect(appended[0]?.kind).toBe('trail')
expect(appended[0]?.tools?.[0]).toContain('Review Diff')
expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts')
expect(appended[1]?.kind).toBe('diff')
expect(appended[1]?.text).toContain('```diff')
expect(appended[1]?.tools ?? []).toEqual([])
expect(appended[2]?.text).toBe('done')
expect(appended[2]?.tools ?? []).toEqual([])
})
it('shows setup panel for missing provider startup error', () => {

View file

@ -33,6 +33,12 @@ 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)]
}
export interface InterruptDeps {
appendMessage: (msg: Msg) => void
gw: { request: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T> }
@ -292,17 +298,31 @@ class TurnController {
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
const finalMessages = [...segments]
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]
if (finalText) {
finalMessages.push({
role: 'assistant',
text: finalText,
...(!detailsBelongBeforeDiff && {
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
})
})
}
const wasInterrupted = this.interrupted

View file

@ -31,11 +31,20 @@ export const MessageLine = memo(function MessageLine({
const thinkingMode = sectionMode('thinking', detailsMode, sections)
const toolsMode = sectionMode('tools', detailsMode, sections)
const activityMode = sectionMode('activity', detailsMode, sections)
const thinking = msg.thinking?.trim() ?? ''
if (msg.kind === 'trail' && msg.tools?.length) {
return toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
if (msg.kind === 'trail' && (msg.tools?.length || thinking)) {
return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? (
<Box flexDirection="column" marginTop={1}>
<ToolTrail detailsMode={detailsMode} sections={sections} t={t} trail={msg.tools} />
<ToolTrail
detailsMode={detailsMode}
reasoning={thinking}
reasoningTokens={msg.thinkingTokens}
sections={sections}
t={t}
toolTokens={msg.toolTokens}
trail={msg.tools ?? []}
/>
</Box>
) : null
}
@ -61,7 +70,6 @@ export const MessageLine = memo(function MessageLine({
}
const { body, glyph, prefix } = ROLE[msg.role](t)
const thinking = msg.thinking?.trim() ?? ''
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) ||