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 { rpcErrorMessage } from '../lib/rpc.js'
import { formatToolCall } from '../lib/text.js' import { formatToolCall } from '../lib/text.js'
import { fromSkin } from '../theme.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 type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js' import { patchOverlayState } from './overlayStore.js'
@@ -377,18 +377,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return return
case 'message.complete': { case 'message.complete': {
const { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } = const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
turnController.recordMessageComplete(ev.payload ?? {})
if (!wasInterrupted) { if (!wasInterrupted) {
appendMessage({ const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
role: 'assistant', msgs.forEach(appendMessage)
text: finalText,
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedTools.length ? savedToolTokens : undefined,
tools: savedTools.length ? savedTools : undefined
})
if (bellOnComplete && stdout?.isTTY) { if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07') stdout.write('\x07')

View File

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

View File

@@ -3,7 +3,6 @@ import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayT
import { import {
buildToolTrailLine, buildToolTrailLine,
estimateTokensRough, estimateTokensRough,
isToolTrailResultLine,
isTransientTrailLine, isTransientTrailLine,
sameToolTrailGroup, sameToolTrailGroup,
toolTrailLabel toolTrailLabel
@@ -42,6 +41,8 @@ class TurnController {
persistedToolLabels = new Set<string>() persistedToolLabels = new Set<string>()
protocolWarned = false protocolWarned = false
reasoningText = '' reasoningText = ''
segmentMessages: Msg[] = []
pendingSegmentTools: string[] = []
statusTimer: Timer = null statusTimer: Timer = null
toolTokenAcc = 0 toolTokenAcc = 0
turnTools: string[] = [] turnTools: string[] = []
@@ -74,8 +75,17 @@ class TurnController {
this.activeTools = [] this.activeTools = []
this.streamTimer = clear(this.streamTimer) this.streamTimer = clear(this.streamTimer)
this.bufRef = '' this.bufRef = ''
this.pendingSegmentTools = []
this.segmentMessages = []
patchTurnState({ streaming: '', subagents: [], tools: [], turnTrail: [] }) patchTurnState({
streamPendingTools: [],
streamSegments: [],
streaming: '',
subagents: [],
tools: [],
turnTrail: []
})
patchUiState({ busy: false }) patchUiState({ busy: false })
resetOverlayState() 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() { pulseReasoningStreaming() {
this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer)
patchTurnState({ reasoningActive: true, reasoningStreaming: true }) patchTurnState({ reasoningActive: true, reasoningStreaming: true })
@@ -154,6 +180,8 @@ class TurnController {
this.idle() this.idle()
this.clearReasoning() this.clearReasoning()
this.clearStatusTimer() this.clearStatusTimer()
this.pendingSegmentTools = []
this.segmentMessages = []
this.turnTools = [] this.turnTools = []
this.persistedToolLabels.clear() this.persistedToolLabels.clear()
} }
@@ -163,11 +191,19 @@ class TurnController {
const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim()
const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0
const savedToolTokens = this.toolTokenAcc const savedToolTokens = this.toolTokenAcc
const persisted = [...this.persistedToolLabels] const tools = this.pendingSegmentTools
const finalMessages = [...this.segmentMessages]
const savedTools = this.turnTools.filter( if (finalText) {
line => isToolTrailResultLine(line) && !persisted.some(label => sameToolTrailGroup(label, line)) finalMessages.push({
) role: 'assistant',
text: finalText,
thinking: savedReasoning || undefined,
thinkingTokens: savedReasoning ? savedReasoningTokens : undefined,
toolTokens: savedToolTokens || undefined,
...(tools.length && { tools })
})
}
const wasInterrupted = this.interrupted const wasInterrupted = this.interrupted
@@ -178,7 +214,7 @@ class TurnController {
this.bufRef = '' this.bufRef = ''
patchTurnState({ activity: [], outcome: '' }) patchTurnState({ activity: [], outcome: '' })
return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } return { finalMessages, finalText, wasInterrupted }
} }
recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) {
@@ -218,15 +254,20 @@ class TurnController {
const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '') const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '')
this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) 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) { if (!this.activeTools.length) {
next.push('analyzing tool output…') next.push('analyzing tool output…')
} }
this.turnTools = next.slice(-TRAIL_LIMIT) 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) { recordToolProgress(toolName: string, preview: string) {
@@ -249,6 +290,7 @@ class TurnController {
} }
recordToolStart(toolId: string, name: string, context: string) { recordToolStart(toolId: string, name: string, context: string) {
this.flushStreamingSegment()
this.pruneTransient() this.pruneTransient()
this.endReasoningPhase() this.endReasoningPhase()
@@ -267,7 +309,9 @@ class TurnController {
this.bufRef = '' this.bufRef = ''
this.interrupted = false this.interrupted = false
this.lastStatusNote = '' this.lastStatusNote = ''
this.pendingSegmentTools = []
this.protocolWarned = false this.protocolWarned = false
this.segmentMessages = []
this.turnTools = [] this.turnTools = []
this.toolTokenAcc = 0 this.toolTokenAcc = 0
this.persistedToolLabels.clear() this.persistedToolLabels.clear()

View File

@@ -1,6 +1,6 @@
import { atom } from 'nanostores' import { atom } from 'nanostores'
import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js'
const buildTurnState = (): TurnState => ({ const buildTurnState = (): TurnState => ({
activity: [], activity: [],
@@ -9,6 +9,8 @@ const buildTurnState = (): TurnState => ({
reasoningActive: false, reasoningActive: false,
reasoningStreaming: false, reasoningStreaming: false,
reasoningTokens: 0, reasoningTokens: 0,
streamPendingTools: [],
streamSegments: [],
streaming: '', streaming: '',
subagents: [], subagents: [],
toolTokens: 0, toolTokens: 0,
@@ -32,6 +34,8 @@ export interface TurnState {
reasoningActive: boolean reasoningActive: boolean
reasoningStreaming: boolean reasoningStreaming: boolean
reasoningTokens: number reasoningTokens: number
streamPendingTools: string[]
streamSegments: Msg[]
streaming: string streaming: string
subagents: SubagentProgress[] subagents: SubagentProgress[]
toolTokens: number toolTokens: number

View File

@@ -568,6 +568,8 @@ export function useMainApp(gw: GatewayClient) {
: Boolean( : Boolean(
ui.busy || ui.busy ||
turn.outcome || turn.outcome ||
turn.streamPendingTools.length ||
turn.streamSegments.length ||
turn.subagents.length || turn.subagents.length ||
turn.tools.length || turn.tools.length ||
turn.turnTrail.length || turn.turnTrail.length ||

View File

@@ -31,6 +31,10 @@ const StreamingAssistant = memo(function StreamingAssistant({
return ( return (
<> <>
{progress.streamSegments.map((msg, i) => (
<MessageLine cols={cols} compact={compact} detailsMode={detailsMode} key={`seg:${i}`} msg={msg} t={t} />
))}
{progress.showProgressArea && ( {progress.showProgressArea && (
<Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}> <Box flexDirection="column" marginBottom={progress.showStreamingArea ? 1 : 0}>
<ToolTrail <ToolTrail
@@ -57,7 +61,21 @@ const StreamingAssistant = memo(function StreamingAssistant({
compact={compact} compact={compact}
detailsMode={detailsMode} detailsMode={detailsMode}
isStreaming 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} t={t}
/> />
)} )}