From c2ca02fcff89a3fadae20e13cc85ba3fc70b68b8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Apr 2026 21:45:18 -0500 Subject: [PATCH] fix(tui): stabilize live todo panel count and anchor position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tui_gateway/server.py | 9 +++++---- ui-tui/src/app/createGatewayEventHandler.ts | 8 ++++---- ui-tui/src/app/turnController.ts | 9 ++++++--- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index af10da5dfd..431f555c2a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1040,13 +1040,14 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): pass session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid): - payload = {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)} - if name == "todo" and isinstance(args, dict) and isinstance(args.get("todos"), list): - payload["todos"] = args.get("todos") + # Don't echo args.todos on tool.start — for merge=true (or partial + # replacement) it's only the items being updated, not the full list, + # and would flicker the live count. tool.complete is the source of + # truth (always returns the full list from the tool result). _emit( "tool.start", sid, - payload, + {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}, ) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index b0ef2daf25..5ed17cbf7a 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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 }] diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 4c8a728a01..c63ab2ce06 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -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 })