diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 8610b2551e..e728f8bbd0 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -4,7 +4,7 @@ import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gate import { rpcErrorMessage } from '../lib/rpc.js' import { formatToolCall } from '../lib/text.js' import { fromSkin } from '../theme.js' -import type { SubagentProgress } from '../types.js' +import type { Msg, SubagentProgress } from '../types.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' @@ -377,18 +377,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'message.complete': { - const { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } = - turnController.recordMessageComplete(ev.payload ?? {}) + const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) if (!wasInterrupted) { - appendMessage({ - role: 'assistant', - text: finalText, - thinking: savedReasoning || undefined, - thinkingTokens: savedReasoning ? savedReasoningTokens : undefined, - toolTokens: savedTools.length ? savedToolTokens : undefined, - tools: savedTools.length ? savedTools : undefined - }) + const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] + msgs.forEach(appendMessage) if (bellOnComplete && stdout?.isTTY) { stdout.write('\x07') diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9af6e5dc64..998afe2a19 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -285,6 +285,8 @@ export interface AppLayoutProgressProps { reasoningTokens: number showProgressArea: boolean showStreamingArea: boolean + streamPendingTools: string[] + streamSegments: Msg[] streaming: string subagents: SubagentProgress[] toolTokens: number diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index be25dcbbe8..73d0571734 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -3,7 +3,6 @@ import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayT import { buildToolTrailLine, estimateTokensRough, - isToolTrailResultLine, isTransientTrailLine, sameToolTrailGroup, toolTrailLabel @@ -42,6 +41,8 @@ class TurnController { persistedToolLabels = new Set() protocolWarned = false reasoningText = '' + segmentMessages: Msg[] = [] + pendingSegmentTools: string[] = [] statusTimer: Timer = null toolTokenAcc = 0 turnTools: string[] = [] @@ -74,8 +75,17 @@ class TurnController { this.activeTools = [] this.streamTimer = clear(this.streamTimer) this.bufRef = '' + this.pendingSegmentTools = [] + this.segmentMessages = [] - patchTurnState({ streaming: '', subagents: [], tools: [], turnTrail: [] }) + patchTurnState({ + streamPendingTools: [], + streamSegments: [], + streaming: '', + subagents: [], + tools: [], + turnTrail: [] + }) patchUiState({ busy: false }) resetOverlayState() } @@ -110,6 +120,22 @@ class TurnController { }) } + flushStreamingSegment() { + const text = this.bufRef.trimStart() + + if (!text) { + return + } + + const tools = this.pendingSegmentTools + + this.streamTimer = clear(this.streamTimer) + this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }] + this.bufRef = '' + this.pendingSegmentTools = [] + patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' }) + } + pulseReasoningStreaming() { this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) patchTurnState({ reasoningActive: true, reasoningStreaming: true }) @@ -154,6 +180,8 @@ class TurnController { this.idle() this.clearReasoning() this.clearStatusTimer() + this.pendingSegmentTools = [] + this.segmentMessages = [] this.turnTools = [] this.persistedToolLabels.clear() } @@ -163,11 +191,19 @@ class TurnController { const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedToolTokens = this.toolTokenAcc - const persisted = [...this.persistedToolLabels] + const tools = this.pendingSegmentTools + const finalMessages = [...this.segmentMessages] - const savedTools = this.turnTools.filter( - line => isToolTrailResultLine(line) && !persisted.some(label => sameToolTrailGroup(label, line)) - ) + if (finalText) { + finalMessages.push({ + role: 'assistant', + text: finalText, + thinking: savedReasoning || undefined, + thinkingTokens: savedReasoning ? savedReasoningTokens : undefined, + toolTokens: savedToolTokens || undefined, + ...(tools.length && { tools }) + }) + } const wasInterrupted = this.interrupted @@ -178,7 +214,7 @@ class TurnController { this.bufRef = '' patchTurnState({ activity: [], outcome: '' }) - return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } + return { finalMessages, finalText, wasInterrupted } } recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { @@ -218,15 +254,20 @@ class TurnController { 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)), line] + const next = this.turnTools.filter(item => !sameToolTrailGroup(label, item)) if (!this.activeTools.length) { next.push('analyzing tool output…') } this.turnTools = next.slice(-TRAIL_LIMIT) - patchTurnState({ tools: this.activeTools, turnTrail: this.turnTools }) + patchTurnState({ + streamPendingTools: this.pendingSegmentTools, + tools: this.activeTools, + turnTrail: this.turnTools + }) } recordToolProgress(toolName: string, preview: string) { @@ -249,6 +290,7 @@ class TurnController { } recordToolStart(toolId: string, name: string, context: string) { + this.flushStreamingSegment() this.pruneTransient() this.endReasoningPhase() @@ -267,7 +309,9 @@ class TurnController { this.bufRef = '' this.interrupted = false this.lastStatusNote = '' + this.pendingSegmentTools = [] this.protocolWarned = false + this.segmentMessages = [] this.turnTools = [] this.toolTokenAcc = 0 this.persistedToolLabels.clear() diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index 9b7e04db78..148a50c196 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -1,6 +1,6 @@ import { atom } from 'nanostores' -import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' +import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js' const buildTurnState = (): TurnState => ({ activity: [], @@ -9,6 +9,8 @@ const buildTurnState = (): TurnState => ({ reasoningActive: false, reasoningStreaming: false, reasoningTokens: 0, + streamPendingTools: [], + streamSegments: [], streaming: '', subagents: [], toolTokens: 0, @@ -32,6 +34,8 @@ export interface TurnState { reasoningActive: boolean reasoningStreaming: boolean reasoningTokens: number + streamPendingTools: string[] + streamSegments: Msg[] streaming: string subagents: SubagentProgress[] toolTokens: number diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 8fa7c10619..73ea9febda 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -568,6 +568,8 @@ export function useMainApp(gw: GatewayClient) { : Boolean( ui.busy || turn.outcome || + turn.streamPendingTools.length || + turn.streamSegments.length || turn.subagents.length || turn.tools.length || turn.turnTrail.length || diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index dcadb8226d..26d8e4b0a9 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -31,6 +31,10 @@ const StreamingAssistant = memo(function StreamingAssistant({ return ( <> + {progress.streamSegments.map((msg, i) => ( + + ))} + {progress.showProgressArea && ( + )} + + {!progress.showStreamingArea && !!progress.streamPendingTools.length && ( + )}