diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 92154fd008..071f8141a0 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 51df15e450..847f82b7c6 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 d38d34659b..db312d20e2 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()