feat(tui): interleave tool rows into live assistant turns
Live turn rendering used to show the streaming assistant text as one blob with tool calls pooled in a separate section below, so the live view drifted from the reload view (which threads tool rows inline via toTranscriptMessages). Model now mirrors reload: - turnStore gains streamSegments (completed assistant chunks, each with any tool rows that landed between its predecessor and itself) and streamPendingTools (tool rows waiting for the next chunk) - turnController.flushStreamingSegment() seals the current bufRef into a segment when a new tool.start fires; pending tools get attached to that next chunk so order matches reload hydration - recordMessageComplete returns finalMessages instead of one payload, so appendMessage gets the same shape for live-ending turns as for reloaded ones - appLayout renders segments before the progress/streaming area, and the streaming message + pending-tools fallback carry whatever tools arrived after the last assistant chunk
This commit is contained in:
@ -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')
|
||||
|
||||
@ -285,6 +285,8 @@ export interface AppLayoutProgressProps {
|
||||
reasoningTokens: number
|
||||
showProgressArea: boolean
|
||||
showStreamingArea: boolean
|
||||
streamPendingTools: string[]
|
||||
streamSegments: Msg[]
|
||||
streaming: string
|
||||
subagents: SubagentProgress[]
|
||||
toolTokens: number
|
||||
|
||||
@ -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<string>()
|
||||
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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -31,6 +31,10 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
||||
|
||||
return (
|
||||
<>
|
||||
{progress.streamSegments.map((msg, i) => (
|
||||
<MessageLine cols={cols} compact={compact} detailsMode={detailsMode} key={`seg:${i}`} msg={msg} t={t} />
|
||||
))}
|
||||
|
||||
{progress.showProgressArea && (
|
||||
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
|
||||
<ToolTrail
|
||||
@ -57,7 +61,21 @@ const StreamingAssistant = memo(function StreamingAssistant({
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
isStreaming
|
||||
msg={{ role: 'assistant', text: progress.streaming }}
|
||||
msg={{
|
||||
role: 'assistant',
|
||||
text: progress.streaming,
|
||||
...(progress.streamPendingTools.length && { tools: progress.streamPendingTools })
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!progress.showStreamingArea && !!progress.streamPendingTools.length && (
|
||||
<MessageLine
|
||||
cols={cols}
|
||||
compact={compact}
|
||||
detailsMode={detailsMode}
|
||||
msg={{ kind: 'trail', role: 'system', text: '', tools: progress.streamPendingTools }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user