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:
Brooklyn Nicholson
2026-04-17 11:33:29 -05:00
parent f53250b5e1
commit bedbeebbc8
6 changed files with 85 additions and 22 deletions

View File

@ -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')

View File

@ -285,6 +285,8 @@ export interface AppLayoutProgressProps {
reasoningTokens: number
showProgressArea: boolean
showStreamingArea: boolean
streamPendingTools: string[]
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
toolTokens: number

View File

@ -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()

View File

@ -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

View File

@ -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 ||

View File

@ -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}
/>
)}