diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index c3cb5095d6..7e0cddfe5d 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -185,19 +185,17 @@ describe('createGatewayEventHandler', () => { expect(appended).toHaveLength(0) expect(turnController.segmentMessages).toEqual([ { role: 'assistant', text: 'Editing the file' }, - { kind: 'trail', role: 'system', text: '', tools: ['Patch("foo.ts") ✓'] }, - { kind: 'diff', role: 'assistant', text: block } + { kind: 'diff', role: 'assistant', text: block, tools: ['Patch("foo.ts") ✓'] } ]) onEvent({ payload: { text: 'patch applied' }, type: 'message.complete' } as any) - expect(appended).toHaveLength(5) + expect(appended).toHaveLength(4) expect(appended[0]?.text).toBe('Editing the file') - expect(appended[1]).toMatchObject({ kind: 'trail' }) + expect(appended[1]).toMatchObject({ kind: 'diff', text: block }) expect(appended[1]?.tools?.[0]).toContain('Patch') - expect(appended[2]).toMatchObject({ kind: 'diff', text: block }) - expect(appended[4]?.text).toBe('patch applied') - expect(appended[4]?.text).not.toContain('```diff') + 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', () => { @@ -211,10 +209,9 @@ describe('createGatewayEventHandler', () => { // Only the final message — diff-only segment dropped so we don't // render two stacked copies of the same patch. - expect(appended).toHaveLength(2) - expect(appended[0]).toMatchObject({ kind: 'trail' }) - expect(appended[1]?.text).toBe(assistantText) - expect((appended[1]?.text.match(/```diff/g) ?? []).length).toBe(1) + 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 inline diff segments', () => { @@ -226,12 +223,12 @@ describe('createGatewayEventHandler', () => { onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any) // 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') + 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[0]?.tools?.[0]).toContain('Tool') + expect(appended[1]?.text).toBe('done') }) it('drops the diff segment when assistant writes its own ```diff fence', () => { @@ -246,10 +243,9 @@ describe('createGatewayEventHandler', () => { } as any) onEvent({ payload: { text: assistantText }, type: 'message.complete' } as any) - expect(appended).toHaveLength(2) - expect(appended[0]).toMatchObject({ kind: 'trail' }) - expect(appended[1]?.text).toBe(assistantText) - expect((appended[1]?.text.match(/```diff/g) ?? []).length).toBe(1) + 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', () => { @@ -265,15 +261,13 @@ describe('createGatewayEventHandler', () => { // 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).toHaveLength(2) + expect(appended[0]?.kind).toBe('diff') + expect(appended[0]?.text).toContain('```diff') 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]?.text).toBe('done') 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', () => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 502f5387c7..4e51c03204 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -380,18 +380,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' if (inlineDiffText) { - turnController.flushStreamingSegment() - } - - turnController.recordToolComplete( - ev.payload.tool_id, - ev.payload.name, - ev.payload.error, - inlineDiffText ? '' : ev.payload.summary - ) - - if (inlineDiffText) { - turnController.pushInlineDiffSegment(inlineDiffText) + turnController.recordInlineDiffToolComplete( + inlineDiffText, + ev.payload.tool_id, + ev.payload.name, + ev.payload.error + ) + } else { + turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) } return diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 1269409dd4..0dadbfbcd5 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -220,7 +220,7 @@ class TurnController { }, REASONING_PULSE_MS) } - pushInlineDiffSegment(diffText: string) { + pushInlineDiffSegment(diffText: string, tools: 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, @@ -247,7 +247,7 @@ class TurnController { return } - this.segmentMessages = [...this.segmentMessages, { kind: 'diff', role: 'assistant', text: block }] + this.segmentMessages = [...this.segmentMessages, { kind: 'diff', role: 'assistant', text: block, ...(tools.length && { tools }) }] patchTurnState({ streamSegments: this.segmentMessages }) } @@ -397,13 +397,25 @@ class TurnController { } recordToolComplete(toolId: string, fallbackName?: string, error?: string, summary?: string) { + const line = this.completeTool(toolId, fallbackName, error, summary) + + this.pendingSegmentTools = [...this.pendingSegmentTools, line] + this.publishToolState() + } + + recordInlineDiffToolComplete(diffText: string, toolId: string, fallbackName?: string, error?: string) { + this.flushStreamingSegment() + this.pushInlineDiffSegment(diffText, [this.completeTool(toolId, fallbackName, error, '')]) + this.publishToolState() + } + + private completeTool(toolId: string, fallbackName?: string, error?: string, summary?: string) { const done = this.activeTools.find(tool => tool.id === toolId) const name = done?.name ?? fallbackName ?? 'tool' const label = toolTrailLabel(name) const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '') this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) - this.pendingSegmentTools = [...this.pendingSegmentTools, line] const next = this.turnTools.filter(item => !sameToolTrailGroup(label, item)) @@ -412,6 +424,11 @@ class TurnController { } this.turnTools = next.slice(-TRAIL_LIMIT) + + return line + } + + private publishToolState() { patchTurnState({ streamPendingTools: this.pendingSegmentTools, tools: this.activeTools,