From dff1c8fcf195d10dd5d642ca216f3df56159cf50 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 18:35:59 -0500 Subject: [PATCH 1/6] fix(tui): tool inline_diff renders inline with the active turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported during TUI v2 blitz retest: code-review diffs from tool.complete appeared at the top of the current interaction thread, out of sequence with the agent's messages and tool rows below them. Root cause — `sys(inline_diff)` appends to `historyItems`, which sits above the `StreamingAssistant` pane that renders the active turn. Until the turn closed, the diff visually floated above everything else happening in the same turn. Route the diff through `turnController.appendSegmentMessage` instead so it flushes any pending streaming text first, then lands in the segment stream beside assistant output and tool calls. On `message.complete` the segment list is committed to history in emit order (diff → final text), matching what the gateway sent. Adds a regression test that exercises tool.complete → message.complete with an inline_diff payload and asserts both the streaming and final placement. --- .../createGatewayEventHandler.test.ts | 32 +++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 7 +++- ui-tui/src/app/turnController.ts | 12 +++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index f1f0c306bcd..17b6e02f7c2 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -143,6 +143,38 @@ describe('createGatewayEventHandler', () => { expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) }) + it('routes inline_diff into the active segment stream, not historyItems', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + + onEvent({ + payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, + type: 'tool.start' + } as any) + onEvent({ + payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, + 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. + expect(appended).toHaveLength(0) + expect(turnController.segmentMessages).toContainEqual({ role: 'system', text: diff }) + + 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(2) + expect(appended[0]).toMatchObject({ role: 'system', text: diff }) + expect(appended[1]).toMatchObject({ role: 'assistant', text: 'patch applied' }) + }) + it('shows setup panel for missing provider startup error', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 8f45bb3d7eb..3ae6b26dc82 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -266,7 +266,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) if (ev.payload.inline_diff && getUiState().inlineDiffs) { - sys(ev.payload.inline_diff) + // 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: ev.payload.inline_diff }) } return diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 43622e7c7aa..d3bd2989f6d 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -182,6 +182,18 @@ 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() + this.segmentMessages = [...this.segmentMessages, msg] + patchTurnState({ streamSegments: this.segmentMessages }) + } + pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) { patchTurnState(state => { const base = replaceLabel From bddf0cd61e84707022cde0377ae28d8c1b8ab22d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 18:50:42 -0500 Subject: [PATCH 2/6] fix(tui): keep inline diffs below tool rows and strip ANSI Follow-up on #13729 from blitz screenshot feedback.\n\n- When tool.complete carried inline_diff but no buffered assistant text existed, pending tool rows were still in streamPendingTools, so diff rendered above the tool row section. appendSegmentMessage now emits pending tool rows as a trail segment before appending the diff artifact.\n- Strip ANSI color escapes from inline_diff payloads so we don't render loud red/green terminal palettes in the transcript. --- .../__tests__/createGatewayEventHandler.test.ts | 15 ++++++++++----- ui-tui/src/app/createGatewayEventHandler.ts | 10 ++++++++-- ui-tui/src/app/turnController.ts | 10 ++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 17b6e02f7c2..92154fd008a 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -146,7 +146,8 @@ describe('createGatewayEventHandler', () => { it('routes inline_diff into the active segment stream, not historyItems', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) - const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + const diff = '\u001b[31m--- a/foo.ts\u001b[0m\n\u001b[32m+++ b/foo.ts\u001b[0m\n@@\n-old\n+new' + const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' onEvent({ payload: { context: 'foo.ts', name: 'patch', tool_id: 'tool-1' }, @@ -161,7 +162,10 @@ describe('createGatewayEventHandler', () => { // held in segmentMessages so the transcript renders it inline with the // current turn rather than above it. expect(appended).toHaveLength(0) - expect(turnController.segmentMessages).toContainEqual({ role: 'system', text: diff }) + expect(turnController.segmentMessages).toContainEqual( + expect.objectContaining({ kind: 'trail', role: 'system', text: '' }) + ) + expect(turnController.segmentMessages).toContainEqual({ role: 'system', text: cleaned }) onEvent({ payload: { text: 'patch applied' }, @@ -170,9 +174,10 @@ describe('createGatewayEventHandler', () => { // 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(2) - expect(appended[0]).toMatchObject({ role: 'system', text: diff }) - expect(appended[1]).toMatchObject({ role: 'assistant', text: 'patch applied' }) + 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' }) }) it('shows setup panel for missing provider startup error', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 3ae6b26dc82..51df15e4505 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -2,7 +2,7 @@ import { STREAM_BATCH_MS } from '../config/timing.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js' import { rpcErrorMessage } from '../lib/rpc.js' -import { formatToolCall } from '../lib/text.js' +import { formatToolCall, stripAnsi } from '../lib/text.js' import { fromSkin } from '../theme.js' import type { Msg, SubagentProgress } from '../types.js' @@ -266,12 +266,18 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) if (ev.payload.inline_diff && getUiState().inlineDiffs) { + const diffText = stripAnsi(String(ev.payload.inline_diff)) + + if (!diffText.trim()) { + 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: ev.payload.inline_diff }) + turnController.appendSegmentMessage({ role: 'system', text: diffText }) } return diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index d3bd2989f6d..d38d34659b8 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -190,6 +190,16 @@ class TurnController { */ appendSegmentMessage(msg: Msg) { this.flushStreamingSegment() + + if (this.pendingSegmentTools.length) { + this.segmentMessages = [ + ...this.segmentMessages, + { kind: 'trail', role: 'system', text: '', tools: this.pendingSegmentTools } + ] + this.pendingSegmentTools = [] + patchTurnState({ streamPendingTools: [] }) + } + this.segmentMessages = [...this.segmentMessages, msg] patchTurnState({ streamSegments: this.segmentMessages }) } From 31b3b09ea42b6dcc388c671b65f24959ba5e1966 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 19:02:53 -0500 Subject: [PATCH 3/6] 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. --- .../createGatewayEventHandler.test.ts | 23 +++++------- ui-tui/src/app/createGatewayEventHandler.ts | 10 ++--- ui-tui/src/app/turnController.ts | 37 +++++++++---------- 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 92154fd008a..071f8141a09 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 51df15e4505..847f82b7c66 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index d38d34659b8..db312d20e22 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -39,6 +39,7 @@ class TurnController { bufRef = '' interrupted = false lastStatusNote = '' + pendingInlineDiffs: string[] = [] persistedToolLabels = new Set() 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() From 9654c9fb100b02bd3595fccf8b7f8c52eb3d167e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 19:06:49 -0500 Subject: [PATCH 4/6] fix(tui): dedupe inline_diff when assistant already echoes it Avoid duplicate diff rendering in #13729 flow. We now skip queued inline diffs that are already present in final assistant text and dedupe repeated queued diffs by exact content. --- .../createGatewayEventHandler.test.ts | 20 +++++++++++++++++++ ui-tui/src/app/turnController.ts | 7 ++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 071f8141a09..517b2be0c25 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -175,6 +175,26 @@ describe('createGatewayEventHandler', () => { expect(appended[0]?.text).toContain(cleaned) }) + it('does not append inline_diff twice when assistant text already contains it', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + const cleaned = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + const assistantText = `Done. Here's the inline diff:\n\n\`\`\`diff\n${cleaned}\n\`\`\`` + + onEvent({ + payload: { inline_diff: cleaned, summary: 'patched', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) + onEvent({ + payload: { text: assistantText }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]?.text).toBe(assistantText) + expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1) + }) + it('shows setup panel for missing provider startup error', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index db312d20e22..005eed4bcb8 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -187,7 +187,7 @@ class TurnController { queueInlineDiff(diffText: string) { const text = diffText.trim() - if (!text) { + if (!text || this.pendingInlineDiffs.includes(text)) { return } @@ -239,8 +239,9 @@ 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 remainingInlineDiffs = this.pendingInlineDiffs.filter(diff => !finalText.includes(diff)) + const inlineDiffBlock = remainingInlineDiffs.length + ? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\`` : '' const mergedText = [finalText, inlineDiffBlock].filter(Boolean).join('\n\n') const existingReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() From e684afa1519c1ef3956861217a0c0b42fe4c84f7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 19:13:15 -0500 Subject: [PATCH 5/6] fix(tui): keep review-diff tool rows terse When tool.complete already carries inline_diff, the assistant message owns the full diff block. Suppress the tool-row summary/detail in that case so the turn shows one detailed diff surface instead of a rich diff plus a duplicated tool-detail payload. --- .../createGatewayEventHandler.test.ts | 20 +++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 18 ++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 517b2be0c25..22a6b281f97 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -195,6 +195,26 @@ describe('createGatewayEventHandler', () => { expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1) }) + it('keeps tool trail terse when inline_diff is present', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + const diff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + + onEvent({ + payload: { inline_diff: diff, name: 'review_diff', summary: diff, tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) + onEvent({ + payload: { text: 'done' }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]?.tools?.[0]).toContain('Review Diff') + expect(appended[0]?.tools?.[0]).not.toContain('--- a/foo.ts') + expect(appended[0]?.text).toContain('```diff') + }) + it('shows setup panel for missing provider startup error', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 847f82b7c66..35c412f6bb7 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -263,19 +263,27 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'tool.complete': - turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) + { + const inlineDiffText = + ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' - if (ev.payload.inline_diff && getUiState().inlineDiffs) { - const diffText = stripAnsi(String(ev.payload.inline_diff)) + turnController.recordToolComplete( + ev.payload.tool_id, + ev.payload.name, + ev.payload.error, + inlineDiffText ? '' : ev.payload.summary + ) - if (!diffText.trim()) { + if (!inlineDiffText) { return } // 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) + turnController.queueInlineDiff(inlineDiffText) + + return } return From a8eb13e828b03508fdd59251c9c95143484fb374 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 21 Apr 2026 19:21:00 -0500 Subject: [PATCH 6/6] fix(tui): dedupe inline diffs, strip CLI review-diff header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the prior inline-diff fix, the gateway still prepends a literal " ┊ review diff" line to inline_diff (it's terminal chrome written by `_emit_inline_diff`). Wrapping that in a ```diff fence left that header inside the code block. The agent also often narrates its own edit in a second fenced diff, so the assistant message ended up stacking two diff blocks for the same change. - Strip the leading "┊ review diff" header from queued inline diffs before fencing. - Skip appending the fenced diff entirely when the assistant already wrote its own ```diff (or ```patch) fence. Keeps the single-surface diff UX even when the agent is chatty. --- .../createGatewayEventHandler.test.ts | 39 +++++++++++++++++++ ui-tui/src/app/turnController.ts | 16 +++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 22a6b281f97..e242e5bdd04 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -195,6 +195,45 @@ describe('createGatewayEventHandler', () => { expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1) }) + it('strips the CLI "┊ review diff" header from queued inline diffs', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + const raw = ' \u001b[33m┊ review diff\u001b[0m\n--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + + 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) + + expect(appended).toHaveLength(1) + expect(appended[0]?.text).not.toContain('┊ review diff') + expect(appended[0]?.text).toContain('--- a/foo.ts') + }) + + it('suppresses inline_diff when assistant already wrote a diff fence', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + const inlineDiff = '--- a/foo.ts\n+++ b/foo.ts\n@@\n-old\n+new' + const assistantText = 'Done. Clean swap:\n\n```diff\n-old\n+new\n```' + + onEvent({ + payload: { inline_diff: inlineDiff, summary: 'patched', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) + onEvent({ + payload: { text: assistantText }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]?.text).toBe(assistantText) + expect((appended[0]?.text.match(/```diff/g) ?? []).length).toBe(1) + }) + it('keeps tool trail terse when inline_diff is present', () => { const appended: Msg[] = [] const onEvent = createGatewayEventHandler(buildCtx(appended)) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 005eed4bcb8..bf9d2926cee 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -185,7 +185,13 @@ class TurnController { } queueInlineDiff(diffText: string) { - const text = diffText.trim() + // Strip CLI chrome the gateway emits before the unified diff (e.g. a + // leading "┊ review diff" header written by `_emit_inline_diff` for the + // terminal printer). That header only makes sense as stdout dressing, + // not inside a markdown ```diff block. + const text = diffText + .replace(/^\s*┊[^\n]*\n?/, '') + .trim() if (!text || this.pendingInlineDiffs.includes(text)) { return @@ -239,7 +245,13 @@ class TurnController { const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() const split = splitReasoning(rawText) const finalText = split.text - const remainingInlineDiffs = this.pendingInlineDiffs.filter(diff => !finalText.includes(diff)) + // Skip appending if the assistant already narrated the diff inside a + // markdown fence of its own — otherwise we render two stacked diff + // blocks for the same edit. + const assistantAlreadyHasDiff = /```(?:diff|patch)\b/i.test(finalText) + const remainingInlineDiffs = assistantAlreadyHasDiff + ? [] + : this.pendingInlineDiffs.filter(diff => !finalText.includes(diff)) const inlineDiffBlock = remainingInlineDiffs.length ? `\`\`\`diff\n${remainingInlineDiffs.join('\n\n')}\n\`\`\`` : ''