Skip to content
Open
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
4 changes: 2 additions & 2 deletions cli/src/services/__tests__/fileSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe("FileSearchService", () => {
const results2 = await fileSearchService.getAllFiles(cwd)

expect(results1).toBe(results2) // Now cached again
})
}, 10000)

it("should clear cache for specific workspace", async () => {
const cwd1 = process.cwd()
Expand All @@ -106,6 +106,6 @@ describe("FileSearchService", () => {
// cwd1 should be re-fetched, cwd2 should be cached
const newResults1 = await fileSearchService.getAllFiles(cwd1)
expect(newResults1).not.toBe(results1)
})
}, 10000)
})
})
18 changes: 18 additions & 0 deletions cli/src/state/atoms/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ export const hasActiveTaskAtom = atom<boolean>((get) => {
*/
export const taskResumedViaContinueAtom = atom<boolean>(false)

/**
* Atom to track the timestamp of the last API stream activity
* Updated whenever a chunk arrives from the API stream (text, reasoning, usage, etc.)
* Used to detect if the model has stopped sending tokens
*/
export const lastActivityTimestampAtom = atom<number>(0)

/**
* Derived atom to check if there's a resume_task ask pending
* This checks if the last message is a resume_task or resume_completed_task
Expand Down Expand Up @@ -243,6 +250,9 @@ export const updateExtensionStateAtom = atom(null, (get, set, state: ExtensionSt
set(customModesAtom, state.customModes || [])
set(mcpServersAtom, state.mcpServers || [])
set(cwdAtom, state.cwd || null)
if (state.lastActivityTimestamp) {
set(lastActivityTimestampAtom, state.lastActivityTimestamp)
}
} else {
// Clear all derived atoms
set(chatMessagesAtom, [])
Expand Down Expand Up @@ -459,6 +469,14 @@ export const clearExtensionStateAtom = atom(null, (get, set) => {
set(streamingMessagesSetAtom, new Set<number>())
})

/**
* Action atom to update the last API stream activity timestamp
* Called whenever a chunk arrives from the API stream
*/
export const updateLastActivityTimestampAtom = atom(null, (_get, set) => {
set(lastActivityTimestampAtom, Date.now())
})

/**
* Helper function to calculate message content length for versioning
* Used to determine which version of a message is newer
Expand Down
50 changes: 19 additions & 31 deletions cli/src/state/atoms/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type {
FileMentionSuggestion,
FileMentionContext,
} from "../../services/autocomplete.js"
import { chatMessagesAtom } from "./extension.js"
import { chatMessagesAtom, lastActivityTimestampAtom } 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"
Expand Down Expand Up @@ -66,21 +66,31 @@ export const isCommittingParallelModeAtom = atom<boolean>(false)
*/
export const commitCountdownSecondsAtom = atomWithReset<number>(commitCompletionTimeout / 1000)

/**
* Timeout (in ms) to consider the stream inactive if no chunks received
* This detects when the model has stopped sending tokens
*/
const STREAMING_ACTIVITY_TIMEOUT_MS = 3000 // 3 seconds

/**
* Derived atom to check if the extension is currently streaming/processing
* This mimics the webview's isStreaming logic from ChatView.tsx (lines 550-592)
* Uses real API activity tracking instead of state inference
*
* Returns true when:
* - The last message is partial (still being streamed)
* - There's an active API request that hasn't finished yet (no cost field)
* - Chunks are actively arriving from the API stream (within STREAMING_ACTIVITY_TIMEOUT_MS)
* - This is determined by lastActivityTimestamp being recent
*
* Returns false when:
* - No API activity for more than STREAMING_ACTIVITY_TIMEOUT_MS
* - There's a tool currently asking for approval (waiting for user input)
* - No messages exist
* - All messages are complete
*
* This provides accurate detection of actual model activity rather than inferring
* from partial messages and api_req_started status.
*/
export const isStreamingAtom = atom<boolean>((get) => {
const messages = get(chatMessagesAtom)
const lastActivityTimestamp = get(lastActivityTimestampAtom)

if (messages.length === 0) {
return false
Expand All @@ -100,32 +110,10 @@ export const isStreamingAtom = atom<boolean>((get) => {
return false
}

// Check if the last message is partial (still streaming)
if (lastMessage.partial === true) {
return true
}

// Check if there's an active API request without a cost (not finished)
// Find the last api_req_started message
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg?.say === "api_req_started") {
try {
const data = JSON.parse(msg.text || "{}")
// If cost is undefined, the API request hasn't finished yet
if (data.cost === undefined) {
return true
}
} catch {
// If we can't parse, assume not streaming
return false
}
// Found an api_req_started with cost, so it's finished
break
}
}

return false
// Check if there's recent API activity
// If we've received a chunk within the timeout period, we're streaming
const timeSinceLastActivity = Date.now() - lastActivityTimestamp
return timeSinceLastActivity < STREAMING_ACTIVITY_TIMEOUT_MS
})

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@
import { describe, it, expect, beforeEach } from "vitest"
import { createStore } from "jotai"
import { isStreamingAtom } from "../../atoms/ui.js"
import { chatMessagesAtom, updateChatMessagesAtom } from "../../atoms/extension.js"
import { chatMessagesAtom, updateChatMessagesAtom, lastActivityTimestampAtom } from "../../atoms/extension.js"
import type { ExtensionChatMessage } from "../../../types/messages.js"

describe("isStreamingAtom Logic", () => {
let store: ReturnType<typeof createStore>

beforeEach(() => {
store = createStore()
// Default: no activity (timestamp = 0) so streaming detection depends on explicit setup per test
store.set(lastActivityTimestampAtom, 0)
})

describe("isStreaming state management", () => {
Expand All @@ -22,6 +24,8 @@ describe("isStreamingAtom Logic", () => {
})

it("should be true when last message is partial", () => {
// Set recent activity for streaming detection
store.set(lastActivityTimestampAtom, Date.now())
const partialMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "say",
Expand All @@ -34,6 +38,7 @@ describe("isStreamingAtom Logic", () => {
})

it("should be false when last message is complete", () => {
// No recent activity = not streaming
const completeMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "say",
Expand All @@ -46,6 +51,8 @@ describe("isStreamingAtom Logic", () => {
})

it("should be false when tool is asking for approval", () => {
// Even with recent activity, tool ask blocks streaming
store.set(lastActivityTimestampAtom, Date.now())
const toolMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
Expand All @@ -57,6 +64,8 @@ describe("isStreamingAtom Logic", () => {
})

it("should be true when API request hasn't finished (no cost)", () => {
// Set recent activity for streaming detection
store.set(lastActivityTimestampAtom, Date.now())
const apiReqMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "say",
Expand All @@ -68,6 +77,7 @@ describe("isStreamingAtom Logic", () => {
})

it("should be false when API request has finished (has cost)", () => {
// No recent activity = not streaming
const apiReqMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "say",
Expand All @@ -81,6 +91,7 @@ describe("isStreamingAtom Logic", () => {

describe("Message handling scenarios", () => {
it("should not be streaming for completion_result ask message", () => {
// No recent activity = not streaming
const completionMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
Expand All @@ -93,6 +104,7 @@ describe("isStreamingAtom Logic", () => {
})

it("should not be streaming for followup ask message", () => {
// No recent activity = not streaming
const followupMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
Expand All @@ -105,6 +117,8 @@ describe("isStreamingAtom Logic", () => {
})

it("should not be streaming for tool approval ask message", () => {
// Tool ask explicitly blocks streaming, even with recent activity
store.set(lastActivityTimestampAtom, Date.now())
const toolMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
Expand All @@ -117,6 +131,7 @@ describe("isStreamingAtom Logic", () => {
})

it("should not be streaming for command approval ask message", () => {
// No recent activity = not streaming
const commandMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "ask",
Expand All @@ -129,6 +144,7 @@ describe("isStreamingAtom Logic", () => {
})

it("should not be streaming for complete say messages", () => {
// No recent activity = not streaming
const sayMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "say",
Expand All @@ -142,6 +158,8 @@ describe("isStreamingAtom Logic", () => {
})

it("should be streaming for partial say messages", () => {
// Set recent activity for streaming detection
store.set(lastActivityTimestampAtom, Date.now())
const sayMessage: ExtensionChatMessage = {
ts: Date.now(),
type: "say",
Expand All @@ -155,6 +173,7 @@ describe("isStreamingAtom Logic", () => {
})

it("should handle multiple messages with last being complete", () => {
// No recent activity = not streaming
const messages: ExtensionChatMessage[] = [
{
ts: Date.now(),
Expand Down Expand Up @@ -188,6 +207,8 @@ describe("isStreamingAtom Logic", () => {
})

it("should handle message updates via updateChatMessagesAtom", () => {
// Set recent activity initially for streaming
store.set(lastActivityTimestampAtom, Date.now())
const initialMessages: ExtensionChatMessage[] = [
{
ts: Date.now(),
Expand All @@ -201,6 +222,8 @@ describe("isStreamingAtom Logic", () => {
store.set(chatMessagesAtom, initialMessages)
expect(store.get(isStreamingAtom)).toBe(true)

// Clear activity timestamp before updating to completion
store.set(lastActivityTimestampAtom, 0)
const updatedMessages: ExtensionChatMessage[] = [
...initialMessages,
{
Expand Down
1 change: 1 addition & 0 deletions cli/src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export interface ExtensionState {
cwd?: string
organizationAllowList?: OrganizationAllowList
routerModels?: RouterModels
lastActivityTimestamp?: number // Track actual API stream activity for more accurate streaming detection
[key: string]: unknown
}

Expand Down
3 changes: 2 additions & 1 deletion cli/src/ui/components/StatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Box, Text } from "ink"
import { useHotkeys } from "../../state/hooks/useHotkeys.js"
import { useTheme } from "../../state/hooks/useTheme.js"
import { HotkeyBadge } from "./HotkeyBadge.js"
import { ThinkingSpinner } from "./ThinkingSpinner.js"
import { useAtomValue } from "jotai"
import { isStreamingAtom } from "../../state/atoms/ui.js"
import { hasResumeTaskAtom } from "../../state/atoms/extension.js"
Expand Down Expand Up @@ -43,7 +44,7 @@ export const StatusIndicator: React.FC<StatusIndicatorProps> = ({ disabled = fal
<Box borderStyle="round" borderColor={theme.ui.border.default} paddingX={1} justifyContent="space-between">
{/* Status text on the left */}
<Box>
{isStreaming && <Text color={theme.ui.text.dimmed}>Thinking...</Text>}
{isStreaming && <ThinkingSpinner color={theme.ui.text.dimmed} />}
{hasResumeTask && <Text color={theme.ui.text.dimmed}>Task ready to resume</Text>}
</Box>

Expand Down
32 changes: 32 additions & 0 deletions cli/src/ui/components/ThinkingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* ThinkingSpinner - Animated spinner for the thinking state
* Shows an animated loading spinner with "Thinking..." text
*/

import React, { useEffect, useState } from "react"
import { Text } from "ink"

interface ThinkingSpinnerProps {
color?: string
}

const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const ANIMATION_INTERVAL = 80 // ms per frame

/**
* Displays an animated spinner with "Thinking..." text
* Uses Braille Unicode characters for smooth animation
*/
export const ThinkingSpinner: React.FC<ThinkingSpinnerProps> = ({ color = "gray" }) => {
const [frameIndex, setFrameIndex] = useState(0)

useEffect(() => {
const interval = setInterval(() => {
setFrameIndex((prev) => (prev + 1) % SPINNER_FRAMES.length)
}, ANIMATION_INTERVAL)

return () => clearInterval(interval)
}, [])

return <Text color={color}>{SPINNER_FRAMES[frameIndex]} Thinking...</Text>
}
18 changes: 16 additions & 2 deletions cli/src/ui/components/__tests__/StatusIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

import React from "react"
import { render } from "ink-testing-library"
import { describe, it, expect, vi, beforeEach } from "vitest"
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { Provider as JotaiProvider } from "jotai"
import { createStore } from "jotai"
import { StatusIndicator } from "../StatusIndicator.js"
import { showFollowupSuggestionsAtom } from "../../../state/atoms/ui.js"
import { chatMessagesAtom } from "../../../state/atoms/extension.js"
import { chatMessagesAtom, lastActivityTimestampAtom } from "../../../state/atoms/extension.js"
import type { ExtensionChatMessage } from "../../../types/messages.js"

// Mock the hooks
Expand All @@ -20,11 +20,22 @@ vi.mock("../../../state/hooks/useWebviewMessage.js", () => ({
}),
}))

// Mock timers for ThinkingSpinner animation
vi.useFakeTimers()

describe("StatusIndicator", () => {
let store: ReturnType<typeof createStore>

beforeEach(() => {
store = createStore()
// Default: no activity (timestamp = 0) so tests must explicitly set recent activity if needed
store.set(lastActivityTimestampAtom, 0)
// Reset animation frame for each test
vi.clearAllTimers()
})

afterEach(() => {
vi.clearAllTimers()
})

it("should not render when disabled", () => {
Expand All @@ -38,6 +49,8 @@ describe("StatusIndicator", () => {
})

it("should show Thinking status and cancel hotkey when streaming", () => {
// Set recent activity for streaming detection
store.set(lastActivityTimestampAtom, Date.now())
// Set up a partial message to trigger streaming state
const partialMessage: ExtensionChatMessage = {
type: "say",
Expand All @@ -55,6 +68,7 @@ describe("StatusIndicator", () => {
)

const output = lastFrame()
// ThinkingSpinner contains animated frame plus "Thinking..." text
expect(output).toContain("Thinking...")
expect(output).toContain("to cancel")
// Should show either Ctrl+X or Cmd+X depending on platform
Expand Down
Loading