diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index f1f0c306bcd..e242e5bdd04 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -143,6 +143,117 @@ describe('createGatewayEventHandler', () => { expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) }) + 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' + 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' }, + type: 'tool.start' + } as any) + onEvent({ + payload: { inline_diff: diff, summary: 'patched', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) + + // Diff is buffered for message.complete and sanitized (ANSI stripped). + expect(appended).toHaveLength(0) + expect(turnController.pendingInlineDiffs).toEqual([cleaned]) + + onEvent({ + payload: { text: 'patch applied' }, + type: 'message.complete' + } as any) + + // 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('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('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)) + 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 8f45bb3d7eb..35c412f6bb7 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' @@ -263,10 +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) { - sys(ev.payload.inline_diff) + turnController.recordToolComplete( + ev.payload.tool_id, + ev.payload.name, + ev.payload.error, + inlineDiffText ? '' : ev.payload.summary + ) + + 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(inlineDiffText) + + return } return diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 43622e7c7aa..bf9d2926cee 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,6 +184,22 @@ class TurnController { }, REASONING_PULSE_MS) } + queueInlineDiff(diffText: 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 + // 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 + } + + this.pendingInlineDiffs = [...this.pendingInlineDiffs, text] + } + pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) { patchTurnState(state => { const base = replaceLabel @@ -216,6 +234,7 @@ class TurnController { this.idle() this.clearReasoning() this.clearStatusTimer() + this.pendingInlineDiffs = [] this.pendingSegmentTools = [] this.segmentMessages = [] this.turnTools = [] @@ -226,6 +245,17 @@ class TurnController { const rawText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() const split = splitReasoning(rawText) const finalText = split.text + // 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\`\`\`` + : '' + 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 @@ -233,10 +263,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, @@ -253,7 +283,7 @@ class TurnController { this.bufRef = '' patchTurnState({ activity: [], outcome: '' }) - return { finalMessages, finalText, wasInterrupted } + return { finalMessages, finalText: mergedText, wasInterrupted } } recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { @@ -359,6 +389,7 @@ class TurnController { this.bufRef = '' this.interrupted = false this.lastStatusNote = '' + this.pendingInlineDiffs = [] this.pendingSegmentTools = [] this.protocolWarned = false this.segmentMessages = [] @@ -404,6 +435,7 @@ class TurnController { this.endReasoningPhase() this.clearReasoning() this.activeTools = [] + this.pendingInlineDiffs = [] this.turnTools = [] this.toolTokenAcc = 0 this.persistedToolLabels.clear()