Merge remote-tracking branch 'origin/farm/c42beb4b/fix-duplicate-todo-panels'

This commit is contained in:
can1357
2026-06-26 04:39:33 +02:00
6 changed files with 424 additions and 38 deletions
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
#pendingUsage: Usage | undefined;
#lastAssistantUsage: Usage | undefined;
#waitingPoll: ToolExecutionComponent | null = null;
#todoSnapshot: ToolExecutionComponent | null = null;
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
#expanded = false;
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
this.#pendingUsage = undefined;
this.#lastAssistantUsage = undefined;
this.#waitingPoll = null;
this.#todoSnapshot = null;
this.#expandables = [];
this.container.dispose();
this.container.clear();
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
previous.seal();
}
#resolveTodoSnapshot(nextToolName?: string): void {
const previous = this.#todoSnapshot;
if (!previous) return;
if (!previous.isDisplaceableBlock()) {
this.#todoSnapshot = null;
return;
}
if (previous.canBeDisplacedBy(nextToolName)) {
this.#todoSnapshot = null;
this.container.removeChild(previous);
previous.seal();
return;
}
if (nextToolName !== undefined) return;
this.#todoSnapshot = null;
previous.seal();
}
#ensureReadGroup(): ReadToolGroupComponent {
if (!this.#readGroup) {
this.#readGroup = new ReadToolGroupComponent({
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
case "developer": {
// A user prompt closes the poll-displacement window, same as the live path.
if (message.role === "user") this.#resolveWaitingPoll();
if (message.role === "user") this.#resolveTodoSnapshot();
const textContent = message.role === "user" ? userMessageText(message) : "";
if (textContent) {
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
this.#pendingTools.delete(message.toolCallId);
if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
this.#waitingPoll = pending;
} else if (
message.toolName === "todo" &&
pending instanceof ToolExecutionComponent &&
pending.canBeDisplacedBy("todo")
) {
// A successful todo result supersedes the prior live snapshot. Failed
// follow-ups return false from canBeDisplacedBy("todo"), so the
// last-good panel stays on screen.
this.#resolveTodoSnapshot("todo");
this.#todoSnapshot = pending;
}
}
@@ -39,7 +39,7 @@ import {
truncateToWidth,
} from "../../tools/render-utils";
import { toolRenderers } from "../../tools/renderers";
import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
import { renderDiff } from "./diff";
@@ -75,6 +75,28 @@ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | unde
return lines.slice(0, lastAddIdx + 1).join("\n");
}
type DisplaceableToolName = "job" | "todo";
function isTodoToolDetails(details: unknown): details is TodoToolDetails {
return (
typeof details === "object" &&
details !== null &&
"phases" in details &&
Array.isArray((details as { phases?: unknown }).phases)
);
}
function displaceableToolName(
toolName: string,
result: { details?: unknown; isError?: boolean },
isPartial: boolean,
): DisplaceableToolName | undefined {
if (result.isError === true) return undefined;
if (toolName === "job" && isWaitingPollDetails(result.details)) return "job";
if (toolName === "todo" && !isPartial && isTodoToolDetails(result.details)) return "todo";
return undefined;
}
function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
let changed = false;
const next = previews.map(preview => {
@@ -234,11 +256,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// sealed the block stays in the transcript's repaintable live region so a
// late result still repaints instead of stranding the streaming preview.
#sealed = false;
// A `job` poll result whose watched jobs are all still running. Such a
// block never finalizes (stays in the transcript live region) so a
// follow-up `job` call can displace it instead of stacking another
// "waiting on N jobs" frame. Cleared by `seal()`.
#displaceable = false;
// Tool result snapshots that may be superseded by a later same-tool call
// while still in the transcript live region. `job` uses this for repeated
// all-running polls; `todo` uses it for per-turn state snapshots so only the
// latest list remains visible.
#displaceableByToolName: DisplaceableToolName | undefined;
// Probe into the owning transcript (absent outside the interactive
// transcript, e.g. in tests): whether this block is still repaintable.
#liveRegion?: TranscriptLiveRegionProbe;
@@ -427,11 +449,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
this.#result = result;
this.#resultVersion++;
this.#isPartial = isPartial;
// A `job` poll that found every watched job still running is transient
// "still waiting" chrome; keep the block displaceable so the next `job`
// call replaces it instead of stacking another waiting frame (see the
// event controller's displaceable-poll bookkeeping).
this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
this.#displaceableByToolName = displaceableToolName(this.#toolName, result, isPartial);
// When tool is complete, ensure args are marked complete so spinner stops
if (!isPartial) {
this.#argsComplete = true;
@@ -490,10 +508,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
}
/**
* Start or stop spinner animation based on whether this is a partial task result.
* Start or stop spinner animation for result states that visibly tick.
*/
#updateSpinnerAnimation(): void {
// Spinner for: task tool with partial result, or edit/write while args streaming
// Spinner for: task tool with partial result, edit/write while args
// streaming, or a still-running job poll. Todo snapshots stay live for
// displacement but should remain visually static.
const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
const isBackgroundAsyncRunning =
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
@@ -502,7 +522,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
// Detached async task progress rows are static now; progress snapshots
// still call #maybeFreezeBackgroundTask before applying so rows settle
// once the block leaves the live region.
const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
const needsSpinner = isStreamingArgs || isPartialTask || this.#displaceableByToolName === "job";
if (needsSpinner && !this.#spinnerInterval) {
const frameCount = theme.spinnerFrames.length;
const frame = sharedSpinnerFrame(frameCount);
@@ -610,9 +630,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
isTranscriptBlockFinalized(): boolean {
if (this.#sealed) return true;
if (this.#result === undefined) return false;
// A displaceable waiting poll stays live: its rows are kept out of
// native scrollback so a follow-up `job` call can remove the block.
if (this.#displaceable) return false;
// A displaceable snapshot stays live: its rows are kept out of native
// scrollback so a follow-up tool call can remove the block.
if (this.#displaceableByToolName) return false;
if (!this.#isPartial) return true;
// Partial result: a background async tool is accepted to freeze (the agent
// continues while it runs and would otherwise pin an unbounded live region);
@@ -630,7 +650,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
* committed prefix survives the remaining transitions.
*/
isTranscriptBlockCommitStable(): boolean {
if (this.#displaceable) return false;
if (this.#displaceableByToolName) return false;
if (this.isTranscriptBlockFinalized()) return true;
// `provisionalPendingPreview` describes only the PENDING call preview
// (`renderCall`, before any result): the result render may re-anchor it
@@ -658,7 +678,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
seal(): void {
if (this.#sealed) return;
this.#sealed = true;
this.#displaceable = false;
this.#displaceableByToolName = undefined;
// A sealed detached task is abandoned history: settle its progress rows
// on static gray.
this.#backgroundTaskFrozen = true;
@@ -668,14 +688,19 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
}
/**
* Whether this block is a waiting `job` poll (every watched job still
* running) that has not been sealed. Such a block never finalized, so none
* of its rows entered native scrollback (the ticking spinner keeps the
* stable-prefix ratchet at zero) and the whole block can be removed when a
* follow-up `job` call supersedes it.
* Whether this block is a supersedable result snapshot that has not been
* sealed. Such a block never finalized, so none of its rows entered native
* scrollback and the whole block can be removed when a follow-up matching
* tool call supersedes it.
*/
isDisplaceableBlock(): boolean {
return this.#displaceable && !this.#sealed;
return this.#displaceableByToolName !== undefined && !this.#sealed;
}
canBeDisplacedBy(nextToolName: string | undefined): boolean {
return (
this.#displaceableByToolName !== undefined && this.#displaceableByToolName === nextToolName && !this.#sealed
);
}
/**