fix(tui): render inline diffs inside assistant completion

Follow-up for #13729: segment-level system artifacts still looked detached in real flow.\n\nInstead of appending inline_diff as a standalone segment/system row, queue sanitized diffs during tool.complete and append them as a fenced diff block to the assistant completion text on message.complete. This keeps the diff in the same message flow as the assistant response.
This commit is contained in:
Brooklyn Nicholson 2026-04-21 19:02:53 -05:00
parent bddf0cd61e
commit 31b3b09ea4
3 changed files with 30 additions and 40 deletions

View file

@ -143,7 +143,7 @@ describe('createGatewayEventHandler', () => {
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
})
it('routes inline_diff into the active segment stream, not historyItems', () => {
it('attaches inline_diff to the assistant completion body', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new'
@ -158,26 +158,21 @@ describe('createGatewayEventHandler', () => {
type: 'tool.complete'
} as any)
// While streaming, nothing has flowed to historyItems yet — diff must be
// held in segmentMessages so the transcript renders it inline with the
// current turn rather than above it.
// Diff is buffered for message.complete and sanitized (ANSI stripped).
expect(appended).toHaveLength(0)
expect(turnController.segmentMessages).toContainEqual(
expect.objectContaining({ kind: 'trail', role: 'system', text: '' })
)
expect(turnController.segmentMessages).toContainEqual({ role: 'system', text: cleaned })
expect(turnController.pendingInlineDiffs).toEqual([cleaned])
onEvent({
payload: { text: 'patch applied' },
type: 'message.complete'
} as any)
// After the turn closes, the diff lands in history in the order the
// gateway emitted it — before the assistant's final text, not above it.
expect(appended).toHaveLength(3)
expect(appended[0]).toMatchObject({ kind: 'trail', role: 'system', text: '' })
expect(appended[1]).toMatchObject({ role: 'system', text: cleaned })
expect(appended[2]).toMatchObject({ role: 'assistant', text: 'patch applied' })
// Diff is rendered in the same assistant message body as the completion.
expect(appended).toHaveLength(1)
expect(appended[0]).toMatchObject({ role: 'assistant' })
expect(appended[0]?.text).toContain('patch applied')
expect(appended[0]?.text).toContain('```diff')
expect(appended[0]?.text).toContain(cleaned)
})
it('shows setup panel for missing provider startup error', () => {

View file

@ -272,12 +272,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
// Push into the active turn's segment stream so the diff renders
// inline with the assistant's output. Routing through `sys()`
// lands it in the completed-history section above the streaming
// bubble — which is why blitz testers saw diffs "appear at the
// top, out of sequence" with the rest of the turn.
turnController.appendSegmentMessage({ role: 'system', text: diffText })
// Keep inline diffs attached to the assistant completion body so
// they render in the same message flow, not as a standalone system
// artifact that can look out-of-place around tool rows.
turnController.queueInlineDiff(diffText)
}
return

View file

@ -39,6 +39,7 @@ class TurnController {
bufRef = ''
interrupted = false
lastStatusNote = ''
pendingInlineDiffs: string[] = []
persistedToolLabels = new Set<string>()
protocolWarned = false
reasoningText = ''
@ -76,6 +77,7 @@ class TurnController {
this.activeTools = []
this.streamTimer = clear(this.streamTimer)
this.bufRef = ''
this.pendingInlineDiffs = []
this.pendingSegmentTools = []
this.segmentMessages = []
@ -182,26 +184,14 @@ class TurnController {
}, REASONING_PULSE_MS)
}
/**
* Append an inline artifact (e.g. tool-complete inline diff) to the active
* turn's segment stream. Routing through `historyItems` via `sys()` lands
* the artifact above the currently-streaming assistant bubble; adding it
* here keeps the paint order aligned with the order the gateway emitted.
*/
appendSegmentMessage(msg: Msg) {
this.flushStreamingSegment()
queueInlineDiff(diffText: string) {
const text = diffText.trim()
if (this.pendingSegmentTools.length) {
this.segmentMessages = [
...this.segmentMessages,
{ kind: 'trail', role: 'system', text: '', tools: this.pendingSegmentTools }
]
this.pendingSegmentTools = []
patchTurnState({ streamPendingTools: [] })
if (!text) {
return
}
this.segmentMessages = [...this.segmentMessages, msg]
patchTurnState({ streamSegments: this.segmentMessages })
this.pendingInlineDiffs = [...this.pendingInlineDiffs, text]
}
pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) {
@ -238,6 +228,7 @@ class TurnController {
this.idle()
this.clearReasoning()
this.clearStatusTimer()
this.pendingInlineDiffs = []
this.pendingSegmentTools = []
this.segmentMessages = []
this.turnTools = []
@ -248,6 +239,10 @@ class TurnController {
const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart()
const split = splitReasoning(rawText)
const finalText = split.text
const inlineDiffBlock = this.pendingInlineDiffs.length
? `\`\`\`diff\n${this.pendingInlineDiffs.join('\n\n')}\n\`\`\``
: ''
const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n')
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
@ -255,10 +250,10 @@ class TurnController {
const tools = this.pendingSegmentTools
const finalMessages = [...this.segmentMessages]
if (finalText) {
if (mergedText) {
finalMessages.push({
role: 'assistant',
text: finalText,
text: mergedText,
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedToolTokens || undefined,
@ -275,7 +270,7 @@ class TurnController {
this.bufRef = ''
patchTurnState({ activity: [], outcome: '' })
return { finalMessages, finalText, wasInterrupted }
return { finalMessages, finalText: mergedText, wasInterrupted }
}
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
@ -381,6 +376,7 @@ class TurnController {
this.bufRef = ''
this.interrupted = false
this.lastStatusNote = ''
this.pendingInlineDiffs = []
this.pendingSegmentTools = []
this.protocolWarned = false
this.segmentMessages = []
@ -426,6 +422,7 @@ class TurnController {
this.endReasoningPhase()
this.clearReasoning()
this.activeTools = []
this.pendingInlineDiffs = []
this.turnTools = []
this.toolTokenAcc = 0
this.persistedToolLabels.clear()