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 })