diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 4ea9864e..f26f7297 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -259,6 +259,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -302,6 +303,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -350,6 +352,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -417,6 +420,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -487,6 +491,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -543,6 +548,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -599,6 +605,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [ @@ -663,6 +670,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -748,6 +756,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 50_000, output_tokens: 1_000 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -804,6 +813,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -868,6 +878,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -948,6 +959,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -1031,6 +1043,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 72_000, output_tokens: 2_000 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -1100,6 +1113,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -1197,6 +1211,7 @@ describe('Content-only pages render without errors', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 26_000, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/components/chat/AskUserQuestion.test.tsx b/desktop/src/components/chat/AskUserQuestion.test.tsx index 9428c364..c6ecf7ad 100644 --- a/desktop/src/components/chat/AskUserQuestion.test.tsx +++ b/desktop/src/components/chat/AskUserQuestion.test.tsx @@ -63,6 +63,7 @@ describe('AskUserQuestion', () => { }, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index d95a66cb..95e34173 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -188,6 +188,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -255,6 +256,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -273,6 +275,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -430,6 +433,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -481,6 +485,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -527,6 +532,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -591,6 +597,7 @@ describe('ChatInput file mentions', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/components/chat/MessageList.test.tsx b/desktop/src/components/chat/MessageList.test.tsx index e0edd062..928c838c 100644 --- a/desktop/src/components/chat/MessageList.test.tsx +++ b/desktop/src/components/chat/MessageList.test.tsx @@ -36,6 +36,7 @@ function makeSessionState(overrides: Partial = {}): PerSessionS pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', apiRetry: null, @@ -580,7 +581,7 @@ describe('MessageList nested tool calls', () => { expect(card.textContent).toContain('Background command') expect(card.textContent).toContain('running') expect(card.textContent).toContain('Running Playwright checks') - expect(card.textContent).toContain('1,200 tokens') + expect(card.textContent).toContain('1.2k tokens') expect(card.textContent).toContain('45s') }) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 72273f5f..a234f704 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -23,6 +23,7 @@ import { StreamingIndicator } from './StreamingIndicator' import { InlineTaskSummary } from './InlineTaskSummary' import { CurrentTurnChangeCard } from './CurrentTurnChangeCard' import type { AgentTaskNotification, UIMessage } from '../../types/chat' +import { formatTokenCount } from '../../lib/formatTokenCount' import { ConfirmDialog } from '../shared/ConfirmDialog' import { clearWindowSelection, getSelectionPopoverPosition, useSelectionPopoverDismiss } from '../../hooks/useSelectionPopoverDismiss' import { @@ -158,11 +159,6 @@ function ChatSelectionMenu({ ) } -function formatCompactTokenCount(tokens: number): string { - if (tokens >= 1000) return `${Math.round(tokens / 100) / 10}k` - return String(tokens) -} - function getCompactSummaryTitle(message: CompactSummaryEvent, t: ReturnType) { if (message.trigger === 'auto') return t('chat.compactSummary.autoTitle') if (message.trigger === 'manual') return t('chat.compactSummary.manualTitle') @@ -179,7 +175,7 @@ function CompactStatusDivider({ message, state }: { message?: CompactSummaryEven const meta = [ message?.trigger ? t(`chat.compactSummary.trigger.${message.trigger}` as TranslationKey) : null, typeof message?.preTokens === 'number' - ? t('chat.compactSummary.tokens', { count: formatCompactTokenCount(message.preTokens) }) + ? t('chat.compactSummary.tokens', { count: formatTokenCount(message.preTokens) }) : null, typeof message?.messagesSummarized === 'number' ? t('chat.compactSummary.messages', { count: String(message.messagesSummarized) }) @@ -350,7 +346,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent }) {task.usage?.totalTokens ? ( - {t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })} + {t('chat.backgroundAgents.tokens', { count: formatTokenCount(task.usage.totalTokens) })} ) : null} {duration ? ( diff --git a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx index c76908fc..458639fd 100644 --- a/desktop/src/components/chat/PlanModePermissionDialog.test.tsx +++ b/desktop/src/components/chat/PlanModePermissionDialog.test.tsx @@ -57,6 +57,7 @@ function seedPendingPlanPermission() { }, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/components/chat/StreamingIndicator.test.tsx b/desktop/src/components/chat/StreamingIndicator.test.tsx new file mode 100644 index 00000000..ba32c732 --- /dev/null +++ b/desktop/src/components/chat/StreamingIndicator.test.tsx @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' + +vi.mock('../../api/websocket', () => ({ + wsManager: { + connect: vi.fn(), + disconnect: vi.fn(), + onMessage: vi.fn(() => () => {}), + clearHandlers: vi.fn(), + send: vi.fn(), + }, +})) + +vi.mock('../../api/sessions', () => ({ + sessionsApi: { + getMessages: vi.fn(async () => ({ messages: [] })), + getSlashCommands: vi.fn(async () => ({ commands: [] })), + }, +})) + +import { StreamingIndicator } from './StreamingIndicator' +import { useChatStore, type PerSessionState } from '../../stores/chatStore' +import { useSettingsStore } from '../../stores/settingsStore' +import { useTabStore } from '../../stores/tabStore' + +const ACTIVE_TAB = 'active-tab' + +function makeSession(overrides: Partial = {}): PerSessionState { + return { + messages: [], + chatState: 'streaming', + connectionState: 'connected', + historyStatus: 'idle', + historyError: null, + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + apiRetry: null, + slashCommands: [], + agentTaskNotifications: {}, + backgroundAgentTasks: {}, + elapsedTimer: null, + ...overrides, + } +} + +describe('StreamingIndicator', () => { + beforeEach(() => { + useSettingsStore.setState({ locale: 'en' }) + useTabStore.setState({ + activeTabId: ACTIVE_TAB, + tabs: [{ sessionId: ACTIVE_TAB, title: 'Test', type: 'session', status: 'running' }], + }) + }) + + // issue #757: token usage is rendered with the shared compact notation and + // an explicit unit instead of a bare number. + it('renders the current turn token estimate as "↓ N tokens"', () => { + useChatStore.setState({ + sessions: { + // 8976 streamed chars ÷ 4 = 2244 tokens → "2.2k tokens" + [ACTIVE_TAB]: makeSession({ streamingResponseChars: 8976 }), + }, + }) + + render() + + expect(screen.getByText(/↓ 2\.2k tokens/)).toBeTruthy() + }) + + it('hides the token estimate until this turn has streamed output', () => { + useChatStore.setState({ + sessions: { + // Previous-turn usage must not leak into the indicator (issue #757 + // follow-up report: the count showed a stale value from the last turn). + [ACTIVE_TAB]: makeSession({ + tokenUsage: { input_tokens: 5000, output_tokens: 6240 }, + }), + }, + }) + + render() + + expect(screen.queryByText(/tokens/)).toBeNull() + }) +}) diff --git a/desktop/src/components/chat/StreamingIndicator.tsx b/desktop/src/components/chat/StreamingIndicator.tsx index a2e35d70..6d885647 100644 --- a/desktop/src/components/chat/StreamingIndicator.tsx +++ b/desktop/src/components/chat/StreamingIndicator.tsx @@ -3,6 +3,7 @@ import { RefreshCw } from 'lucide-react' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useTranslation, type TranslationKey } from '../../i18n' +import { formatTokenCount } from '../../lib/formatTokenCount' function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s` @@ -41,7 +42,8 @@ export function StreamingIndicator() { const statusVerb = sessionState?.statusVerb ?? '' const apiRetry = sessionState?.apiRetry ?? null const elapsedSeconds = sessionState?.elapsedSeconds ?? 0 - const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } + // chars ÷ 4 estimates output tokens for this turn, mirroring the CLI spinner. + const streamingTokens = Math.round((sessionState?.streamingResponseChars ?? 0) / 4) useEffect(() => { if (!apiRetry) return undefined @@ -106,9 +108,9 @@ export function StreamingIndicator() { {formatElapsed(elapsedSeconds)} )} - {tokenUsage.output_tokens > 0 && ( + {streamingTokens > 0 && ( - · ↓ {tokenUsage.output_tokens} + · ↓ {t('common.tokens', { count: formatTokenCount(streamingTokens) })} )} diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index ad7d05be..9f825c21 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -281,6 +281,7 @@ describe('chat blocks', () => { }, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 0fee008e..c9ac1e20 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -30,6 +30,7 @@ function makeChatSession(chatState: ChatState): PerSessionState { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 6d3a64d9..71f30172 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -1369,6 +1369,7 @@ describe('WorkspacePanel', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 8722011a..d26d31d8 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -7,6 +7,7 @@ export const en = { 'common.run': 'Run', 'common.send': 'Send', 'common.stop': 'Stop', + 'common.tokens': '{count} tokens', 'common.rename': 'Rename', 'common.retry': 'Retry', 'common.loading': 'Loading...', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 00f696af..19a4e515 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -9,6 +9,7 @@ export const jp: Record = { 'common.run': '実行', 'common.send': '送信', 'common.stop': '停止', + 'common.tokens': '{count} トークン', 'common.rename': '名前を変更', 'common.retry': '再試行', 'common.loading': '読み込み中...', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index c401d458..47e52ff2 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -9,6 +9,7 @@ export const kr: Record = { 'common.run': '실행', 'common.send': '전송', 'common.stop': '중지', + 'common.tokens': '{count} 토큰', 'common.rename': '이름 바꾸기', 'common.retry': '다시 시도', 'common.loading': '불러오는 중...', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 7a218ddd..fafbf063 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -9,6 +9,7 @@ export const zh: Record = { 'common.run': '執行', 'common.send': '傳送', 'common.stop': '停止', + 'common.tokens': '{count} tokens', 'common.rename': '重新命名', 'common.retry': '重試', 'common.loading': '載入中...', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index d0e6822f..fa1062b1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -9,6 +9,7 @@ export const zh: Record = { 'common.run': '运行', 'common.send': '发送', 'common.stop': '停止', + 'common.tokens': '{count} tokens', 'common.rename': '重命名', 'common.retry': '重试', 'common.loading': '加载中...', diff --git a/desktop/src/lib/formatTokenCount.test.ts b/desktop/src/lib/formatTokenCount.test.ts new file mode 100644 index 00000000..5edcf708 --- /dev/null +++ b/desktop/src/lib/formatTokenCount.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { formatTokenCount } from './formatTokenCount' + +describe('formatTokenCount', () => { + it('formats missing or invalid values as --', () => { + expect(formatTokenCount()).toBe('--') + expect(formatTokenCount(Number.NaN)).toBe('--') + expect(formatTokenCount(-1)).toBe('--') + }) + + it('formats counts below 1000 verbatim', () => { + expect(formatTokenCount(0)).toBe('0') + expect(formatTokenCount(847)).toBe('847') + }) + + it('formats thousands with one decimal and a k suffix', () => { + expect(formatTokenCount(1234)).toBe('1.2k') + expect(formatTokenCount(38_500)).toBe('38.5k') + expect(formatTokenCount(124_320)).toBe('124.3k') + }) + + it('drops the trailing .0 to match the CLI notation', () => { + expect(formatTokenCount(1000)).toBe('1k') + expect(formatTokenCount(2_000_000)).toBe('2m') + }) + + it('formats millions with an m suffix', () => { + expect(formatTokenCount(2_400_000)).toBe('2.4m') + }) +}) diff --git a/desktop/src/lib/formatTokenCount.ts b/desktop/src/lib/formatTokenCount.ts new file mode 100644 index 00000000..496b2fce --- /dev/null +++ b/desktop/src/lib/formatTokenCount.ts @@ -0,0 +1,13 @@ +/** + * Compact token-count formatter shared by the chat UI (session header, + * streaming indicator, compact summary, background agents) and trace views, + * so every surface renders token usage with one notation. + * "847" below 1000, "1.2k" up to 1M, "1.2m" beyond — trailing ".0" dropped + * ("1k", not "1.0k") to match the CLI's formatTokens. + */ +export function formatTokenCount(n?: number): string { + if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '--' + if (n < 1000) return String(Math.round(n)) + if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}m` +} diff --git a/desktop/src/lib/trace/formatters.test.ts b/desktop/src/lib/trace/formatters.test.ts index f6288751..c37876ed 100644 --- a/desktop/src/lib/trace/formatters.test.ts +++ b/desktop/src/lib/trace/formatters.test.ts @@ -29,25 +29,10 @@ describe('formatDurationMs', () => { }) describe('formatTokenCount', () => { - it('formats missing or invalid values as --', () => { - expect(formatTokenCount()).toBe('--') - expect(formatTokenCount(Number.NaN)).toBe('--') - expect(formatTokenCount(-1)).toBe('--') - }) - - it('formats counts below 1000 verbatim', () => { - expect(formatTokenCount(0)).toBe('0') - expect(formatTokenCount(847)).toBe('847') - }) - - it('formats thousands with one decimal and a k suffix', () => { - expect(formatTokenCount(1000)).toBe('1.0k') + // Full coverage lives in ../formatTokenCount.test.ts; this verifies the re-export. + it('re-exports the shared compact formatter', () => { expect(formatTokenCount(1234)).toBe('1.2k') - expect(formatTokenCount(38_500)).toBe('38.5k') - }) - - it('formats millions with an m suffix', () => { - expect(formatTokenCount(2_400_000)).toBe('2.4m') + expect(formatTokenCount(1000)).toBe('1k') }) }) diff --git a/desktop/src/lib/trace/formatters.ts b/desktop/src/lib/trace/formatters.ts index 93cb19c2..db9db257 100644 --- a/desktop/src/lib/trace/formatters.ts +++ b/desktop/src/lib/trace/formatters.ts @@ -1,6 +1,9 @@ import type { TraceCallUsage } from '../../types/trace' +import { formatTokenCount } from '../formatTokenCount' import type { NormalizedUsage } from './types' +export { formatTokenCount } + export function formatDurationMs(ms?: number): string { if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return '--' if (ms < 1000) return `${Math.round(ms)}ms` @@ -12,13 +15,6 @@ export function formatDurationMs(ms?: number): string { return `${minutes}m ${String(seconds).padStart(2, '0')}s` } -export function formatTokenCount(n?: number): string { - if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '--' - if (n < 1000) return String(Math.round(n)) - if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k` - return `${(n / 1_000_000).toFixed(1)}m` -} - export function formatUsageBrief(u?: NormalizedUsage | TraceCallUsage): string { if (!u) return '--' return `${formatTokenCount(u.inputTokens)} → ${formatTokenCount(u.outputTokens)}` diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index e7f63bdd..c1ac306d 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -132,6 +132,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -239,6 +240,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -305,6 +307,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -379,6 +382,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -441,6 +445,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -502,6 +507,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -581,6 +587,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -636,6 +643,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -708,6 +716,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -763,6 +772,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -816,6 +826,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -875,6 +886,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -982,6 +994,7 @@ describe('ActiveSession task polling', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index b49a19f8..39cd5cc5 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -31,6 +31,7 @@ import type { SessionListItem } from '../types/session' import type { ActiveGoalState } from '../types/chat' import { useMobileViewport } from '../hooks/useMobileViewport' import { isDesktopRuntime } from '../lib/desktopRuntime' +import { formatTokenCount } from '../lib/formatTokenCount' import { publicAssetPath } from '../lib/publicAsset' const TASK_POLL_INTERVAL_MS = 1000 @@ -479,7 +480,9 @@ export function ActiveSession() { {totalTokens > 0 && ( <> · - {totalTokens.toLocaleString()} t + + {t('common.tokens', { count: formatTokenCount(totalTokens) })} + )} {lastUpdated && ( diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index ced3ef43..53998b03 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -151,6 +151,7 @@ function makeSession(overrides: Partial = {}): PerSessionState pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', apiRetry: null, @@ -1292,6 +1293,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -1376,6 +1378,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -1452,6 +1455,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -1619,6 +1623,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [{ name: 'old-command', description: 'Old command' }], @@ -1992,6 +1997,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2055,6 +2061,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2116,6 +2123,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2437,6 +2445,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 12, output_tokens: 34 }, + streamingResponseChars: 999, elapsedSeconds: 5, statusVerb: 'Thinking', slashCommands: [], @@ -2461,6 +2470,7 @@ describe('chatStore history mapping', () => { expect(session?.streamingText).toBe('') expect(session?.chatState).toBe('idle') expect(session?.tokenUsage).toEqual({ input_tokens: 0, output_tokens: 0 }) + expect(session?.streamingResponseChars).toBe(0) expect(session?.slashCommands).toEqual([]) expect(clearTasksMock).toHaveBeenCalledWith(TEST_SESSION_ID) @@ -2510,6 +2520,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2555,6 +2566,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: 'Compacting conversation', slashCommands: [], @@ -2609,6 +2621,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2657,6 +2670,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2737,6 +2751,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -2981,6 +2996,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3026,6 +3042,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3133,6 +3150,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3203,6 +3221,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3493,6 +3512,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3595,6 +3615,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3647,6 +3668,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3679,6 +3701,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3720,6 +3743,7 @@ describe('chatStore history mapping', () => { }, }, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3784,6 +3808,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3825,6 +3850,7 @@ describe('chatStore history mapping', () => { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], @@ -3861,4 +3887,57 @@ describe('chatStore history mapping', () => { attachments: undefined, }) }) + + // issue #757: the streaming indicator estimates this turn's output tokens + // from streamed characters (÷4, mirroring the CLI spinner) instead of + // showing the previous turn's stale usage. + it('accumulates streamed text, tool input, and thinking chars for the token estimate', () => { + vi.useFakeTimers() + useChatStore.setState({ sessions: { [TEST_SESSION_ID]: makeSession() } }) + + const charsOf = () => + useChatStore.getState().sessions[TEST_SESSION_ID]?.streamingResponseChars + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + text: 'a'.repeat(40), + }) + vi.advanceTimersByTime(60) + expect(charsOf()).toBe(40) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'content_delta', + toolInput: '{"a":1}', + }) + vi.advanceTimersByTime(60) + expect(charsOf()).toBe(47) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'thinking', + text: 'pondering', + }) + expect(charsOf()).toBe(56) + + vi.runOnlyPendingTimers() + vi.useRealTimers() + }) + + it('resets the streaming token estimate when the user sends the next message', () => { + vi.useFakeTimers() + useChatStore.setState({ + sessions: { + [TEST_SESSION_ID]: makeSession({ + chatState: 'idle', + streamingResponseChars: 4321, + }), + }, + }) + + useChatStore.getState().sendMessage(TEST_SESSION_ID, '继续') + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.streamingResponseChars).toBe(0) + if (session?.elapsedTimer) clearInterval(session.elapsedTimer) + vi.useRealTimers() + }) }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index fc1dd402..a9cdb4ea 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -79,6 +79,12 @@ export type PerSessionState = { request: ComputerUsePermissionRequest } | null tokenUsage: TokenUsage + /** + * Characters streamed by the assistant during the current turn (text, + * thinking, tool input). ÷4 approximates output tokens for the streaming + * indicator — same estimation the CLI spinner uses. Reset on each send. + */ + streamingResponseChars: number elapsedSeconds: number statusVerb: string apiRetry?: ApiRetryState | null @@ -111,6 +117,7 @@ const DEFAULT_SESSION_STATE: PerSessionState = { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', apiRetry: null, @@ -966,6 +973,7 @@ export const useChatStore = create((set, get) => ({ chatState: 'thinking', elapsedSeconds: 0, streamingText: '', + streamingResponseChars: 0, statusVerb: isMemberSession ? '' : randomSpinnerVerb(), apiRetry: null, elapsedTimer: timer, @@ -1327,7 +1335,6 @@ export const useChatStore = create((set, get) => ({ : msg.verb && msg.verb !== 'Thinking' ? msg.verb : '', - ...(msg.tokens ? { tokenUsage: { ...session.tokenUsage, output_tokens: msg.tokens } } : {}), ...(msg.state === 'idle' ? { activeThinkingId: null } : {}), ...(msg.state === 'idle' ? { apiRetry: null } : {}), ...(nextMessages !== session.messages ? { messages: nextMessages } : {}), @@ -1439,7 +1446,10 @@ export const useChatStore = create((set, get) => ({ const text = pendingDeltaBySession.get(sessionId) ?? '' pendingDeltaBySession.delete(sessionId) flushTimerBySession.delete(sessionId) - update((s) => ({ streamingText: s.streamingText + text })) + update((s) => ({ + streamingText: s.streamingText + text, + streamingResponseChars: s.streamingResponseChars + text.length, + })) }, 50) flushTimerBySession.set(sessionId, timer) } @@ -1455,6 +1465,7 @@ export const useChatStore = create((set, get) => ({ const activeToolUseId = s.activeToolUseId return { streamingToolInput: partialInput, + streamingResponseChars: s.streamingResponseChars + text.length, ...(activeToolUseId ? { messages: upsertToolUseMessage(s.messages, activeToolUseId, (existing) => { @@ -1491,7 +1502,13 @@ export const useChatStore = create((set, get) => ({ if (last && last.type === 'thinking') { const updated = [...base] updated[updated.length - 1] = { ...last, content: last.content + msg.text } - return { messages: updated, chatState: 'thinking', activeThinkingId: last.id, streamingText: '' } + return { + messages: updated, + chatState: 'thinking', + activeThinkingId: last.id, + streamingText: '', + streamingResponseChars: s.streamingResponseChars + msg.text.length, + } } const id = nextId() return { @@ -1499,6 +1516,7 @@ export const useChatStore = create((set, get) => ({ chatState: 'thinking', activeThinkingId: id, streamingText: '', + streamingResponseChars: s.streamingResponseChars + msg.text.length, } }) break @@ -1767,6 +1785,7 @@ export const useChatStore = create((set, get) => ({ statusVerb: '', apiRetry: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, slashCommands: [], activeGoal: null, backgroundAgentTasks: {}, diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index ce6d7e71..aad9cfbf 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -29,6 +29,7 @@ function createMemberSessionState() { pendingPermission: null, pendingComputerUsePermission: null, tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, elapsedSeconds: 0, statusVerb: '', slashCommands: [], diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 8ee3a3f2..80609f40 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -94,7 +94,7 @@ export type ServerMessage = } | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } - | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } + | { type: 'status'; state: ChatState; verb?: string } // CLI 回传的权限模式变化(如 ExitPlanMode 退出 plan 后恢复、Shift+Tab)。 // 桌面端据此把选择器校正回 CLI 的真实权限,避免本地影子值漂移。 | { type: 'permission_mode_changed'; mode: PermissionMode } diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index 4e77bf90..74bff9e2 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -5,7 +5,7 @@ import { useMemo, useRef } from 'react'; import { stringWidth } from '../../ink/stringWidth.js'; import { Box, Text, useAnimationFrame } from '../../ink.js'; import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; -import { formatDuration, formatNumber } from '../../utils/format.js'; +import { formatDuration, formatTokens } from '../../utils/format.js'; import { toInkColor } from '../../utils/ink.js'; import type { Theme } from '../../utils/theme.js'; import { Byline } from '../design-system/Byline.js'; @@ -164,7 +164,7 @@ export function SpinnerAnimationRow({ // === Token count (leader + teammates, or foregrounded teammate) === const totalTokens = foregroundedTeammate && !foregroundedTeammate.isIdle ? foregroundedTeammate.progress?.tokenCount ?? 0 : leaderTokens + teammateTokens; - const tokenCount = formatNumber(totalTokens); + const tokenCount = formatTokens(totalTokens); const tokensText = hasRunningTeammates ? `${tokenCount} tokens` : `${figures.arrowDown} ${tokenCount} tokens`; const tokensWidth = stringWidth(tokensText); diff --git a/src/components/Spinner/TeammateSpinnerLine.tsx b/src/components/Spinner/TeammateSpinnerLine.tsx index 638667be..e94b83d2 100644 --- a/src/components/Spinner/TeammateSpinnerLine.tsx +++ b/src/components/Spinner/TeammateSpinnerLine.tsx @@ -10,7 +10,7 @@ import { stringWidth } from '../../ink/stringWidth.js'; import { Box, Text } from '../../ink.js'; import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; import { summarizeRecentActivities } from '../../utils/collapseReadSearch.js'; -import { formatDuration, formatNumber, truncateToWidth } from '../../utils/format.js'; +import { formatDuration, formatTokens, truncateToWidth } from '../../utils/format.js'; import { toInkColor } from '../../utils/ink.js'; import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js'; type Props = { @@ -127,7 +127,7 @@ export function TeammateSpinnerLine({ // Get stats from progress const toolUseCount = teammate.progress?.toolUseCount ?? 0; const tokenCount = teammate.progress?.tokenCount ?? 0; - const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatNumber(tokenCount)} tokens`; + const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatTokens(tokenCount)} tokens`; const statsWidth = stringWidth(statsText); const selectHintText = ` · ${TEAMMATE_SELECT_HINT}`; const selectHintWidth = stringWidth(selectHintText); @@ -216,7 +216,7 @@ export function TeammateSpinnerLine({ {showStats && {' '} · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} ·{' '} - {formatNumber(tokenCount)} tokens + {formatTokens(tokenCount)} tokens } {/* Hints: select hint when highlighted, view hint when selected but not foregrounded */} {showSelectHint && · {TEAMMATE_SELECT_HINT}} diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index e27dfe63..7f0e88d7 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -64,7 +64,7 @@ export type ServerMessage = } | { type: 'message_complete'; usage: TokenUsage } | { type: 'thinking'; text: string } - | { type: 'status'; state: ChatState; verb?: string; elapsed?: number; tokens?: number } + | { type: 'status'; state: ChatState; verb?: string } // CLI 是权限模式的唯一真相来源。当 CLI 内部 mode 变化(如 ExitPlanMode 后 // 恢复到进入 plan 前的模式、Shift+Tab 切换)时,把新模式回传给前端,让桌面端 // 选择器与 CLI 保持同步,而不是停留在本地影子值上。 diff --git a/tests/formatTokens.test.ts b/tests/formatTokens.test.ts new file mode 100644 index 00000000..b4783991 --- /dev/null +++ b/tests/formatTokens.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { formatNumber, formatTokens } from "../src/utils/format.js" + +// The spinner rows render token counts via formatTokens (issue #757): same +// compact notation as formatNumber, but without the ".0" artifact ("1k", not +// "1.0k"). +describe("formatTokens", () => { + test("keeps counts below 1000 verbatim", () => { + expect(formatTokens(0)).toBe("0") + expect(formatTokens(999)).toBe("999") + }) + + test("compacts thousands with one decimal", () => { + expect(formatTokens(1234)).toBe("1.2k") + expect(formatTokens(38_500)).toBe("38.5k") + }) + + test("drops the trailing .0 that formatNumber keeps", () => { + expect(formatNumber(1000)).toBe("1.0k") + expect(formatTokens(1000)).toBe("1k") + expect(formatTokens(2_000_000)).toBe("2m") + }) +})