diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 5eab8c7..f831617 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -654,7 +654,11 @@ export default function App({ buffersRef.current.pendingRefresh = true; setTimeout(() => { buffersRef.current.pendingRefresh = false; - refreshDerived(); + // Skip refresh if stream was interrupted - prevents stale updates appearing + // after user cancels. Normal stream completion still renders (interrupted=false). + if (!buffersRef.current.interrupted) { + refreshDerived(); + } }, 16); // ~60fps } }, [refreshDerived]); @@ -828,6 +832,8 @@ export default function App({ // Clear any stale pending tool calls from previous turns // If we're sending a new message, old pending state is no longer relevant markIncompleteToolsAsCancelled(buffersRef.current); + // Reset interrupted flag since we're starting a fresh stream + buffersRef.current.interrupted = false; // Clear completed subagents from the UI when starting a new turn clearCompletedSubagents(); @@ -3013,6 +3019,8 @@ ${recentCommits} // Reset token counter for this turn (only count the agent's response) buffersRef.current.tokenCount = 0; + // Clear interrupted flag from previous turn + buffersRef.current.interrupted = false; // Rotate to a new thinking message for this turn setThinkingMessage(getRandomThinkingVerb()); // Show streaming state immediately for responsiveness @@ -3412,6 +3420,8 @@ ${recentCommits} // Show "thinking" state and lock input while executing approved tools client-side setStreaming(true); + // Ensure interrupted flag is cleared for this execution + buffersRef.current.interrupted = false; const approvalAbortController = new AbortController(); toolAbortControllerRef.current = approvalAbortController; diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index bcee7ef..be7d7a6 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -62,6 +62,7 @@ export type Buffers = { toolCallIdToLineId: Map; lastOtid: string | null; // Track the last otid to detect transitions pendingRefresh?: boolean; // Track throttled refresh state + interrupted?: boolean; // Track if stream was interrupted by user (skip stale refreshes) usage: { promptTokens: number; completionTokens: number; @@ -162,6 +163,9 @@ export function markCurrentLineAsFinished(b: Buffers) { * This prevents blinking tool calls from staying in progress state. */ export function markIncompleteToolsAsCancelled(b: Buffers) { + // Mark buffer as interrupted to skip stale throttled refreshes + b.interrupted = true; + for (const [id, line] of b.byId.entries()) { if (line.kind === "tool_call" && line.phase !== "finished") { const updatedLine = { diff --git a/src/cli/helpers/stream.ts b/src/cli/helpers/stream.ts index 0b946ae..d61a3d3 100644 --- a/src/cli/helpers/stream.ts +++ b/src/cli/helpers/stream.ts @@ -141,10 +141,7 @@ export async function drainStream( } } - onChunk(buffers, chunk); - queueMicrotask(refresh); - - // Check abort signal again after processing chunk (for eager cancellation) + // Check abort signal before processing - don't add data after interrupt if (abortSignal?.aborted) { stopReason = "cancelled"; markIncompleteToolsAsCancelled(buffers); @@ -152,6 +149,9 @@ export async function drainStream( break; } + onChunk(buffers, chunk); + queueMicrotask(refresh); + if (chunk.message_type === "stop_reason") { stopReason = chunk.stop_reason; // Continue reading stream to get usage_statistics that may come after