From f5552f92e2b935a2f404cb7cee3179927ab143fd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 15:55:38 -0500 Subject: [PATCH] fix(tui): stabilize live todo progress --- ui-tui/src/__tests__/turnStore.test.ts | 60 +++++++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 2 + ui-tui/src/app/turnController.ts | 63 +++++++------------- ui-tui/src/app/turnStore.ts | 22 +++++++ ui-tui/src/components/appLayout.tsx | 8 +-- ui-tui/src/components/messageLine.tsx | 5 ++ ui-tui/src/components/streamingAssistant.tsx | 5 +- ui-tui/src/components/todoPanel.tsx | 59 +++++++++++------- ui-tui/src/lib/liveLayout.ts | 2 +- ui-tui/src/lib/liveProgress.test.ts | 48 +++++++++++++++ ui-tui/src/lib/liveProgress.ts | 34 +++++++++++ ui-tui/src/lib/messages.test.ts | 20 ++++--- ui-tui/src/lib/messages.ts | 13 +--- ui-tui/src/types.ts | 1 + 14 files changed, 256 insertions(+), 86 deletions(-) create mode 100644 ui-tui/src/__tests__/turnStore.test.ts create mode 100644 ui-tui/src/lib/liveProgress.test.ts create mode 100644 ui-tui/src/lib/liveProgress.ts diff --git a/ui-tui/src/__tests__/turnStore.test.ts b/ui-tui/src/__tests__/turnStore.test.ts new file mode 100644 index 0000000000..006a12888d --- /dev/null +++ b/ui-tui/src/__tests__/turnStore.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { + appendTurnSegment, + archiveDoneTodos, + getTurnState, + patchTurnState, + resetTurnState, + toggleTodoCollapsed +} from '../app/turnStore.js' + +describe('turnStore live progress helpers', () => { + beforeEach(() => resetTurnState()) + + it('archives completed todos into a transcript trail and clears the live anchor', () => { + patchTurnState({ + todos: [ + { content: 'prep', id: 'prep', status: 'completed' }, + { content: 'serve', id: 'serve', status: 'completed' } + ] + }) + + expect(archiveDoneTodos()).toEqual([ + { + kind: 'trail', + role: 'system', + text: '', + todos: [ + { content: 'prep', id: 'prep', status: 'completed' }, + { content: 'serve', id: 'serve', status: 'completed' } + ] + } + ]) + expect(getTurnState().todos).toEqual([]) + }) + + it('does not archive active todos', () => { + patchTurnState({ todos: [{ content: 'cook', id: 'cook', status: 'in_progress' }] }) + + expect(archiveDoneTodos()).toEqual([]) + expect(getTurnState().todos).toHaveLength(1) + }) + + it('tracks collapsed state independently of todo content', () => { + toggleTodoCollapsed() + expect(getTurnState().todoCollapsed).toBe(true) + + toggleTodoCollapsed() + expect(getTurnState().todoCollapsed).toBe(false) + }) + + it('merges adjacent live tool shelves before rendering', () => { + appendTurnSegment({ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }) + appendTurnSegment({ kind: 'trail', role: 'system', text: '', tools: ['two ✓'] }) + + expect(getTurnState().streamSegments).toEqual([ + { kind: 'trail', role: 'system', text: '', tools: ['one ✓', 'two ✓'] } + ]) + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 267bf8c166..d1e9d63309 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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') diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 8d9d2e1330..9bc87ea808 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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((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 }) } diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index f6d40bd3b6..9700f9533f 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -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 = (selector: (state: TurnState) => T): T => export const patchTurnState = (next: Partial | ((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[] diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 5c63c0e2e7..2608a9dabe 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -28,10 +28,6 @@ const TranscriptPane = memo(function TranscriptPane({ return ( <> - - - - {transcript.virtualHistory.topSpacer > 0 ? : null} @@ -73,6 +69,10 @@ const TranscriptPane = memo(function TranscriptPane({ + + + + diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index e827dd5fa3..7465a0885a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -10,6 +10,7 @@ import type { Theme } from '../theme.js' import type { ActiveTool, DetailsMode, Msg, SectionVisibility } from '../types.js' import { Md } from './markdown.js' +import { TodoPanel } from './todoPanel.js' import { ToolTrail } from './thinking.js' export const MessageLine = memo(function MessageLine({ @@ -35,6 +36,10 @@ export const MessageLine = memo(function MessageLine({ const activityMode = sectionMode('activity', detailsMode, sections, detailsModeCommandOverride) const thinking = msg.thinking?.trim() ?? '' + if (msg.kind === 'trail' && msg.todos?.length) { + return + } + if (msg.kind === 'trail' && (msg.tools?.length || tools.length || thinking)) { return thinkingMode !== 'hidden' || toolsMode !== 'hidden' || activityMode !== 'hidden' ? ( diff --git a/ui-tui/src/components/streamingAssistant.tsx b/ui-tui/src/components/streamingAssistant.tsx index b027998690..8b5f261115 100644 --- a/ui-tui/src/components/streamingAssistant.tsx +++ b/ui-tui/src/components/streamingAssistant.tsx @@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react' import { memo } from 'react' import type { AppLayoutProgressProps } from '../app/interfaces.js' -import { useTurnSelector } from '../app/turnStore.js' +import { toggleTodoCollapsed, useTurnSelector } from '../app/turnStore.js' import { $uiState } from '../app/uiStore.js' import type { DetailsMode, Msg, SectionVisibility } from '../types.js' @@ -105,8 +105,9 @@ export const StreamingAssistant = memo(function StreamingAssistant({ export const LiveTodoPanel = memo(function LiveTodoPanel() { const ui = useStore($uiState) const todos = useTurnSelector(state => state.todos) + const collapsed = useTurnSelector(state => state.todoCollapsed) - return + return }) interface StreamingAssistantProps { diff --git a/ui-tui/src/components/todoPanel.tsx b/ui-tui/src/components/todoPanel.tsx index cb8ccd8012..964512d875 100644 --- a/ui-tui/src/components/todoPanel.tsx +++ b/ui-tui/src/components/todoPanel.tsx @@ -11,35 +11,52 @@ const rowColor = (t: Theme, status: TodoItem['status']) => { return tone === 'active' ? t.color.cornsilk : tone === 'body' ? t.color.statusFg : t.color.dim } -export const TodoPanel = memo(function TodoPanel({ t, todos }: { t: Theme; todos: TodoItem[] }) { +export const TodoPanel = memo(function TodoPanel({ + collapsed = false, + onToggle, + t, + todos +}: { + collapsed?: boolean + onToggle?: () => void + t: Theme + todos: TodoItem[] +}) { if (!todos.length) { return null } + const done = todos.filter(todo => todo.status === 'completed').length + return ( - - - - Todo - {' '} - - ({todos.filter(todo => todo.status === 'completed').length}/{todos.length}) + + + {collapsed ? '▸ ' : '▾ '} + + Todo + {' '} + + ({done}/{todos.length}) + - - - {todos.map(todo => { - const tone = todoTone(todo.status) - const color = rowColor(t, todo.status) - - return ( - - {todoGlyph(todo.status)} - {todo.content} - - ) - })} + + {!collapsed && ( + + {todos.map(todo => { + const tone = todoTone(todo.status) + const color = rowColor(t, todo.status) + + return ( + + {todoGlyph(todo.status)} + {todo.content} + + ) + })} + + )} ) }) diff --git a/ui-tui/src/lib/liveLayout.ts b/ui-tui/src/lib/liveLayout.ts index a990b06d0e..13856f5c39 100644 --- a/ui-tui/src/lib/liveLayout.ts +++ b/ui-tui/src/lib/liveLayout.ts @@ -1 +1 @@ -export const liveTailOrder = () => ['todo', 'scroll-history', 'assistant'] as const +export const liveTailOrder = () => ['scroll-history', 'assistant', 'live-todo'] as const diff --git a/ui-tui/src/lib/liveProgress.test.ts b/ui-tui/src/lib/liveProgress.test.ts new file mode 100644 index 0000000000..d10e1bb9a1 --- /dev/null +++ b/ui-tui/src/lib/liveProgress.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { appendToolShelfMessage, isTodoDone } from './liveProgress.js' + +describe('isTodoDone', () => { + it('only treats non-empty all-completed/cancelled lists as done', () => { + expect(isTodoDone([])).toBe(false) + expect(isTodoDone([{ content: 'x', id: 'x', status: 'completed' }])).toBe(true) + expect(isTodoDone([{ content: 'x', id: 'x', status: 'in_progress' }])).toBe(false) + expect( + isTodoDone([ + { content: 'x', id: 'x', status: 'completed' }, + { content: 'y', id: 'y', status: 'cancelled' } + ]) + ).toBe(true) + }) +}) + +describe('appendToolShelfMessage', () => { + it('merges adjacent tool shelves into one contextual shelf', () => { + const merged = appendToolShelfMessage([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }], { + kind: 'trail', + role: 'system', + text: '', + tools: ['two ✓'] + }) + + expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', tools: ['one ✓', 'two ✓'] }]) + }) + + it('adds tools to the nearest contextual thinking shelf', () => { + const merged = appendToolShelfMessage( + [{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓'] }], + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] } + ) + + expect(merged).toEqual([{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['one ✓', 'two ✓'] }]) + }) + + it('starts a new shelf across assistant text boundaries', () => { + const merged = appendToolShelfMessage( + [{ kind: 'trail', role: 'system', text: '', tools: ['one ✓'] }, { role: 'assistant', text: 'done' }], + { kind: 'trail', role: 'system', text: '', tools: ['two ✓'] } + ) + + expect(merged).toHaveLength(3) + }) +}) diff --git a/ui-tui/src/lib/liveProgress.ts b/ui-tui/src/lib/liveProgress.ts new file mode 100644 index 0000000000..62f741633c --- /dev/null +++ b/ui-tui/src/lib/liveProgress.ts @@ -0,0 +1,34 @@ +import type { Msg, TodoItem } from '../types.js' + +export const isTodoDone = (todos: readonly TodoItem[]) => + todos.length > 0 && todos.every(todo => todo.status === 'completed' || todo.status === 'cancelled') + +export const isToolShelfMessage = (msg: Msg | undefined) => + Boolean(msg?.kind === 'trail' && !msg.text && !msg.thinking?.trim() && msg.tools?.length) + +const canHoldToolShelf = (msg: Msg | undefined) => + Boolean(msg?.kind === 'trail' && !msg.text && (msg.thinking?.trim() || msg.tools?.length)) + +export const appendToolShelfMessage = (prev: readonly Msg[], msg: Msg): Msg[] => { + if (!isToolShelfMessage(msg)) { + return [...prev, msg] + } + + for (let index = prev.length - 1; index >= 0; index--) { + const candidate = prev[index] + + if (canHoldToolShelf(candidate)) { + const next = [...prev] + + next[index] = { ...candidate!, tools: [...(candidate!.tools ?? []), ...(msg.tools ?? [])] } + + return next + } + + if (candidate?.kind !== 'trail' || candidate.text) { + break + } + } + + return [...prev, msg] +} diff --git a/ui-tui/src/lib/messages.test.ts b/ui-tui/src/lib/messages.test.ts index 6194311cb1..422ddb1af9 100644 --- a/ui-tui/src/lib/messages.test.ts +++ b/ui-tui/src/lib/messages.test.ts @@ -4,20 +4,26 @@ import { appendTranscriptMessage } from './messages.js' describe('appendTranscriptMessage', () => { it('merges adjacent tool-only shelves into one transcript row', () => { - const out = appendTranscriptMessage( - [{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓'] }], - { kind: 'trail', role: 'system', text: '', tools: ['Terminal("two") ✓'] } - ) + const out = appendTranscriptMessage([{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓'] }], { + kind: 'trail', + role: 'system', + text: '', + tools: ['Terminal("two") ✓'] + }) - expect(out).toEqual([{ kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] }]) + expect(out).toEqual([ + { kind: 'trail', role: 'system', text: '', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] } + ]) }) - it('does not merge tool shelves across thinking text', () => { + it('merges tool shelves into the nearest thinking shelf', () => { const out = appendTranscriptMessage( [{ kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓'] }], { kind: 'trail', role: 'system', text: '', tools: ['Terminal("two") ✓'] } ) - expect(out).toHaveLength(2) + expect(out).toEqual([ + { kind: 'trail', role: 'system', text: '', thinking: 'plan', tools: ['Terminal("one") ✓', 'Terminal("two") ✓'] } + ]) }) }) diff --git a/ui-tui/src/lib/messages.ts b/ui-tui/src/lib/messages.ts index 60fc4b76ba..b8e89421e5 100644 --- a/ui-tui/src/lib/messages.ts +++ b/ui-tui/src/lib/messages.ts @@ -1,17 +1,8 @@ import type { Msg, Role } from '../types.js' -const isToolShelf = (msg: Msg | undefined) => - Boolean(msg?.kind === 'trail' && !msg.text && !msg.thinking?.trim() && msg.tools?.length) +import { appendToolShelfMessage } from './liveProgress.js' -export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => { - if (isToolShelf(msg) && isToolShelf(prev.at(-1))) { - const last = prev.at(-1)! - - return [...prev.slice(0, -1), { ...last, tools: [...(last.tools ?? []), ...(msg.tools ?? [])] }] - } - - return [...prev, msg] -} +export const appendTranscriptMessage = (prev: Msg[], msg: Msg): Msg[] => appendToolShelfMessage(prev, msg) export const upsert = (prev: Msg[], role: Role, text: string): Msg[] => prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 89c83856d1..ac61868b8a 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -116,6 +116,7 @@ export interface Msg { thinkingTokens?: number toolTokens?: number tools?: string[] + todos?: TodoItem[] } export type Role = 'assistant' | 'system' | 'tool' | 'user'