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 { 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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user