mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
fix: unify token usage display across desktop and CLI (#757)
Token counts were rendered with five inconsistent formats across the chat UI (header "181,117 t", in-progress bare "↓ 2514", background agents "12,345 tokens", compact summary "1.5k", trace "1.2k"), and the in-progress count was actually stale: the server never set the status event's tokens field, so the indicator showed the previous turn's value (hence "first message shows nothing", "sometimes appears, never moves"). - Add shared desktop lib/formatTokenCount; route header, streaming indicator, background agents, compact summary, and trace through it. - Header shows compact "124.3k tokens" with the exact count on hover. - Add common.tokens i18n key (en/zh/zh-TW/jp/kr). - Estimate the in-progress count from streamed chars (÷4, mirroring the CLI spinner) via a new streamingResponseChars per-session field, reset on each send; drop the dead status.tokens/elapsed fields. - CLI spinner rows use formatTokens to drop the "1.0k" artifact.
This commit is contained in:
parent
9238481e86
commit
a94e5b641a
@ -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: [],
|
||||
|
||||
@ -63,6 +63,7 @@ describe('AskUserQuestion', () => {
|
||||
},
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -36,6 +36,7 @@ function makeSessionState(overrides: Partial<PerSessionState> = {}): 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')
|
||||
})
|
||||
|
||||
|
||||
@ -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<typeof useTranslation>) {
|
||||
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 })
|
||||
</span>
|
||||
{task.usage?.totalTokens ? (
|
||||
<span className="hidden shrink-0 text-[11px] text-[var(--color-text-tertiary)] sm:inline">
|
||||
{t('chat.backgroundAgents.tokens', { count: task.usage.totalTokens.toLocaleString() })}
|
||||
{t('chat.backgroundAgents.tokens', { count: formatTokenCount(task.usage.totalTokens) })}
|
||||
</span>
|
||||
) : null}
|
||||
{duration ? (
|
||||
|
||||
@ -57,6 +57,7 @@ function seedPendingPlanPermission() {
|
||||
},
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
|
||||
94
desktop/src/components/chat/StreamingIndicator.test.tsx
Normal file
94
desktop/src/components/chat/StreamingIndicator.test.tsx
Normal file
@ -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> = {}): 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(<StreamingIndicator />)
|
||||
|
||||
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(<StreamingIndicator />)
|
||||
|
||||
expect(screen.queryByText(/tokens/)).toBeNull()
|
||||
})
|
||||
})
|
||||
@ -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)}
|
||||
</span>
|
||||
)}
|
||||
{tokenUsage.output_tokens > 0 && (
|
||||
{streamingTokens > 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
· ↓ {tokenUsage.output_tokens}
|
||||
· ↓ {t('common.tokens', { count: formatTokenCount(streamingTokens) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -281,6 +281,7 @@ describe('chat blocks', () => {
|
||||
},
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -1369,6 +1369,7 @@ describe('WorkspacePanel', () => {
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
|
||||
@ -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...',
|
||||
|
||||
@ -9,6 +9,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'common.run': '実行',
|
||||
'common.send': '送信',
|
||||
'common.stop': '停止',
|
||||
'common.tokens': '{count} トークン',
|
||||
'common.rename': '名前を変更',
|
||||
'common.retry': '再試行',
|
||||
'common.loading': '読み込み中...',
|
||||
|
||||
@ -9,6 +9,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'common.run': '실행',
|
||||
'common.send': '전송',
|
||||
'common.stop': '중지',
|
||||
'common.tokens': '{count} 토큰',
|
||||
'common.rename': '이름 바꾸기',
|
||||
'common.retry': '다시 시도',
|
||||
'common.loading': '불러오는 중...',
|
||||
|
||||
@ -9,6 +9,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'common.run': '執行',
|
||||
'common.send': '傳送',
|
||||
'common.stop': '停止',
|
||||
'common.tokens': '{count} tokens',
|
||||
'common.rename': '重新命名',
|
||||
'common.retry': '重試',
|
||||
'common.loading': '載入中...',
|
||||
|
||||
@ -9,6 +9,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'common.run': '运行',
|
||||
'common.send': '发送',
|
||||
'common.stop': '停止',
|
||||
'common.tokens': '{count} tokens',
|
||||
'common.rename': '重命名',
|
||||
'common.retry': '重试',
|
||||
'common.loading': '加载中...',
|
||||
|
||||
30
desktop/src/lib/formatTokenCount.test.ts
Normal file
30
desktop/src/lib/formatTokenCount.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
13
desktop/src/lib/formatTokenCount.ts
Normal file
13
desktop/src/lib/formatTokenCount.ts
Normal file
@ -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`
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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)}`
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{totalTokens.toLocaleString()} t</span>
|
||||
<span title={t('common.tokens', { count: totalTokens.toLocaleString() })}>
|
||||
{t('common.tokens', { count: formatTokenCount(totalTokens) })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
|
||||
@ -151,6 +151,7 @@ function makeSession(overrides: Partial<PerSessionState> = {}): 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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<ChatStore>((set, get) => ({
|
||||
chatState: 'thinking',
|
||||
elapsedSeconds: 0,
|
||||
streamingText: '',
|
||||
streamingResponseChars: 0,
|
||||
statusVerb: isMemberSession ? '' : randomSpinnerVerb(),
|
||||
apiRetry: null,
|
||||
elapsedTimer: timer,
|
||||
@ -1327,7 +1335,6 @@ export const useChatStore = create<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((set, get) => ({
|
||||
chatState: 'thinking',
|
||||
activeThinkingId: id,
|
||||
streamingText: '',
|
||||
streamingResponseChars: s.streamingResponseChars + msg.text.length,
|
||||
}
|
||||
})
|
||||
break
|
||||
@ -1767,6 +1785,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
|
||||
statusVerb: '',
|
||||
apiRetry: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
slashCommands: [],
|
||||
activeGoal: null,
|
||||
backgroundAgentTasks: {},
|
||||
|
||||
@ -29,6 +29,7 @@ function createMemberSessionState() {
|
||||
pendingPermission: null,
|
||||
pendingComputerUsePermission: null,
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
streamingResponseChars: 0,
|
||||
elapsedSeconds: 0,
|
||||
statusVerb: '',
|
||||
slashCommands: [],
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 && <Text dimColor>
|
||||
{' '}
|
||||
· {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} ·{' '}
|
||||
{formatNumber(tokenCount)} tokens
|
||||
{formatTokens(tokenCount)} tokens
|
||||
</Text>}
|
||||
{/* Hints: select hint when highlighted, view hint when selected but not foregrounded */}
|
||||
{showSelectHint && <Text dimColor> · {TEAMMATE_SELECT_HINT}</Text>}
|
||||
|
||||
@ -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 保持同步,而不是停留在本地影子值上。
|
||||
|
||||
23
tests/formatTokens.test.ts
Normal file
23
tests/formatTokens.test.ts
Normal file
@ -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")
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user