Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-processing-indicator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Keep "Thinking..." indicator visible in CLI after checkpoint saves and API calls complete
51 changes: 51 additions & 0 deletions cli/src/state/atoms/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +144,56 @@ export const isStreamingAtom = atom<boolean>((get) => {
*/
export const isCancellingAtom = atom<boolean>(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<boolean>((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
// ============================================================================
Expand Down
7 changes: 5 additions & 2 deletions cli/src/ui/components/StatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -38,6 +38,7 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ 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)
Expand Down Expand Up @@ -84,7 +85,9 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ disabled = fal
{isCancelling && !isPastingImage && !isPastingText && (
<ThinkingAnimation text="Cancelling..." />
)}
{isStreaming && !isCancelling && !isPastingImage && !isPastingText && <ThinkingAnimation />}
{(isStreaming || isProcessing) && !isCancelling && !isPastingImage && !isPastingText && (
<ThinkingAnimation />
)}
{hasResumeTask && !isPastingImage && !isPastingText && (
<Text color={theme.ui.text.dimmed}>Task ready to resume</Text>
)}
Expand Down
127 changes: 127 additions & 0 deletions cli/src/ui/components/__tests__/StatusIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<JotaiProvider store={store}>
<StatusIndicator disabled={false} />
</JotaiProvider>,
)

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(
<JotaiProvider store={store}>
<StatusIndicator disabled={false} />
</JotaiProvider>,
)

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(
<JotaiProvider store={store}>
<StatusIndicator disabled={false} />
</JotaiProvider>,
)

const output = lastFrame()
// Waiting for approval, should NOT show Thinking or Processing
expect(output).not.toContain("Thinking...")
expect(output).not.toContain("Processing...")
})
})