fix(tui): stabilize live todo progress

This commit is contained in:
Brooklyn Nicholson 2026-04-26 15:55:38 -05:00
parent 1566f1eecc
commit f5552f92e2
14 changed files with 256 additions and 86 deletions

View file

@ -11,6 +11,7 @@ import { applyDelegationStatus, getDelegationState } from './delegationStore.js'
import type { GatewayEventHandlerContext } from './interfaces.js'
import { patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { archiveDoneTodos } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
@ -538,6 +539,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
msgs.forEach(appendMessage)
archiveDoneTodos().forEach(appendMessage)
if (bellOnComplete && stdout?.isTTY) {
stdout.write('\x07')

View file

@ -7,6 +7,7 @@ import {
} from '../config/timing.js'
import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js'
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
import { appendToolShelfMessage, isToolShelfMessage } from '../lib/liveProgress.js'
import {
boundedLiveRenderText,
buildToolTrailLine,
@ -19,7 +20,7 @@ import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '
import { resetFlowOverlays } from './overlayStore.js'
import { pushSnapshot } from './spawnHistoryStore.js'
import { getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
const INTERRUPT_COOLDOWN_MS = 1500
@ -42,20 +43,6 @@ const diffSegmentBody = (msg: Msg): null | string => {
const hasDetails = (msg: Msg): boolean => Boolean(msg.thinking || msg.tools?.length || msg.toolTokens)
const isToolOnly = (msg: Msg | undefined) =>
Boolean(msg && msg.kind === 'trail' && !msg.thinking?.trim() && !msg.text && msg.tools?.length)
const mergeSequentialToolOnly = (segments: Msg[]) =>
segments.reduce<Msg[]>((acc, msg) => {
if (isToolOnly(msg) && isToolOnly(acc.at(-1))) {
const prev = acc.at(-1)!
return [...acc.slice(0, -1), { ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }]
}
return [...acc, msg]
}, [])
const isTodoStatus = (status: unknown): status is TodoItem['status'] =>
status === 'pending' || status === 'in_progress' || status === 'completed' || status === 'cancelled'
@ -281,17 +268,7 @@ class TurnController {
}
private pushSegment(msg: Msg) {
if (isToolOnly(msg) && isToolOnly(this.segmentMessages.at(-1)!)) {
const prev = this.segmentMessages.at(-1)!
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...prev, tools: [...(prev.tools ?? []), ...(msg.tools ?? [])] }
]
return
}
this.segmentMessages = [...this.segmentMessages, msg]
this.segmentMessages = appendToolShelfMessage(this.segmentMessages, msg)
}
flushStreamingSegment() {
@ -347,16 +324,22 @@ class TurnController {
}
private flushPendingToolsIntoLastSegment() {
const last = this.segmentMessages[this.segmentMessages.length - 1]
if (!this.pendingSegmentTools.length || !isToolOnly(last)) {
if (!this.pendingSegmentTools.length) {
return false
}
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...last, tools: [...(last.tools ?? []), ...this.pendingSegmentTools] }
]
const next = appendToolShelfMessage(this.segmentMessages, {
kind: 'trail',
role: 'system',
text: '',
tools: this.pendingSegmentTools
})
if (next.length === this.segmentMessages.length + 1) {
return false
}
this.segmentMessages = next
this.pendingSegmentTools = []
patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages })
@ -449,7 +432,7 @@ class TurnController {
let tools = this.pendingSegmentTools
const last = this.segmentMessages[this.segmentMessages.length - 1]
if (tools.length && isToolOnly(last)) {
if (tools.length && isToolShelfMessage(last)) {
this.segmentMessages = [
...this.segmentMessages.slice(0, -1),
{ ...last, tools: [...(last.tools ?? []), ...tools] }
@ -465,13 +448,11 @@ class TurnController {
// assistant narration stays put.
const finalHasOwnDiffFence = /```(?:diff|patch)\b/i.test(finalText)
const segments = mergeSequentialToolOnly(
this.segmentMessages.filter(msg => {
const body = diffSegmentBody(msg)
const segments = this.segmentMessages.filter(msg => {
const body = diffSegmentBody(msg)
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
)
return body === null || (!finalHasOwnDiffFence && !finalText.includes(body))
})
const hasReasoningSegment =
this.reasoningSegmentIndex !== null || segments.some(msg => Boolean(msg.thinking?.trim()))
@ -490,6 +471,8 @@ class TurnController {
const finalMessages = hasDetails(finalDetails) ? [...segments, finalDetails] : [...segments]
finalMessages.push(...archiveDoneTodos())
if (finalText) {
finalMessages.push({ role: 'assistant', text: finalText })
}

View file

@ -1,6 +1,7 @@
import { atom } from 'nanostores'
import { useSyncExternalStore } from 'react'
import { appendToolShelfMessage, isTodoDone } from '../lib/liveProgress.js'
import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js'
const buildTurnState = (): TurnState => ({
@ -14,6 +15,7 @@ const buildTurnState = (): TurnState => ({
streamSegments: [],
streaming: '',
subagents: [],
todoCollapsed: false,
todos: [],
toolTokens: 0,
tools: [],
@ -36,6 +38,25 @@ export const useTurnSelector = <T>(selector: (state: TurnState) => T): T =>
export const patchTurnState = (next: Partial<TurnState> | ((state: TurnState) => TurnState)) =>
$turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next })
export const toggleTodoCollapsed = () => patchTurnState(state => ({ ...state, todoCollapsed: !state.todoCollapsed }))
export const archiveDoneTodos = () => {
const state = $turnState.get()
if (!isTodoDone(state.todos)) {
return []
}
const msg: Msg = { kind: 'trail', role: 'system', text: '', todos: state.todos }
patchTurnState({ todoCollapsed: false, todos: [] })
return [msg]
}
export const appendTurnSegment = (msg: Msg) =>
patchTurnState(state => ({ ...state, streamSegments: appendToolShelfMessage(state.streamSegments, msg) }))
export const resetTurnState = () => $turnState.set(buildTurnState())
export interface TurnState {
@ -49,6 +70,7 @@ export interface TurnState {
streamSegments: Msg[]
streaming: string
subagents: SubagentProgress[]
todoCollapsed: boolean
todos: TodoItem[]
toolTokens: number
tools: ActiveTool[]