fix(tui): stabilize live todo panel count and anchor position

Two bugs surfaced together while the model fired the todo tool:

1. Count flickered (e.g. 3 → 1 → 3) because tool.start echoed
   args.todos as the live state. With merge=true (or any partial
   replacement) args.todos is just the items being updated, not the
   full list. Drop the early echo — tool.complete already carries the
   canonical full list from the tool result.

2. After turn end the panel jumped from under the user prompt to below
   thinking/tools because archiveDoneTodos() was pushed AFTER segments
   in finalMessages. Prepend the archive trail msg so it sits right
   after the user prompt — same visual slot the live panel occupied
   during streaming.
This commit is contained in:
Brooklyn Nicholson 2026-04-26 21:45:18 -05:00
parent b51c528613
commit c2ca02fcff
3 changed files with 15 additions and 11 deletions

View file

@ -537,10 +537,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
if (!wasInterrupted) {
// Archive the todo list FIRST so it sits above the final assistant
// text in the transcript — same position it held during streaming.
// Otherwise the panel would visibly jump from "above live answer" to
// "below final answer" at message.complete.
// Defensive: turnController.recordMessageComplete already prepends
// the archive at the head of finalMessages. This is a no-op in the
// normal path (state.todos is empty) but covers any edge where
// todos linger past the controller archive.
archiveTodosAtTurnEnd().forEach(appendMessage)
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]

View file

@ -469,9 +469,12 @@ class TurnController {
...(tools.length && { tools })
}
const finalMessages = hasDetails(finalDetails) ? [...segments, finalDetails] : [...segments]
finalMessages.push(...archiveDoneTodos())
// Archive todos FIRST so the trail msg sits right after the user prompt,
// not between thinking/tools and the final assistant text. Keeps the
// panel visually anchored where it lived during streaming.
const archived = archiveDoneTodos()
const body = hasDetails(finalDetails) ? [...segments, finalDetails] : segments
const finalMessages: Msg[] = [...archived, ...body]
if (finalText) {
finalMessages.push({ role: 'assistant', text: finalText })