From 84dff1d5e176f36565791e87e4ebff28b96b912a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 22 Jan 2026 09:23:58 +0100 Subject: [PATCH] fix(cli): Keep 'Thinking...' indicator visible after checkpoint saves --- .changeset/cli-processing-indicator.md | 5 + cli/src/state/atoms/ui.ts | 51 +++++++ cli/src/ui/components/StatusIndicator.tsx | 7 +- .../__tests__/StatusIndicator.test.tsx | 127 ++++++++++++++++++ 4 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-processing-indicator.md diff --git a/.changeset/cli-processing-indicator.md b/.changeset/cli-processing-indicator.md new file mode 100644 index 00000000000..f74a21f2cc5 --- /dev/null +++ b/.changeset/cli-processing-indicator.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep "Thinking..." indicator visible in CLI after checkpoint saves and API calls complete diff --git a/cli/src/state/atoms/ui.ts b/cli/src/state/atoms/ui.ts index 085e6e71b99..4a9952b655c 100644 --- a/cli/src/state/atoms/ui.ts +++ b/cli/src/state/atoms/ui.ts @@ -17,6 +17,7 @@ import { chatMessagesAtom } from "./extension.js" import { splitMessages } from "../../ui/messages/utils/messageCompletion.js" import { textBufferStringAtom, textBufferCursorAtom, setTextAtom, clearTextAtom } from "./textBuffer.js" import { commitCompletionTimeout } from "../../parallel/parallel.js" +import { logs } from "../../services/logs.js" /** * Unified message type that can represent both CLI and extension messages @@ -143,6 +144,56 @@ export const isStreamingAtom = atom((get) => { */ export const isCancellingAtom = atom(false) +/** + * Derived atom to check if the task is actively processing but not streaming. + * This fills the gap when isStreamingAtom returns false but the task is still running. + * + * Returns true when: + * - The last message is `checkpoint_saved` (task just saved a checkpoint, will continue) + * - OR the last message is `api_req_started` with a cost (API call finished, task will continue) + * + * This is more precise than checking for "any non-completion message" because it only + * triggers for specific messages that indicate the task is actively processing. + */ +export const isProcessingAtom = atom((get) => { + const messages = get(chatMessagesAtom) + + if (messages.length === 0) { + return false + } + + const lastMessage = messages[messages.length - 1] + if (!lastMessage) { + return false + } + + // If streaming, let isStreamingAtom handle it + const isStreaming = get(isStreamingAtom) + if (isStreaming) { + return false + } + + // After checkpoint_saved, the task is still running + if (lastMessage.say === "checkpoint_saved") { + return true + } + + // After api_req_started with cost (finished API call), the task is processing the response + if (lastMessage.say === "api_req_started" && lastMessage.text) { + try { + const apiReqInfo = JSON.parse(lastMessage.text) + // If there's a cost, the API call has finished and the task is processing + if (apiReqInfo.cost !== undefined && apiReqInfo.cost > 0) { + return true + } + } catch (error) { + logs.debug("Failed to parse api_req_started message in isProcessingAtom", "UIAtoms", { error }) + } + } + + return false +}) + // ============================================================================ // Input Mode System // ============================================================================ diff --git a/cli/src/ui/components/StatusIndicator.tsx b/cli/src/ui/components/StatusIndicator.tsx index 20715d7dfff..3e78597922e 100644 --- a/cli/src/ui/components/StatusIndicator.tsx +++ b/cli/src/ui/components/StatusIndicator.tsx @@ -10,7 +10,7 @@ import { useTheme } from "../../state/hooks/useTheme.js" import { HotkeyBadge } from "./HotkeyBadge.js" import { ThinkingAnimation } from "./ThinkingAnimation.js" import { useAtomValue, useSetAtom } from "jotai" -import { isStreamingAtom, isCancellingAtom } from "../../state/atoms/ui.js" +import { isStreamingAtom, isCancellingAtom, isProcessingAtom } from "../../state/atoms/ui.js" import { hasResumeTaskAtom } from "../../state/atoms/extension.js" import { exitPromptVisibleAtom, pendingImagePastesAtom, pendingTextPastesAtom } from "../../state/atoms/keyboard.js" import { useEffect } from "react" @@ -38,6 +38,7 @@ export const StatusIndicator: React.FC = ({ disabled = fal const theme = useTheme() const { hotkeys, shouldShow } = useHotkeys() const isStreaming = useAtomValue(isStreamingAtom) + const isProcessing = useAtomValue(isProcessingAtom) const isCancelling = useAtomValue(isCancellingAtom) const setIsCancelling = useSetAtom(isCancellingAtom) const hasResumeTask = useAtomValue(hasResumeTaskAtom) @@ -84,7 +85,9 @@ export const StatusIndicator: React.FC = ({ disabled = fal {isCancelling && !isPastingImage && !isPastingText && ( )} - {isStreaming && !isCancelling && !isPastingImage && !isPastingText && } + {(isStreaming || isProcessing) && !isCancelling && !isPastingImage && !isPastingText && ( + + )} {hasResumeTask && !isPastingImage && !isPastingText && ( Task ready to resume )} diff --git a/cli/src/ui/components/__tests__/StatusIndicator.test.tsx b/cli/src/ui/components/__tests__/StatusIndicator.test.tsx index 967edba32b1..89f1bdc17fa 100644 --- a/cli/src/ui/components/__tests__/StatusIndicator.test.tsx +++ b/cli/src/ui/components/__tests__/StatusIndicator.test.tsx @@ -216,4 +216,131 @@ describe("StatusIndicator", () => { expect(output).toContain("Cancelling...") expect(output).not.toContain("Thinking...") }) + + it("should show indicator after checkpoint_saved when task is still running", () => { + // This reproduces the bug from issue #5251 + // After a checkpoint is saved, the indicator disappears because: + // 1. The checkpoint_saved message is not partial + // 2. The previous api_req_started has a cost (finished) + // 3. The next api_req_started hasn't been sent yet + // But the task is still running, so we should show an indicator + + const messages: ExtensionChatMessage[] = [ + // User started a task + { + type: "say", + say: "text", + ts: 1000, + text: "Starting task...", + partial: false, + }, + // API request completed (has cost) + { + type: "say", + say: "api_req_started", + ts: 2000, + text: JSON.stringify({ cost: 0.001, tokensIn: 100, tokensOut: 50 }), + partial: false, + }, + // Checkpoint was saved - this is the last message + { + type: "say", + say: "checkpoint_saved", + ts: 3000, + text: "abc123", + partial: false, + }, + ] + store.set(chatMessagesAtom, messages) + + const { lastFrame } = render( + + + , + ) + + const output = lastFrame() + // The task is still running (no completion_result), so we should show an indicator + // Currently this fails because isStreamingAtom returns false + expect(output).toMatch(/(?:Thinking|Processing)\.\.\./) + }) + + it("should NOT show indicator after completion_result", () => { + // When the task is complete, we should NOT show any indicator + const messages: ExtensionChatMessage[] = [ + { + type: "say", + say: "text", + ts: 1000, + text: "Starting task...", + partial: false, + }, + { + type: "say", + say: "api_req_started", + ts: 2000, + text: JSON.stringify({ cost: 0.001, tokensIn: 100, tokensOut: 50 }), + partial: false, + }, + { + type: "say", + say: "completion_result", + ts: 3000, + text: "Task completed successfully", + partial: false, + }, + ] + store.set(chatMessagesAtom, messages) + + const { lastFrame } = render( + + + , + ) + + const output = lastFrame() + // Task is complete, should NOT show Thinking or Processing + expect(output).not.toContain("Thinking...") + expect(output).not.toContain("Processing...") + }) + + it("should NOT show indicator when waiting for tool approval", () => { + // When waiting for user to approve a tool, we should NOT show indicator + const messages: ExtensionChatMessage[] = [ + { + type: "say", + say: "text", + ts: 1000, + text: "Starting task...", + partial: false, + }, + { + type: "say", + say: "api_req_started", + ts: 2000, + text: JSON.stringify({ cost: 0.001, tokensIn: 100, tokensOut: 50 }), + partial: false, + }, + // Tool asking for approval + { + type: "ask", + ask: "tool", + ts: 3000, + text: JSON.stringify({ tool: "write_to_file", path: "test.txt" }), + partial: false, + }, + ] + store.set(chatMessagesAtom, messages) + + const { lastFrame } = render( + + + , + ) + + const output = lastFrame() + // Waiting for approval, should NOT show Thinking or Processing + expect(output).not.toContain("Thinking...") + expect(output).not.toContain("Processing...") + }) })