From 5adc4feaa4f1f33f00bdf0cee682fd8ba973317c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B4=B2=E5=86=A0=E7=84=B6?= <117555359+BenGuanRan@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:59:08 +0800 Subject: [PATCH] feat(stats): add interactive /stats dashboard with cross-session tracking (#4779) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(stats): add dashboard design spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) * feat(stats): add cross-session usage tracking service Add usageHistoryService to core with JSONL-based persistence, session replay from chat history with sessionId deduplication, time-range aggregation, and per-model/tool/file breakdown including latency fields. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(stats): add stats data service and ASCII chart utilities Add statsDataService for delta calculations, efficiency metrics, tool leaderboard, and heatmap/trend data. Add asciiCharts with braille line chart (Bresenham rendering) and GitHub-style contribution heatmap. Includes 38 unit tests covering both modules. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(stats): implement interactive /stats dashboard Add three-tab dialog: Session (live metrics), Activity (KPIs, heatmap, braille token trend chart, project ranking), and Efficiency (cache rate, tool success, latency cards, tool leaderboard, model comparison table). Supports tab/shift-tab navigation, r to cycle time ranges (all/month/ week/today), left/right to pan months in the trend chart, esc to close. Persist usage on /clear for accurate cross-session tracking. Update statsCommand tests for new dialog behavior and clearCommand tests for telemetry mock. Update /stats documentation in commands.md. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(i18n): add stats dashboard translations for all locales Add translations for stats dashboard UI strings in zh, zh-TW, ca, de, fr, ja, pt, ru. Add stats keys to en.js baseline and mustTranslateKeys. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(stats): use terminal default background for inactive heatmap cells Intensity 0 cells (no activity) now render without backgroundColor, inheriting the terminal's native background instead of a hardcoded color that renders incorrectly across different terminal themes. Also fix green gradient direction: brighter = more activity. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(stats): use dot markers for inactive heatmap cells Inactive cells render as '··' with no background color instead of colored blocks, matching common contribution graph designs. Active cells keep their green gradient backgrounds. Fix gradient direction so brighter green = more activity. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(stats): restore full session stats from original StatsDisplay Add back Session ID, Success Rate with color thresholds, User Agreement rate, Performance breakdown (Wall Time, Agent Active, API Time %, Tool Time %), and full token counts that were present in the original exit screen but missing from the new Session tab. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(stats): address PR review — timezone bugs, double-count, cleanup - Fix monthOffset overflow: setDate(1) before subtracting months to prevent day-count overflow (e.g. Mar 31 → Feb) - Fix UTC date-parse off-by-one: append 'T00:00:00' to date-only strings in calculateStreaks and HeatmapView fmtDate - Fix current session double-counted after rebuild: deduplicate by sessionId when injecting live session into loadStatsData - Remove unused bodyWidth prop from SessionTab - Remove 13 unused i18n keys (Overview, Favorite model, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(stats): add NaN guard, catch unhandled promise, fix useEffect race - Guard against malformed chat records with NaN timestamps in rebuild - Add .catch() to loadStatsData promise to prevent TUI crash - Add stale flag to useEffect to prevent race on rapid range cycling Co-Authored-By: Claude Opus 4.6 (1M context) * fix(i18n): sync locale files with en.js baseline for CI check Add 19 missing translations to zh-TW.js, remove extra keys from zh.js and zh-TW.js that were deleted from en.js in prior cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(stats): use fake timers in getPreviousRangeBounds tests The test compared new Date() in the assertion against new Date() inside the function, which could differ by 1ms across a millisecond boundary. Pin system time to prevent flaky CI failures. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(i18n): restore Session and Success keys removed in error These keys are still referenced by t('Session') in stats-helpers.tsx and t('Success') in StatsEfficiencyTab.tsx. Add to en.js baseline and restore zh/zh-TW translations. * fix(stats): address R3 review — malformed record guard, arrow fix, error state - Skip malformed records in aggregateUsage (missing tools/files/models) - Use Object.create(null) to prevent prototype pollution on model names - Fix Avg Latency delta arrow direction (▼ for decrease, ▲ for increase) - Clamp fmtSuccessBar to prevent RangeError on corrupt data - Add error state UI when loadStatsData fails * fix(stats): address R4 review — DST fix, token consistency, tests, cleanup - Fix DST bug in getPreviousRangeBounds('today') using setDate - Unify token counting: project ranking uses totalTokens (same as KPI) - Add clearCommand tests for persistSessionUsage with/without activity - Remove dead code (unreachable sorted.length check) - Fix heatmap legend to use dot markers matching grid cells - Add 'Failed to load stats' i18n key to en/zh/zh-TW * fix(stats): include thoughtsTokens in totalTokens fallback calculation * Revert "feat(input): move physical cursor to visual cursor for IME input (#4652)" This reverts commit 77458ad21f47a21caafe74ce6ae82b24c41eace0. * fix(stats): prevent Yoga layout from compressing StatsDialog content Add flexShrink={0} to the outer Box of StatsDialog so that Yoga's default flex-shrink behavior does not compress KPI cards, charts, and tables when the dialog exceeds the available terminal height. The parent container in DefaultAppLayout already applies height + overflow="hidden" to clip overflow — this fix ensures content retains its natural size instead of being squeezed. Co-Authored-By: Claude Opus 4.6 * fix(stats): add debug logging to catch blocks and strengthen test assertion - Add debugLogger to all catch blocks in usageHistoryService.ts for observability when file I/O or parsing fails - Strengthen test assertion from toContain('Session duration') to toContain('Session duration: 0s') to verify the zero-duration fallback --------- Co-authored-by: a.ran Co-authored-by: Claude Opus 4.6 (1M context) --- .../2025-06-03-stats-dashboard-redesign.md | 1337 +++++++++++++++++ .../2025-06-03-stats-dashboard-redesign.md | 270 ++++ docs/users/features/commands.md | 24 +- package-lock.json | 195 --- package.json | 2 - packages/cli/package.json | 2 +- packages/cli/src/gemini.tsx | 25 + packages/cli/src/i18n/locales/ca.js | 51 + packages/cli/src/i18n/locales/de.js | 51 + packages/cli/src/i18n/locales/en.js | 85 +- packages/cli/src/i18n/locales/fr.js | 55 +- packages/cli/src/i18n/locales/ja.js | 54 +- packages/cli/src/i18n/locales/pt.js | 54 +- packages/cli/src/i18n/locales/ru.js | 55 +- packages/cli/src/i18n/locales/zh-TW.js | 96 +- packages/cli/src/i18n/locales/zh.js | 100 +- packages/cli/src/i18n/mustTranslateKeys.ts | 12 + packages/cli/src/ui/AppContainer.tsx | 12 + .../cli/src/ui/commands/clearCommand.test.ts | 31 + packages/cli/src/ui/commands/clearCommand.ts | 20 + packages/cli/src/ui/commands/forkCommand.ts | 9 +- .../cli/src/ui/commands/statsCommand.test.ts | 26 +- packages/cli/src/ui/commands/statsCommand.ts | 85 +- packages/cli/src/ui/commands/types.ts | 3 +- .../cli/src/ui/components/BaseTextInput.tsx | 109 +- .../cli/src/ui/components/DialogManager.tsx | 6 + .../cli/src/ui/components/InputPrompt.tsx | 10 - .../src/ui/components/StatsActivityTab.tsx | 275 ++++ .../cli/src/ui/components/StatsDialog.tsx | 238 +++ .../src/ui/components/StatsEfficiencyTab.tsx | 258 ++++ .../src/ui/components/StatsHeatmapView.tsx | 107 ++ .../cli/src/ui/components/StatsSessionTab.tsx | 215 +++ .../components/agent-view/AgentComposer.tsx | 2 - .../cli/src/ui/components/stats-helpers.tsx | 103 ++ .../cli/src/ui/contexts/UIActionsContext.tsx | 1 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + packages/cli/src/ui/hooks/useDialogClose.ts | 8 + packages/cli/src/ui/hooks/useStatsDialog.ts | 20 + packages/cli/src/ui/utils/asciiCharts.test.ts | 238 +++ packages/cli/src/ui/utils/asciiCharts.ts | 386 +++++ .../cli/src/ui/utils/statsDataService.test.ts | 488 ++++++ packages/cli/src/ui/utils/statsDataService.ts | 322 ++++ packages/core/src/index.ts | 1 + .../src/services/usageHistoryService.test.ts | 351 +++++ .../core/src/services/usageHistoryService.ts | 428 ++++++ packages/core/src/telemetry/uiTelemetry.ts | 6 + patches/ink+7.0.3.patch | 16 - 48 files changed, 5813 insertions(+), 434 deletions(-) create mode 100644 .qwen/plans/2025-06-03-stats-dashboard-redesign.md create mode 100644 .qwen/specs/2025-06-03-stats-dashboard-redesign.md create mode 100644 packages/cli/src/ui/components/StatsActivityTab.tsx create mode 100644 packages/cli/src/ui/components/StatsDialog.tsx create mode 100644 packages/cli/src/ui/components/StatsEfficiencyTab.tsx create mode 100644 packages/cli/src/ui/components/StatsHeatmapView.tsx create mode 100644 packages/cli/src/ui/components/StatsSessionTab.tsx create mode 100644 packages/cli/src/ui/components/stats-helpers.tsx create mode 100644 packages/cli/src/ui/hooks/useStatsDialog.ts create mode 100644 packages/cli/src/ui/utils/asciiCharts.test.ts create mode 100644 packages/cli/src/ui/utils/asciiCharts.ts create mode 100644 packages/cli/src/ui/utils/statsDataService.test.ts create mode 100644 packages/cli/src/ui/utils/statsDataService.ts create mode 100644 packages/core/src/services/usageHistoryService.test.ts create mode 100644 packages/core/src/services/usageHistoryService.ts delete mode 100644 patches/ink+7.0.3.patch diff --git a/.qwen/plans/2025-06-03-stats-dashboard-redesign.md b/.qwen/plans/2025-06-03-stats-dashboard-redesign.md new file mode 100644 index 0000000000..ca20961816 --- /dev/null +++ b/.qwen/plans/2025-06-03-stats-dashboard-redesign.md @@ -0,0 +1,1337 @@ +# Stats Dashboard Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Overview and Models tabs in the `/stats` TUI with an Activity tab (time-based trends) and an Efficiency tab (performance metrics and tool analysis). + +**Architecture:** Extend the data layer (`usageHistoryService`, `statsDataService`) with delta calculation, tool duration, and latency fields. Replace the two UI tab components in `StatsDialog.tsx`. Change the heatmap from session-count to token-based with today highlight. + +**Tech Stack:** TypeScript, Ink/React, Vitest, braille ASCII charts + +--- + +### Task 1: Extend UsageSummaryRecord with latency and tool duration + +**Files:** +- Modify: `packages/core/src/services/usageHistoryService.ts:16-44` +- Modify: `packages/core/src/services/usageHistoryService.ts:111-158` (metricsToUsageRecord) +- Test: `packages/core/src/services/usageHistoryService.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `packages/core/src/services/usageHistoryService.test.ts`: + +```typescript +import { describe, it, expect } from 'vitest'; +import { metricsToUsageRecord } from './usageHistoryService.js'; +import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +import { ToolCallDecision } from '../telemetry/tool-call-decision.js'; + +function makeMetrics(): SessionMetrics { + return { + models: { + 'qwen-max': { + api: { totalRequests: 5, totalErrors: 0, totalLatencyMs: 9500 }, + tokens: { prompt: 1000, candidates: 500, total: 1500, cached: 800, thoughts: 0 }, + bySource: {}, + }, + }, + tools: { + totalCalls: 10, + totalSuccess: 9, + totalFail: 1, + totalDurationMs: 5000, + totalDecisions: { + [ToolCallDecision.ACCEPT]: 5, + [ToolCallDecision.REJECT]: 1, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 4, + }, + byName: { + edit: { count: 6, success: 6, fail: 0, durationMs: 3000, decisions: { [ToolCallDecision.ACCEPT]: 3, [ToolCallDecision.REJECT]: 0, [ToolCallDecision.MODIFY]: 0, [ToolCallDecision.AUTO_ACCEPT]: 3 } }, + bash: { count: 4, success: 3, fail: 1, durationMs: 2000, decisions: { [ToolCallDecision.ACCEPT]: 2, [ToolCallDecision.REJECT]: 1, [ToolCallDecision.MODIFY]: 0, [ToolCallDecision.AUTO_ACCEPT]: 1 } }, + }, + }, + files: { totalLinesAdded: 50, totalLinesRemoved: 10 }, + }; +} + +describe('metricsToUsageRecord', () => { + it('includes totalLatencyMs from all models', () => { + const record = metricsToUsageRecord('s1', '/proj', 1000, 2000, makeMetrics()); + expect(record.totalLatencyMs).toBe(9500); + }); + + it('includes per-tool totalDurationMs in byName', () => { + const record = metricsToUsageRecord('s1', '/proj', 1000, 2000, makeMetrics()); + expect(record.tools.byName['edit']!.totalDurationMs).toBe(3000); + expect(record.tools.byName['bash']!.totalDurationMs).toBe(2000); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/core && npx vitest run src/services/usageHistoryService.test.ts` +Expected: FAIL — `totalLatencyMs` is undefined, `totalDurationMs` missing from byName entries. + +- [ ] **Step 3: Extend the interface and implementation** + +In `packages/core/src/services/usageHistoryService.ts`, update `UsageSummaryRecord`: + +```typescript +export interface UsageSummaryRecord { + version: 1; + sessionId: string; + timestamp: number; + startTime: number; + project: string; + durationMs: number; + totalLatencyMs?: number; + models: Record< + string, + { + requests: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + thoughtsTokens: number; + totalTokens: number; + } + >; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + byName: Record; + }; + files: { + linesAdded: number; + linesRemoved: number; + }; +} +``` + +Update `metricsToUsageRecord` to populate the new fields: + +```typescript +export function metricsToUsageRecord( + sessionId: string, + project: string, + startTime: number, + endTime: number, + metrics: SessionMetrics, +): UsageSummaryRecord { + const models: UsageSummaryRecord['models'] = {}; + let totalLatencyMs = 0; + for (const [name, m] of Object.entries(metrics.models)) { + totalLatencyMs += m.api.totalLatencyMs; + models[name] = { + requests: m.api.totalRequests, + inputTokens: m.tokens.prompt, + outputTokens: m.tokens.candidates, + cachedTokens: m.tokens.cached, + thoughtsTokens: m.tokens.thoughts, + totalTokens: + m.tokens.total || + m.tokens.prompt + m.tokens.candidates + m.tokens.thoughts, + }; + } + const toolsByName: UsageSummaryRecord['tools']['byName'] = {}; + for (const [name, stats] of Object.entries(metrics.tools.byName)) { + toolsByName[name] = { + count: stats.count, + success: stats.success, + fail: stats.fail, + totalDurationMs: stats.durationMs, + }; + } + return { + version: 1, + sessionId, + timestamp: endTime, + startTime, + project, + durationMs: endTime - startTime, + totalLatencyMs, + models, + tools: { + totalCalls: metrics.tools.totalCalls, + totalSuccess: metrics.tools.totalSuccess, + totalFail: metrics.tools.totalFail, + byName: toolsByName, + }, + files: { + linesAdded: metrics.files.totalLinesAdded, + linesRemoved: metrics.files.totalLinesRemoved, + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/core && npx vitest run src/services/usageHistoryService.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/services/usageHistoryService.ts packages/core/src/services/usageHistoryService.test.ts +git commit -m "feat(stats): extend UsageSummaryRecord with latency and tool duration" +``` + +--- + +### Task 2: Add delta calculation and aggregation extensions + +**Files:** +- Modify: `packages/core/src/services/usageHistoryService.ts:283-394` (aggregateUsage) +- Test: `packages/core/src/services/usageHistoryService.test.ts` (extend) + +- [ ] **Step 1: Write the failing tests** + +Add to `packages/core/src/services/usageHistoryService.test.ts`: + +```typescript +import { aggregateUsage, type UsageSummaryRecord, type TimeRange } from './usageHistoryService.js'; + +function makeRecord(overrides: Partial = {}): UsageSummaryRecord { + return { + version: 1, + sessionId: 's1', + timestamp: Date.now(), + startTime: Date.now() - 60000, + project: '/proj', + durationMs: 60000, + totalLatencyMs: 2000, + models: { + 'qwen-max': { + requests: 3, + inputTokens: 1000, + outputTokens: 500, + cachedTokens: 800, + thoughtsTokens: 0, + totalTokens: 1500, + }, + }, + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + byName: { + edit: { count: 3, success: 3, fail: 0, totalDurationMs: 1500 }, + bash: { count: 2, success: 1, fail: 1, totalDurationMs: 3000 }, + }, + }, + files: { linesAdded: 20, linesRemoved: 5 }, + ...overrides, + }; +} + +describe('aggregateUsage', () => { + it('includes totalLatencyMs in aggregated result', () => { + const records = [makeRecord({ totalLatencyMs: 2000 }), makeRecord({ totalLatencyMs: 3000 })]; + const report = aggregateUsage(records, 'all'); + expect(report.totalLatencyMs).toBe(5000); + }); + + it('includes totalDurationMs per tool in topTools', () => { + const records = [makeRecord()]; + const report = aggregateUsage(records, 'all'); + const editTool = report.tools.topTools.find((t) => t.name === 'edit'); + expect(editTool!.totalDurationMs).toBe(1500); + }); + + it('computes totalRequests in aggregated result', () => { + const records = [makeRecord(), makeRecord()]; + const report = aggregateUsage(records, 'all'); + expect(report.totalRequests).toBe(6); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd packages/core && npx vitest run src/services/usageHistoryService.test.ts` +Expected: FAIL — `totalLatencyMs`, `totalDurationMs` on topTools, and `totalRequests` don't exist on the report. + +- [ ] **Step 3: Extend AggregatedReport and aggregateUsage** + +Update `AggregatedReport` interface: + +```typescript +export interface AggregatedReport { + timeRange: TimeRange; + periodStart: Date; + periodEnd: Date; + sessionCount: number; + totalDurationMs: number; + totalLatencyMs: number; + totalRequests: number; + models: Record< + string, + { + requests: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + thoughtsTokens: number; + totalTokens: number; + } + >; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + topTools: Array<{ + name: string; + count: number; + success: number; + fail: number; + totalDurationMs: number; + }>; + }; + files: { + linesAdded: number; + linesRemoved: number; + }; + projects: Array<{ + path: string; + sessionCount: number; + totalDurationMs: number; + totalInputTokens: number; + totalOutputTokens: number; + }>; +} +``` + +Update `aggregateUsage` function body — add accumulators: + +```typescript +export function aggregateUsage( + records: UsageSummaryRecord[], + range: TimeRange, +): AggregatedReport { + const { start, end } = getTimeRangeBounds(range); + const filtered = records.filter((r) => { + const ts = r.timestamp; + return ts >= start.getTime() && ts <= end.getTime(); + }); + + const models: AggregatedReport['models'] = {}; + let totalCalls = 0; + let totalSuccess = 0; + let totalFail = 0; + let totalDurationMs = 0; + let totalLatencyMs = 0; + let totalRequests = 0; + let linesAdded = 0; + let linesRemoved = 0; + const toolCounts = new Map< + string, + { count: number; success: number; fail: number; totalDurationMs: number } + >(); + const projectMap = new Map< + string, + { + sessionCount: number; + totalDurationMs: number; + totalInputTokens: number; + totalOutputTokens: number; + } + >(); + + for (const r of filtered) { + totalDurationMs += r.durationMs; + totalLatencyMs += r.totalLatencyMs ?? 0; + totalCalls += r.tools.totalCalls; + totalSuccess += r.tools.totalSuccess; + totalFail += r.tools.totalFail; + linesAdded += r.files.linesAdded; + linesRemoved += r.files.linesRemoved; + + for (const [name, m] of Object.entries(r.models)) { + totalRequests += m.requests; + const existing = models[name]; + if (existing) { + existing.requests += m.requests; + existing.inputTokens += m.inputTokens; + existing.outputTokens += m.outputTokens; + existing.cachedTokens += m.cachedTokens; + existing.thoughtsTokens += m.thoughtsTokens; + existing.totalTokens += m.totalTokens; + } else { + models[name] = { ...m }; + } + } + + for (const [name, stats] of Object.entries(r.tools.byName)) { + const existing = toolCounts.get(name); + if (existing) { + existing.count += stats.count; + existing.success += stats.success; + existing.fail += stats.fail; + existing.totalDurationMs += stats.totalDurationMs ?? 0; + } else { + toolCounts.set(name, { + count: stats.count, + success: stats.success, + fail: stats.fail, + totalDurationMs: stats.totalDurationMs ?? 0, + }); + } + } + + let sessionInput = 0; + let sessionOutput = 0; + for (const m of Object.values(r.models)) { + sessionInput += m.inputTokens; + sessionOutput += m.outputTokens; + } + const proj = projectMap.get(r.project); + if (proj) { + proj.sessionCount++; + proj.totalDurationMs += r.durationMs; + proj.totalInputTokens += sessionInput; + proj.totalOutputTokens += sessionOutput; + } else { + projectMap.set(r.project, { + sessionCount: 1, + totalDurationMs: r.durationMs, + totalInputTokens: sessionInput, + totalOutputTokens: sessionOutput, + }); + } + } + + const topTools = [...toolCounts.entries()] + .map(([name, stats]) => ({ name, ...stats })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const projects = [...projectMap.entries()] + .map(([p, stats]) => ({ path: p, ...stats })) + .sort( + (a, b) => + b.totalInputTokens + + b.totalOutputTokens - + (a.totalInputTokens + a.totalOutputTokens), + ); + + return { + timeRange: range, + periodStart: start, + periodEnd: end, + sessionCount: filtered.length, + totalDurationMs, + totalLatencyMs, + totalRequests, + models, + tools: { totalCalls, totalSuccess, totalFail, topTools }, + files: { linesAdded, linesRemoved }, + projects, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/core && npx vitest run src/services/usageHistoryService.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/services/usageHistoryService.ts packages/core/src/services/usageHistoryService.test.ts +git commit -m "feat(stats): add latency/duration/requests to aggregated report" +``` + +--- + +### Task 3: Add delta calculation to statsDataService + +**Files:** +- Modify: `packages/cli/src/ui/utils/statsDataService.ts` +- Test: `packages/cli/src/ui/utils/statsDataService.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +Create `packages/cli/src/ui/utils/statsDataService.test.ts`: + +```typescript +import { describe, it, expect, vi } from 'vitest'; +import type { UsageSummaryRecord } from '@qwen-code/qwen-code-core'; + +// Mock loadUsageHistory to return controlled data +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + loadUsageHistory: vi.fn(), + }; +}); + +import { loadUsageHistory } from '@qwen-code/qwen-code-core'; +import { loadStatsData } from './statsDataService.js'; + +const mockedLoad = vi.mocked(loadUsageHistory); + +function makeRecord(ts: number, tokens: number): UsageSummaryRecord { + return { + version: 1, + sessionId: `s-${ts}`, + timestamp: ts, + startTime: ts - 60000, + project: '/proj', + durationMs: 60000, + totalLatencyMs: 2000, + models: { + 'qwen-max': { + requests: 2, + inputTokens: tokens, + outputTokens: tokens / 2, + cachedTokens: tokens * 0.8, + thoughtsTokens: 0, + totalTokens: tokens * 1.5, + }, + }, + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + byName: { edit: { count: 5, success: 4, fail: 1, totalDurationMs: 1000 } }, + }, + files: { linesAdded: 10, linesRemoved: 5 }, + }; +} + +describe('loadStatsData delta', () => { + it('computes delta for week range', async () => { + const now = Date.now(); + const inThisWeek = now - 2 * 24 * 60 * 60 * 1000; + const inPrevWeek = now - 10 * 24 * 60 * 60 * 1000; + mockedLoad.mockResolvedValue([ + makeRecord(inThisWeek, 1000), + makeRecord(inPrevWeek, 500), + ]); + const data = await loadStatsData('week'); + expect(data.delta).toBeDefined(); + expect(data.delta!.tokens).toBeGreaterThan(0); + }); + + it('returns no delta for all range', async () => { + mockedLoad.mockResolvedValue([makeRecord(Date.now(), 1000)]); + const data = await loadStatsData('all'); + expect(data.delta).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/cli && npx vitest run src/ui/utils/statsDataService.test.ts` +Expected: FAIL — `delta` property doesn't exist on StatsData. + +- [ ] **Step 3: Extend StatsData and implement delta calculation** + +Update `packages/cli/src/ui/utils/statsDataService.ts`: + +Add to `StatsData` interface: + +```typescript +export interface StatsData { + report: AggregatedReport; + heatmap: Record; + currentStreak: number; + longestStreak: number; + activeDays: number; + totalDays: number; + mostActiveDay: { date: string; count: number } | null; + longestSession: { durationMs: number; date: string } | null; + favoriteModel: string | null; + tokensPerDay: Array<{ date: string; model: string; tokens: number }>; + delta: { + sessions: number | null; + duration: number | null; + tokens: number | null; + cacheRate: number | null; + toolSuccess: number | null; + avgLatency: number | null; + } | null; + efficiency: { + cacheHitRate: number; + toolSuccessRate: number; + avgLatencyMs: number | null; + }; + toolLeaderboard: Array<{ + name: string; + count: number; + totalDurationMs: number; + successRate: number; + }>; +} +``` + +Add a helper function for delta: + +```typescript +function computeDelta( + current: AggregatedReport, + previous: AggregatedReport, +): StatsData['delta'] { + const pctChange = (cur: number, prev: number): number | null => { + if (prev === 0) return cur > 0 ? 100 : null; + return ((cur - prev) / prev) * 100; + }; + + let curTokens = 0, prevTokens = 0; + let curInput = 0, prevInput = 0; + let curCached = 0, prevCached = 0; + for (const m of Object.values(current.models)) { + curTokens += m.totalTokens; + curInput += m.inputTokens; + curCached += m.cachedTokens; + } + for (const m of Object.values(previous.models)) { + prevTokens += m.totalTokens; + prevInput += m.inputTokens; + prevCached += m.cachedTokens; + } + + const curCacheRate = curInput > 0 ? (curCached / curInput) * 100 : 0; + const prevCacheRate = prevInput > 0 ? (prevCached / prevInput) * 100 : 0; + const curToolSuccess = current.tools.totalCalls > 0 + ? (current.tools.totalSuccess / current.tools.totalCalls) * 100 : 0; + const prevToolSuccess = previous.tools.totalCalls > 0 + ? (previous.tools.totalSuccess / previous.tools.totalCalls) * 100 : 0; + const curLatency = current.totalRequests > 0 + ? current.totalLatencyMs / current.totalRequests : null; + const prevLatency = previous.totalRequests > 0 + ? previous.totalLatencyMs / previous.totalRequests : null; + + return { + sessions: pctChange(current.sessionCount, previous.sessionCount), + duration: pctChange(current.totalDurationMs, previous.totalDurationMs), + tokens: pctChange(curTokens, prevTokens), + cacheRate: curCacheRate - prevCacheRate, + toolSuccess: curToolSuccess - prevToolSuccess, + avgLatency: curLatency !== null && prevLatency !== null + ? curLatency - prevLatency : null, + }; +} +``` + +Add a helper to get previous range bounds: + +```typescript +function getPreviousRangeBounds(range: TimeRange): { start: Date; end: Date } | null { + if (range === 'all') return null; + const { start, end } = getTimeRangeBounds(range); + const durationMs = end.getTime() - start.getTime(); + return { + start: new Date(start.getTime() - durationMs), + end: new Date(start.getTime()), + }; +} +``` + +Update `loadStatsData` to compute delta, efficiency, and toolLeaderboard: + +```typescript +export async function loadStatsData( + range: TimeRange, + currentSession?: UsageSummaryRecord, +): Promise { + const persisted = await loadUsageHistory(); + const records = currentSession ? [...persisted, currentSession] : persisted; + const report = aggregateUsage(records, range); + const { start, end } = getTimeRangeBounds(range); + + // Delta + let delta: StatsData['delta'] = null; + const prevBounds = getPreviousRangeBounds(range); + if (prevBounds) { + const prevFiltered = records.filter( + (r) => r.timestamp >= prevBounds.start.getTime() && r.timestamp < prevBounds.end.getTime(), + ); + const prevReport = aggregateUsage(prevFiltered, 'all'); + delta = computeDelta(report, prevReport); + } + + // Efficiency + let totalInput = 0, totalCached = 0; + for (const m of Object.values(report.models)) { + totalInput += m.inputTokens; + totalCached += m.cachedTokens; + } + const efficiency: StatsData['efficiency'] = { + cacheHitRate: totalInput > 0 ? (totalCached / totalInput) * 100 : 0, + toolSuccessRate: report.tools.totalCalls > 0 + ? (report.tools.totalSuccess / report.tools.totalCalls) * 100 : 0, + avgLatencyMs: report.totalRequests > 0 + ? report.totalLatencyMs / report.totalRequests : null, + }; + + // Tool leaderboard + const toolLeaderboard = report.tools.topTools.slice(0, 8).map((t) => ({ + name: t.name, + count: t.count, + totalDurationMs: t.totalDurationMs, + successRate: t.count > 0 ? (t.success / t.count) * 100 : 0, + })); + + // ... rest of existing code (heatmap, streaks, etc.) ... + + const filtered = records.filter( + (r) => r.timestamp >= start.getTime() && r.timestamp <= end.getTime(), + ); + const heatmap = buildHeatmap(records, start, end); + const heatmapDates = Object.keys(heatmap); + const { currentStreak, longestStreak } = calculateStreaks(heatmapDates); + + const firstDate = heatmapDates.sort()[0]; + const activeDays = heatmapDates.length; + let totalDays = 0; + if (firstDate) { + totalDays = Math.max( + 1, + Math.ceil( + (end.getTime() - new Date(firstDate).getTime()) / (1000 * 60 * 60 * 24), + ) + 1, + ); + } + + let mostActiveDay: StatsData['mostActiveDay'] = null; + for (const [date, count] of Object.entries(heatmap)) { + if (!mostActiveDay || count > mostActiveDay.count) { + mostActiveDay = { date, count }; + } + } + + let longestSession: StatsData['longestSession'] = null; + for (const r of filtered) { + if (!longestSession || r.durationMs > longestSession.durationMs) { + longestSession = { + durationMs: r.durationMs, + date: new Date(r.timestamp).toISOString().split('T')[0]!, + }; + } + } + + let favoriteModel: string | null = null; + let maxTokens = 0; + for (const [name, m] of Object.entries(report.models)) { + if (m.totalTokens > maxTokens) { + maxTokens = m.totalTokens; + favoriteModel = name; + } + } + + const tokensPerDay = buildTokensPerDay(records, start, end); + + return { + report, + heatmap, + currentStreak, + longestStreak, + activeDays, + totalDays, + mostActiveDay, + longestSession, + favoriteModel, + tokensPerDay, + delta, + efficiency, + toolLeaderboard, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd packages/cli && npx vitest run src/ui/utils/statsDataService.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/ui/utils/statsDataService.ts packages/cli/src/ui/utils/statsDataService.test.ts +git commit -m "feat(stats): add delta calculation, efficiency metrics, tool leaderboard to StatsData" +``` + +--- + +### Task 4: Change heatmap to token-based with today highlight + +**Files:** +- Modify: `packages/cli/src/ui/utils/statsDataService.ts:69-82` (buildHeatmap) +- Modify: `packages/cli/src/ui/utils/asciiCharts.ts` (HeatmapCell interface + buildHeatmapData) + +- [ ] **Step 1: Change buildHeatmap to sum tokens instead of counting sessions** + +In `packages/cli/src/ui/utils/statsDataService.ts`, update `buildHeatmap`: + +```typescript +function buildHeatmap( + records: UsageSummaryRecord[], + start: Date, + end: Date, +): Record { + const heatmap: Record = {}; + for (const r of records) { + if (r.timestamp < start.getTime() || r.timestamp > end.getTime()) continue; + const ts = new Date(r.timestamp); + const key = `${ts.getFullYear()}-${String(ts.getMonth() + 1).padStart(2, '0')}-${String(ts.getDate()).padStart(2, '0')}`; + let totalTokens = 0; + for (const m of Object.values(r.models)) { + totalTokens += m.totalTokens || m.inputTokens + m.outputTokens; + } + heatmap[key] = (heatmap[key] || 0) + totalTokens; + } + return heatmap; +} +``` + +- [ ] **Step 2: Add `isToday` flag to HeatmapCell** + +In `packages/cli/src/ui/utils/asciiCharts.ts`, update the interface: + +```typescript +export interface HeatmapCell { + char: string; + intensity: HeatmapIntensity; + isToday?: boolean; +} +``` + +In `buildHeatmapData`, after computing each cell, mark today: + +```typescript +// Inside the while loop, after creating the cell: +const todayKey = formatDateKey(new Date()); +// ... +const isToday = key === todayKey; +grid[row]!.push({ char: HEATMAP_CHARS[level]!, intensity: level, isToday }); +``` + +- [ ] **Step 3: Render today's cell distinctly in StatsDialog.tsx** + +In `StatsDialog.tsx`, inside the `HeatmapView` component's cell render: + +```typescript +{row.cells.map((cell, ci) => ( + + {cell.isToday ? '▪▪' : cell.char} + +))} +``` + +- [ ] **Step 4: Verify visually by running `npm run dev` and opening `/stats`** + +Run: `npm run dev` then type `/stats` and switch to Activity tab. +Expected: Heatmap shows token-based intensity, today's cell has `▪▪` marker with bold+underline. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/ui/utils/statsDataService.ts packages/cli/src/ui/utils/asciiCharts.ts packages/cli/src/ui/components/StatsDialog.tsx +git commit -m "feat(stats): token-based heatmap with today highlight" +``` + +--- + +### Task 5: Add 'today' to TimeRange and update range cycle + +**Files:** +- Modify: `packages/core/src/services/usageHistoryService.ts:46,253-281` +- Modify: `packages/cli/src/ui/components/StatsDialog.tsx:34` + +- [ ] **Step 1: Verify 'today' is already in the TimeRange type** + +Check that `type TimeRange = 'today' | 'week' | 'month' | 'all'` already exists (added in current code at line 46). It does. The `getTimeRangeBounds` function already handles the `'today'` case. + +- [ ] **Step 2: Update RANGE_CYCLE in StatsDialog.tsx** + +```typescript +const RANGE_CYCLE: TimeRange[] = ['today', 'week', 'month', 'all']; +``` + +Update `getRangeLabel`: + +```typescript +function getRangeLabel(range: string): string { + const labels: Record = { + today: t('Today'), + all: t('All time'), + week: t('Last 7 days'), + month: t('Last 30 days'), + }; + return labels[range] ?? range; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/ui/components/StatsDialog.tsx +git commit -m "feat(stats): add 'today' to range cycle" +``` + +--- + +### Task 6: Implement ActivityTab component + +**Files:** +- Modify: `packages/cli/src/ui/components/StatsDialog.tsx` + +- [ ] **Step 1: Replace OverviewTab with ActivityTab** + +Remove the entire `OverviewTab` component and replace with `ActivityTab`: + +```typescript +const ActivityTab: React.FC<{ + data: StatsData; + bodyWidth: number; + chartMonthOffset: number; + range: TimeRange; +}> = ({ data, bodyWidth, chartMonthOffset, range }) => { + const heatmapWeeks = Math.min( + 26, + Math.max(8, Math.floor((bodyWidth - 4) / 2)), + ); + const col1Width = Math.floor(bodyWidth / 3); + + let totalTokens = 0; + for (const m of Object.values(data.report.models)) { + totalTokens += m.totalTokens; + } + + const dailyTotals = new Map(); + for (const d of data.tokensPerDay) { + dailyTotals.set(d.date, (dailyTotals.get(d.date) || 0) + d.tokens); + } + const allDates = [...dailyTotals.keys()].sort(); + const availableMonths = [...new Set(allDates.map((d) => d.slice(0, 7)))] + .sort() + .reverse(); + const clampedOffset = Math.min( + chartMonthOffset, + Math.max(0, availableMonths.length - 1), + ); + const chartMonth = + range === 'all' && availableMonths.length > 0 + ? availableMonths[clampedOffset]! + : null; + const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const chartMonthLabel = chartMonth + ? `${monthNames[Number(chartMonth.slice(5, 7)) - 1]} ${chartMonth.slice(0, 4)}` + : null; + const canGoLeft = clampedOffset < availableMonths.length - 1; + const canGoRight = clampedOffset > 0; + const filteredData = chartMonth + ? [...dailyTotals.entries()].filter(([d]) => d.startsWith(chartMonth)) + : [...dailyTotals.entries()]; + const totalSeries = [ + { label: t('Total'), data: filteredData.map(([date, value]) => ({ date, value })) }, + ]; + const overviewChart = buildLineChartData(totalSeries, bodyWidth, 6); + + return ( + + {/* KPI Row */} + + + {t('Sessions')} + {data.report.sessionCount} + {data.delta?.sessions != null && ( + = 0 ? theme.status.success : theme.status.error}> + {' '}{data.delta.sessions >= 0 ? '▲' : '▼'}{Math.abs(data.delta.sessions).toFixed(0)}% + + )} + + + {t('Duration')} + {fmtDurationShort(data.report.totalDurationMs)} + {data.delta?.duration != null && ( + = 0 ? theme.status.success : theme.status.error}> + {' '}{data.delta.duration >= 0 ? '▲' : '▼'}{Math.abs(data.delta.duration).toFixed(0)}% + + )} + + + {t('Tokens')} + {fmtTokens(totalTokens)} + {data.delta?.tokens != null && ( + = 0 ? theme.status.success : theme.status.error}> + {' '}{data.delta.tokens >= 0 ? '▲' : '▼'}{Math.abs(data.delta.tokens).toFixed(0)}% + + )} + + + + {/* Heatmap */} + + + + + {t('streak')}: + {data.currentStreak}d + + + {t('best')}: + {data.longestStreak}d + + + + + {/* Token Trend */} + + + {t('Token Trend')} + {chartMonthLabel && ( + + {' '}{canGoLeft ? '← ' : ' '}{chartMonthLabel}{canGoRight ? ' →' : ''} + + )} + + {overviewChart ? ( + <> + {overviewChart.rows.map((row, ri) => ( + + {row.yLabel}{row.border} + {row.cells.map((cell, ci) => ( + = 0 ? theme.text.accent : theme.text.secondary}> + {cell.char} + + ))} + + ))} + + {overviewChart.xAxisRow.yLabel}{overviewChart.xAxisRow.border} + {overviewChart.xAxisRow.cells.map((cell, ci) => ( + {cell.char} + ))} + + + {overviewChart.xLabelRow.yLabel}{overviewChart.xLabelRow.border} + {overviewChart.xLabelRow.cells.map((cell, ci) => ( + {cell.char} + ))} + + + ) : ( + {' '}{t('(no data)')} + )} + + + {/* Project Ranking */} + {data.report.projects.length > 0 && ( + + {t('Projects')} + + {data.report.projects.slice(0, 5).map((proj) => { + const name = proj.path.split('/').pop() || proj.path; + const tokens = proj.totalInputTokens + proj.totalOutputTokens; + return ( + + ); + })} + + )} + + ); +}; +``` + +- [ ] **Step 2: Update tab references in StatsDialog render** + +Replace `activeTab === 'overview'` with `activeTab === 'activity'` and update props to pass the new `ActivityTab` component. Update `TAB_DEFS`: + +```typescript +type StatsTab = 'session' | 'activity' | 'efficiency'; + +const TAB_DEFS: Array<{ tab: StatsTab; label: () => string }> = [ + { tab: 'session', label: () => t('Session') }, + { tab: 'activity', label: () => t('Activity') }, + { tab: 'efficiency', label: () => t('Efficiency') }, +]; +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/ui/components/StatsDialog.tsx +git commit -m "feat(stats): implement ActivityTab with KPI deltas, heatmap, trend, projects" +``` + +--- + +### Task 7: Implement EfficiencyTab component + +**Files:** +- Modify: `packages/cli/src/ui/components/StatsDialog.tsx` + +- [ ] **Step 1: Replace ModelsTab with EfficiencyTab** + +Remove the `ModelsTab` and `ChartView` components. Add `EfficiencyTab`: + +```typescript +function fmtSuccessBar(rate: number): string { + const filled = Math.round(rate / 10); + return '█'.repeat(filled) + '░'.repeat(10 - filled); +} + +function getSuccessColor(rate: number): string { + if (rate >= 95) return theme.status.success; + if (rate >= 80) return theme.status.warning; + return theme.status.error; +} + +function getCacheColor(rate: number): string { + if (rate >= 85) return theme.status.success; + if (rate >= 70) return theme.status.warning; + return theme.status.error; +} + +const EfficiencyTab: React.FC<{ + data: StatsData; + bodyWidth: number; +}> = ({ data, bodyWidth }) => { + const cardWidth = Math.floor((bodyWidth - 4) / 3); + + const modelEntries = Object.entries(data.report.models).sort( + (a, b) => b[1].totalTokens - a[1].totalTokens, + ); + + return ( + + {/* Performance Cards */} + + + {t('Cache Hit Rate')} + + {data.efficiency.cacheHitRate.toFixed(1)}% + + {data.delta?.cacheRate != null && ( + = 0 ? theme.status.success : theme.status.error}> + {data.delta.cacheRate >= 0 ? '▲' : '▼'} {Math.abs(data.delta.cacheRate).toFixed(1)}% + + )} + + + {t('Tool Success')} + + {data.efficiency.toolSuccessRate.toFixed(1)}% + + {data.delta?.toolSuccess != null && ( + = 0 ? theme.status.success : theme.status.error}> + {data.delta.toolSuccess >= 0 ? '▲' : '▼'} {Math.abs(data.delta.toolSuccess).toFixed(1)}% + + )} + + + {t('Avg Latency')} + + {data.efficiency.avgLatencyMs != null + ? `${(data.efficiency.avgLatencyMs / 1000).toFixed(1)}s` + : '—'} + + {data.delta?.avgLatency != null && ( + + {data.delta.avgLatency <= 0 ? '▲' : '▼'} {Math.abs(data.delta.avgLatency / 1000).toFixed(1)}s + + )} + + + + {/* Tool Leaderboard */} + {data.toolLeaderboard.length > 0 && ( + + {t('Tool Leaderboard')} + + {data.toolLeaderboard.map((tool) => ( + + ))} + + )} + + {/* Model Comparison */} + {modelEntries.length > 0 && ( + + {t('Models')} + + {modelEntries.map(([name, m], i) => { + const cacheRate = m.inputTokens > 0 ? (m.cachedTokens / m.inputTokens) * 100 : 0; + const latency = data.report.totalLatencyMs > 0 && m.requests > 0 + ? `${((data.report.totalLatencyMs / data.report.totalRequests) / 1000).toFixed(1)}s` + : '—'; + return ( + + ); + })} + + )} + + {/* Code Impact */} + {(data.report.files.linesAdded > 0 || data.report.files.linesRemoved > 0) && ( + + {t('Code Impact')} + +{data.report.files.linesAdded.toLocaleString()} + / + -{data.report.files.linesRemoved.toLocaleString()} + {t('net')}: + + +{(data.report.files.linesAdded - data.report.files.linesRemoved).toLocaleString()} + + + )} + + ); +}; +``` + +- [ ] **Step 2: Wire EfficiencyTab into the main render** + +In the `StatsDialog` render body, replace `activeTab === 'models'` with: + +```typescript +{activeTab === 'efficiency' && !loading && data && ( + +)} +``` + +Remove the `chartFilter` state and the `e` key handler (no longer needed). + +Update the hints text: + +```typescript +{activeTab === 'session' + ? t('tab · esc') + : t('tab · r dates · ←→ month · esc')} +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/ui/components/StatsDialog.tsx +git commit -m "feat(stats): implement EfficiencyTab with perf cards, tool leaderboard, models" +``` + +--- + +### Task 8: Add i18n keys + +**Files:** +- Modify: `packages/cli/src/i18n/mustTranslateKeys.ts` + +- [ ] **Step 1: Add new translation keys** + +Add the new keys to the must-translate list (the `t()` function uses the key itself as the English fallback, so no separate English file is needed): + +```typescript +// In mustTranslateKeys.ts, add to the array: +'Activity', +'Efficiency', +'Today', +'Cache Hit Rate', +'Tool Success', +'Avg Latency', +'Tool Leaderboard', +'Calls', +'Time', +'Reqs', +'Cache', +'Latency', +'Code Impact', +'net', +'streak', +'best', +'Token Trend', +``` + +- [ ] **Step 2: Run the i18n tests** + +Run: `cd packages/cli && npx vitest run src/i18n/` +Expected: PASS (or check what the test expects — may need to update snapshot) + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/i18n/mustTranslateKeys.ts +git commit -m "feat(stats): add i18n keys for new dashboard tabs" +``` + +--- + +### Task 9: Clean up unused code and verify + +**Files:** +- Modify: `packages/cli/src/ui/components/StatsDialog.tsx` + +- [ ] **Step 1: Remove dead code** + +Remove the `ChartView` component (was only used by ModelsTab). Remove `ModelStatsDisplay` import if present. Remove unused `chartFilter` state variable and related key handlers. + +- [ ] **Step 2: Run typecheck** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors. + +- [ ] **Step 3: Run existing tests** + +Run: `cd packages/cli && npx vitest run` +Expected: All pass (fix any snapshot updates with `--update` if needed). + +- [ ] **Step 4: Visual verification** + +Run: `npm run dev`, then type `/stats`: +- Verify Session tab unchanged +- Verify Activity tab shows KPI row with deltas, token heatmap with today highlight, sparkline, projects +- Verify Efficiency tab shows performance cards, tool leaderboard with bars, model table, code impact +- Verify `r` cycles through today/week/month/all +- Verify ←→ navigates months in chart + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/ui/components/StatsDialog.tsx +git commit -m "refactor(stats): remove dead ChartView/ModelsTab code" +``` diff --git a/.qwen/specs/2025-06-03-stats-dashboard-redesign.md b/.qwen/specs/2025-06-03-stats-dashboard-redesign.md new file mode 100644 index 0000000000..b814fb219e --- /dev/null +++ b/.qwen/specs/2025-06-03-stats-dashboard-redesign.md @@ -0,0 +1,270 @@ +# Stats Dashboard Redesign + +## Overview + +Redesign the `/stats` TUI dashboard to improve layout hierarchy, add efficiency metrics, tool usage details, and trend comparisons. The Session tab remains unchanged. + +## Tab Structure + +``` +Tab 1: Session (unchanged - live current-session metrics) +Tab 2: Activity (time-based trends and usage patterns) +Tab 3: Efficiency (performance metrics and tool analysis) +``` + +## Time Range Selector + +Cycle: `Today` → `Week` → `Month` → `All` + +Triggered by pressing `r`. All data in Activity and Efficiency tabs is filtered by the selected range. + +## Delta Calculation + +Every KPI card shows a trend arrow comparing the current range against the previous equivalent range: + +- Range = Today → compare today vs yesterday +- Range = Week → compare last 7 days vs the 7 days before that +- Range = Month → compare last 30 days vs the 30 days before that +- Range = All → no delta shown + +Display: positive = green `▲ +12%`, negative = red `▼ -3%`. For latency, lower is better so the colors invert. + +Implementation: load two time slices from `usage_record.jsonl`, aggregate each, compute percentage change. + +## Activity Tab + +Layout from top to bottom: + +### 1. KPI Row + +Three metrics in a horizontal row, each with value + delta arrow: + +| Metric | Source | Example | +|--------|--------|---------| +| Sessions | `report.sessionCount` | `42 ▲+8` | +| Duration | `report.totalDurationMs` | `18h 32m ▲+2h` | +| Tokens | sum of `report.models[*].totalTokens` | `2.4m ▲+12%` | + +### 2. Heatmap + +- Full width, GitHub-style grid +- **Color intensity** = daily total token consumption (not session count) +- **Today's cell** = distinct border or marker character (e.g., `[ ]` instead of ` `, or a brighter outline color) +- Right-aligned metadata: `streak: 12d │ best: 23d` +- Legend row: `Less ░░░░░ More` +- Column labels: month abbreviations + day numbers +- Row labels: Mon / Wed / Fri (compact 3-row mode) +- Weeks shown: `min(26, max(8, floor((bodyWidth - 4) / 2)))` + +### 3. Token Trend Chart + +- Braille sub-pixel line chart (existing `buildLineChartData`) +- Single series: total tokens per day +- Height: 6 rows +- Month navigation with `←` `→` when range = `all` +- Month label: `← Jun 2025 →` + +### 4. Project Ranking + +Table showing top 5 projects: + +``` + Project Sessions Tokens Duration + qwen-code 28 1.8m 12h + web-app 10 420k 4h + infra 4 180k 2h +``` + +Source: `report.projects` sorted by totalTokens descending. + +## Efficiency Tab + +Layout from top to bottom: + +### 1. Performance Cards Row + +Three boxed metric cards: + +| Metric | Calculation | Source | +|--------|-------------|--------| +| Cache Hit Rate | `cachedTokens / inputTokens * 100` | `report.models[*].cachedTokens` / `inputTokens` | +| Tool Success Rate | `totalSuccess / totalCalls * 100` | `report.tools.totalSuccess` / `totalCalls` | +| Avg Latency | `totalLatencyMs / totalRequests` | Requires adding `totalLatencyMs` to persisted records OR computing from per-model data | + +Each card shows: label, bold percentage/value, delta arrow. + +Note on Avg Latency: The current `UsageSummaryRecord` does not persist latency data. Options: +1. Compute from live `SessionMetrics` for current session only (show "—" for historical) +2. Add `totalLatencyMs` field to the persisted record (migration: old records show "—") + +**Decision: Option 2** — extend `UsageSummaryRecord` with optional `totalLatencyMs`. Old records without this field display "—" for latency delta. + +### 2. Tool Leaderboard + +Table showing top 8 tools by call count: + +``` + Tool Calls Time Success + edit 847 42.3s ██████████ 98% + read 612 8.1s ██████████ 99% + bash 431 67.8s █████████░ 89% + glob 298 2.4s ██████████ 99% + grep 256 3.1s █████████░ 97% + write 189 12.5s ██████████ 96% + agent 45 89.2s ████████░░ 82% +``` + +- Success rate visualized as a 10-char bar: filled `█` + empty `░` +- Color: green if ≥95%, orange if ≥80%, red if <80% +- Source: `report.tools.topTools` (already computed, but needs duration added) + +Note: Current `topTools` in aggregated report only has `count, success, fail`. Need to add `totalDurationMs` per tool to the aggregation. + +### 3. Model Comparison Table + +``` + Model Reqs In/Out Cache Latency + ● qwen-max 186 1.2m/340k 91% 2.1s + ● qwen-plus 124 890k/210k 84% 1.2s + ● qwen-turbo 67 310k/89k 72% 0.8s +``` + +- Sorted by totalTokens descending +- Color-coded dots (series colors) +- Cache column: green ≥85%, orange ≥70%, red <70% +- Source: `report.models` + +### 4. Code Impact + +Single-line summary: + +``` + Code +2,847 lines / -1,203 lines net: +1,644 +``` + +Source: `report.files.linesAdded`, `report.files.linesRemoved`. + +## Keyboard Controls + +| Key | Action | +|-----|--------| +| `Tab` / `Shift+Tab` | Switch between tabs | +| `r` | Cycle range: today → week → month → all | +| `←` / `h` | Previous month (chart navigation, range=all only) | +| `→` / `l` | Next month (chart navigation, range=all only) | +| `Esc` | Close dialog | + +## Data Layer Changes + +### UsageSummaryRecord v1 Extensions (backward-compatible) + +Add optional fields to existing schema: + +```typescript +interface UsageSummaryRecord { + // ... existing fields ... + totalLatencyMs?: number; // NEW: sum of all API response latencies + tools: { + // ... existing fields ... + byName: Record; + }; +} +``` + +### StatsData Extensions + +```typescript +interface StatsData { + // ... existing fields ... + delta?: { + sessions: number | null; // percentage change + duration: number | null; + tokens: number | null; + cacheRate: number | null; + toolSuccess: number | null; + avgLatency: number | null; + }; + efficiency: { + cacheHitRate: number; + toolSuccessRate: number; + avgLatencyMs: number | null; + }; + toolLeaderboard: Array<{ + name: string; + count: number; + totalDurationMs: number; + successRate: number; + }>; +} +``` + +### Heatmap Data Change + +Currently `buildHeatmapData` receives `Record` where value = session count. Change to: value = total tokens for that day. The mapping to intensity levels (0-4) needs recalibration: + +- 0: no usage +- 1: < 10k tokens +- 2: 10k - 50k tokens +- 3: 50k - 200k tokens +- 4: > 200k tokens + +Thresholds should be computed dynamically based on the data distribution (percentile-based) rather than hardcoded, to adapt to different usage patterns. + +### Today Highlight + +In `buildHeatmapData`, mark today's cell with a special property. Render it with a distinct character or color attribute (e.g., bright white border characters `[▓]` instead of plain `▓▓`). + +## Internationalization + +All user-facing strings wrapped in `t()`. New i18n keys: + +``` +stats.activity = "Activity" +stats.efficiency = "Efficiency" +stats.today = "Today" +stats.sessions = "Sessions" +stats.duration = "Duration" +stats.tokens = "Tokens" +stats.cacheHitRate = "Cache Hit Rate" +stats.toolSuccessRate = "Tool Success" +stats.avgLatency = "Avg Latency" +stats.toolLeaderboard = "Tool Leaderboard" +stats.calls = "Calls" +stats.time = "Time" +stats.success = "Success" +stats.models = "Models" +stats.reqs = "Reqs" +stats.cache = "Cache" +stats.latency = "Latency" +stats.codeImpact = "Code Impact" +stats.net = "net" +stats.streak = "streak" +stats.best = "best" +stats.tokenTrend = "Token Trend" +stats.projects = "Projects" +stats.project = "Project" +``` + +## Files to Modify + +| File | Change | +|------|--------| +| `packages/cli/src/ui/components/StatsDialog.tsx` | Replace OverviewTab and ModelsTab with ActivityTab and EfficiencyTab | +| `packages/core/src/services/usageHistoryService.ts` | Add delta calculation, extend aggregation for tool duration and latency | +| `packages/cli/src/ui/utils/statsDataService.ts` | Extend StatsData with efficiency and delta fields | +| `packages/cli/src/ui/utils/asciiCharts.ts` | Add today highlight to heatmap, adjust intensity mapping | +| `packages/core/src/telemetry/uiTelemetry.ts` | Ensure latency is captured in persistence path | +| `packages/cli/src/gemini.tsx` | Persist `totalLatencyMs` and per-tool duration in shutdown hook | +| `packages/cli/src/i18n/*.ts` | Add new translation keys | + +## Out of Scope + +- Cost estimation (requires user-configured pricing, can be added later) +- Per-file change tracking (not available in current data model) +- Context window usage / compression metrics (not tracked) +- Interactive drill-down into individual sessions diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index eae7bacfc1..866525f83c 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -268,17 +268,19 @@ In headless (`--prompt`) or non-interactive contexts, `/diff` prints a plain-tex Commands for obtaining information and performing system settings. -| Command | Description | Usage Examples | -| --------------- | ------------------------------------------------------------- | -------------------------------- | -| `/help` | Display help information for available commands | `/help` or `/?` | -| `/status` | Display version information | `/status` or `/about` | -| `/status paths` | Display current session file and log paths | `/status paths` | -| `/stats` | Display detailed statistics for current session | `/stats` | -| `/settings` | Open settings editor | `/settings` | -| `/auth` | Change authentication method | `/auth` | -| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | -| `/copy` | Copy AI output to clipboard (`/copy N` = Nth-last AI message) | `/copy` or `/copy 2` | -| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | +| Command | Description | Usage Examples | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `/help` | Display help information for available commands | `/help` or `/?` | +| `/status` | Display version information | `/status` or `/about` | +| `/status paths` | Display current session file and log paths | `/status paths` | +| `/stats` | Open interactive usage statistics dashboard with three tabs: Session (live metrics), Activity (heatmap, token trend, project ranking), and Efficiency (cache rate, tool leaderboard, model comparison). Use `tab` to switch tabs, `r` to cycle time ranges, `←→` to pan months, `esc` to close. | `/stats` | +| `/stats model` | Show per-model token breakdown and estimated cost | `/stats model` | +| `/stats tools` | Show per-tool call counts | `/stats tools` | +| `/settings` | Open settings editor | `/settings` | +| `/auth` | Change authentication method | `/auth` | +| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | +| `/copy` | Copy AI output to clipboard (`/copy N` = Nth-last AI message) | `/copy` or `/copy 2` | +| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | ### 1.10 Common Shortcuts diff --git a/package-lock.json b/package-lock.json index a59589eb50..a053dcaeee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,6 @@ "mock-fs": "^5.5.0", "msw": "^2.10.4", "npm-run-all": "^4.1.5", - "patch-package": "^8.0.1", "prettier": "^3.5.3", "react-devtools-core": "^6.1.5", "semver": "^7.7.2", @@ -5949,13 +5948,6 @@ "addons/*" ] }, - "node_modules/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -7363,22 +7355,6 @@ } } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cjs-module-lexer": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", @@ -10001,16 +9977,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-yarn-workspace-root": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", - "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "micromatch": "^4.0.2" - } - }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", @@ -12432,26 +12398,6 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stable-stringify": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", - "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "isarray": "^2.0.5", - "jsonify": "^0.0.1", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -12502,16 +12448,6 @@ "node": ">= 10.0.0" } }, - "node_modules/jsonify": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", - "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", - "dev": true, - "license": "Public Domain", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/jsonrepair": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", @@ -12604,16 +12540,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/klaw-sync": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", - "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.11" - } - }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -14580,107 +14506,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/patch-package": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", - "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@yarnpkg/lockfile": "^1.1.0", - "chalk": "^4.1.2", - "ci-info": "^3.7.0", - "cross-spawn": "^7.0.3", - "find-yarn-workspace-root": "^2.0.0", - "fs-extra": "^10.0.0", - "json-stable-stringify": "^1.0.2", - "klaw-sync": "^6.0.0", - "minimist": "^1.2.6", - "open": "^7.4.2", - "semver": "^7.5.3", - "slash": "^2.0.0", - "tmp": "^0.2.4", - "yaml": "^2.2.2" - }, - "bin": { - "patch-package": "index.js" - }, - "engines": { - "node": ">=14", - "npm": ">5" - } - }, - "node_modules/patch-package/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/patch-package/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/patch-package/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/patch-package/node_modules/open": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", - "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^2.0.0", - "is-wsl": "^2.1.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/patch-package/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -16675,16 +16500,6 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, - "node_modules/slash": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", - "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/slice-ansi": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", @@ -17886,16 +17701,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tmp": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", - "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index e76faa6755..a853f34b35 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ "build:sdk:python": "python3 -m build packages/sdk-python", "check-i18n": "npm run check-i18n --workspace=packages/cli", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", - "postinstall": "patch-package", "prepare": "husky && npm run build && npm run bundle", "prepare:package": "node scripts/prepare-package.js", "package:hosted-installation": "node scripts/build-hosted-installation-assets.js", @@ -129,7 +128,6 @@ "mock-fs": "^5.5.0", "msw": "^2.10.4", "npm-run-all": "^4.1.5", - "patch-package": "^8.0.1", "prettier": "^3.5.3", "react-devtools-core": "^6.1.5", "semver": "^7.7.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index 0a19666cc2..37a56fa51f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,7 +62,7 @@ "fzf": "^0.5.2", "glob": "^10.5.0", "highlight.js": "^11.11.1", - "ink": "7.0.3", + "ink": "^7.0.3", "ink-gradient": "^3.0.0", "ink-link": "^4.1.0", "ink-spinner": "^5.0.0", diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 24d55801ca..8d8a14799c 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -17,6 +17,8 @@ import { type Config, createDebugLogger, writeRuntimeStatus, + persistSessionUsage, + uiTelemetryService, } from '@qwen-code/qwen-code-core'; import { render } from 'ink'; import dns from 'node:dns'; @@ -834,6 +836,29 @@ export async function main() { } } + // Persist session usage for cross-session reports (must run before + // config.shutdown() which clears telemetry state). + // sessionStartTime is read from uiTelemetryService so it stays correct + // after /clear resets the session (reset() updates the internal timestamp). + registerCleanup(() => { + try { + const metrics = uiTelemetryService.getMetrics(); + const hasActivity = Object.values(metrics.models).some( + (m) => m.api.totalRequests > 0, + ); + if (!hasActivity) return; + persistSessionUsage({ + sessionId: config.getSessionId(), + startTime: uiTelemetryService.getSessionStartTime(), + endTime: new Date(), + project: config.getProjectRoot(), + metrics, + }); + } catch { + // Best-effort — don't block shutdown + } + }); + // Register cleanup for MCP clients as early as possible // This ensures MCP server subprocesses are properly terminated on exit registerCleanup(() => config.shutdown()); diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index fd283fe2a9..16b9d4e0ce 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -1471,6 +1471,21 @@ export default { "No s'ha realitzat cap crida a eines en aquesta sessió.", 'Session start time is unavailable, cannot calculate stats.': "L'hora d'inici de la sessió no està disponible, no es poden calcular les estadístiques.", + Activity: 'Activitat', + Efficiency: 'Eficiència', + Today: 'Avui', + 'Token Trend': 'Tendència de Tokens', + 'Cache Hit Rate': "Taxa d'encert de cache", + 'Tool Success': "Èxit d'eines", + 'Tool Leaderboard': "Classificació d'eines", + Time: 'Temps', + Success: 'Èxit', + Cache: 'Cache', + Latency: 'Latència', + 'Code Impact': 'Impacte al codi', + net: 'net', + streak: 'ratxa', + best: 'rècord', // ============================================================================ // Migració del format d'ordres @@ -1921,4 +1936,40 @@ export default { 'Ref:': 'Referència:', '中国 (China)': 'Xina', '中国 (China) - 阿里云百炼': 'Xina - 阿里云百炼', + + // Stats Dashboard — Category 2 + 'Activity Heatmap': "Mapa d'activitat", + Less: 'Menys', + More: 'Més', + Sessions: 'Sessions', + Duration: 'Durada', + Projects: 'Projectes', + 'Loading stats...': 'Carregant estadístiques...', + '(no data)': '(sense dades)', + d: 'd', + h: 'h', + m: 'm', + Input: 'Entrada', + Models: 'Models', + 'All time': 'Tot el temps', + 'Last 7 days': 'Últims 7 dies', + 'Last 30 days': 'Últims 30 dies', + 'Show usage statistics dashboard.': "Mostra el tauler d'estadístiques d'ús.", + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': "Sol·licituds d'API", + 'Tool Calls': "Crides d'eines", + 'Success rate': "Taxa d'èxit", + 'Code Changes': 'Canvis de codi', + Tool: 'Eina', + reqs: 'sol.', + in: 'ent.', + out: 'sort.', + 'In/Out': 'Ent/Sort', }; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index c2a7bbddb3..f1bfdd6bdb 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1405,6 +1405,21 @@ export default { 'In dieser Sitzung wurden keine Werkzeugaufrufe gemacht.', 'Session start time is unavailable, cannot calculate stats.': 'Sitzungsstartzeit nicht verfügbar, Statistiken können nicht berechnet werden.', + Activity: 'Aktivität', + Efficiency: 'Effizienz', + Today: 'Heute', + 'Token Trend': 'Token-Trend', + 'Cache Hit Rate': 'Cache-Trefferquote', + 'Tool Success': 'Tool-Erfolgsrate', + 'Tool Leaderboard': 'Tool-Rangliste', + Time: 'Zeit', + Success: 'Erfolg', + Cache: 'Cache', + Latency: 'Latenz', + 'Code Impact': 'Code-Änderungen', + net: 'netto', + streak: 'Serie', + best: 'Rekord', // ============================================================================ // Command Format Migration @@ -1964,4 +1979,40 @@ export default { remote: 'entfernt', '中国 (China)': 'China', '中国 (China) - 阿里云百炼': 'China - 阿里云百炼', + + // Stats Dashboard — Category 2 + 'Activity Heatmap': 'Aktivitäts-Heatmap', + Less: 'Weniger', + More: 'Mehr', + Sessions: 'Sitzungen', + Duration: 'Dauer', + Projects: 'Projekte', + 'Loading stats...': 'Statistiken werden geladen...', + '(no data)': '(keine Daten)', + d: 'd', + h: 'h', + m: 'm', + Input: 'Eingabe', + Models: 'Modelle', + 'All time': 'Gesamtzeitraum', + 'Last 7 days': 'Letzte 7 Tage', + 'Last 30 days': 'Letzte 30 Tage', + 'Show usage statistics dashboard.': 'Nutzungsstatistik-Dashboard anzeigen.', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'API-Anfragen', + 'Tool Calls': 'Tool-Aufrufe', + 'Success rate': 'Erfolgsrate', + 'Code Changes': 'Code-Änderungen', + Tool: 'Tool', + reqs: 'Anfr.', + in: 'ein', + out: 'aus', + 'In/Out': 'Ein/Aus', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 053a656ece..34cf5a9d14 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -512,8 +512,7 @@ export default { 'Auto Edit': 'Auto Edit', YOLO: 'YOLO', 'toggle vim mode on/off': 'toggle vim mode on/off', - 'check session stats. Usage: /stats [model|tools]': - 'check session stats. Usage: /stats [model|tools]', + 'Show usage statistics dashboard.': 'Show usage statistics dashboard.', 'Show model-specific usage statistics.': 'Show model-specific usage statistics.', 'Show tool-specific usage statistics.': @@ -2026,4 +2025,86 @@ export default { 'Loading suggestions...': 'Loading suggestions...', 'Show per-item context usage breakdown.': 'Show per-item context usage breakdown.', + + // ============================================================================ + // Stats + // ============================================================================ + + // statsCommand non-interactive output + 'Session duration: {{duration}}': 'Session duration: {{duration}}', + 'Prompts: {{count}}': 'Prompts: {{count}}', + 'API requests: {{count}}': 'API requests: {{count}}', + 'Tokens — prompt: {{prompt}}, output: {{output}}': + 'Tokens — prompt: {{prompt}}, output: {{output}}', + 'Tool calls: {{total}} ({{success}} ok, {{fail}} fail)': + 'Tool calls: {{total}} ({{success}} ok, {{fail}} fail)', + 'Files: +{{added}} / -{{removed}} lines': + 'Files: +{{added}} / -{{removed}} lines', + prompt: 'prompt', + output: 'output', + cached: 'cached', + 'Estimated cost: ${{cost}}': 'Estimated cost: ${{cost}}', + 'No model usage data yet.': 'No model usage data yet.', + 'No tool usage data yet.': 'No tool usage data yet.', + + // StatsDialog + Models: 'Models', + 'All time': 'All time', + 'Last 7 days': 'Last 7 days', + 'Last 30 days': 'Last 30 days', + 'N/A': 'N/A', + Sessions: 'Sessions', + days: 'days', + Input: 'Input', + 'Tool calls': 'Tool calls', + 'Code changes': 'Code changes', + Projects: 'Projects', + Name: 'Name', + Duration: 'Duration', + 'Activity Heatmap': 'Activity Heatmap', + 'Loading stats...': 'Loading stats...', + '\u2191 tabs \u00b7 r to cycle dates \u00b7 esc to close': + '\u2191 tabs \u00b7 r to cycle dates \u00b7 esc to close', + Cost: 'Cost', + Less: 'Less', + More: 'More', + '(no data)': '(no data)', + d: 'd', + h: 'h', + m: 'm', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — labels + Session: 'Session', + Activity: 'Activity', + Efficiency: 'Efficiency', + Success: 'Success', + Today: 'Today', + 'Cache Hit Rate': 'Cache Hit Rate', + 'Tool Success': 'Tool Success', + 'Tool Leaderboard': 'Tool Leaderboard', + Time: 'Time', + Cache: 'Cache', + Latency: 'Latency', + 'Code Impact': 'Code Impact', + 'Failed to load stats. Press r to retry.': + 'Failed to load stats. Press r to retry.', + net: 'net', + streak: 'streak', + best: 'best', + 'Token Trend': 'Token Trend', + 'In/Out': 'In/Out', + 'API Requests': 'API Requests', + 'Tool Calls': 'Tool Calls', + 'Success rate': 'Success rate', + 'Code Changes': 'Code Changes', + Tool: 'Tool', + reqs: 'reqs', + in: 'in', + out: 'out', }; diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 04584b5124..cd0986f974 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -872,8 +872,7 @@ export default { 'La commande /fork nécessite le feature gate fork. Définissez QWEN_CODE_ENABLE_FORK_SUBAGENT=1 pour l’activer.', 'The agent tool is unavailable; cannot fork.': "L'outil agent est indisponible ; impossible de créer un fork.", - 'Failed to launch fork: {{error}}': - 'Échec du lancement du fork : {{error}}', + 'Failed to launch fork: {{error}}': 'Échec du lancement du fork : {{error}}', 'User launched a background fork via /fork: {{directive}}': "L'utilisateur a lancé un fork en arrière-plan via /fork : {{directive}}", 'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.': @@ -1467,6 +1466,21 @@ export default { "Aucun appel d'outil n'a été effectué dans cette session.", 'Session start time is unavailable, cannot calculate stats.': "L'heure de début de session est indisponible, impossible de calculer les stats.", + Activity: 'Activité', + Efficiency: 'Efficacité', + Today: "Aujourd'hui", + 'Token Trend': 'Tendance Tokens', + 'Cache Hit Rate': 'Taux de cache', + 'Tool Success': 'Succès outils', + 'Tool Leaderboard': 'Classement outils', + Time: 'Temps', + Success: 'Succès', + Cache: 'Cache', + Latency: 'Latence', + 'Code Impact': 'Impact code', + net: 'net', + streak: 'série', + best: 'record', // ============================================================================ // Migration de format de commande @@ -1959,4 +1973,41 @@ export default { Tokens: 'Jetons', tokens: 'jetons', '中国 (China)': 'Chine', + + // Stats Dashboard — Category 2 + 'Activity Heatmap': "Carte d'activité", + Less: 'Moins', + More: 'Plus', + Sessions: 'Sessions', + Duration: 'Durée', + Projects: 'Projets', + 'Loading stats...': 'Chargement des stats...', + '(no data)': '(aucune donnée)', + d: 'j', + h: 'h', + m: 'm', + Input: 'Entrée', + Models: 'Modèles', + 'All time': 'Tout le temps', + 'Last 7 days': '7 derniers jours', + 'Last 30 days': '30 derniers jours', + 'Show usage statistics dashboard.': + "Afficher le tableau de bord des statistiques d'utilisation.", + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'Requêtes API', + 'Tool Calls': "Appels d'outils", + 'Success rate': 'Taux de réussite', + 'Code Changes': 'Modifications du code', + Tool: 'Outil', + reqs: 'req.', + in: 'ent.', + out: 'sort.', + 'In/Out': 'Ent/Sort', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 64d1291b2b..ee3ef5e087 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -595,8 +595,7 @@ export default { '/fork コマンドには fork フィーチャーゲートが必要です。有効にするには QWEN_CODE_ENABLE_FORK_SUBAGENT=1 を設定してください。', 'The agent tool is unavailable; cannot fork.': 'エージェントツールを利用できないため、フォークできません。', - 'Failed to launch fork: {{error}}': - 'フォークの起動に失敗しました: {{error}}', + 'Failed to launch fork: {{error}}': 'フォークの起動に失敗しました: {{error}}', 'User launched a background fork via /fork: {{directive}}': 'ユーザーが /fork でバックグラウンドフォークを起動しました: {{directive}}', 'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.': @@ -1142,6 +1141,21 @@ export default { 'このセッションではツール呼び出しが行われていません', 'Session start time is unavailable, cannot calculate stats.': 'セッション開始時刻が利用できないため、統計を計算できません', + Activity: 'アクティビティ', + Efficiency: '効率', + Today: '今日', + 'Token Trend': 'Token トレンド', + 'Cache Hit Rate': 'キャッシュヒット率', + 'Tool Success': 'ツール成功率', + 'Tool Leaderboard': 'ツールランキング', + Time: '時間', + Success: '成功率', + Cache: 'キャッシュ', + Latency: 'レイテンシ', + 'Code Impact': 'コード変更', + net: '純増', + streak: '連続', + best: '最長', // Loading 'Waiting for user confirmation...': 'ユーザーの確認を待っています...', // Witty Loading Phrases @@ -1727,4 +1741,40 @@ export default { 'Attribution: commit': 'コミットの帰属表示', '中国 (China)': '中国', '中国 (China) - 阿里云百炼': '中国 - 阿里云百炼', + + // Stats Dashboard — Category 2 (missing from ja) + 'Activity Heatmap': 'アクティビティヒートマップ', + Less: '少', + More: '多', + Sessions: 'セッション数', + Duration: '所要時間', + Projects: 'プロジェクト', + 'Loading stats...': '統計を読み込み中...', + '(no data)': '(データなし)', + d: '日', + h: '時', + m: '分', + Input: '入力', + Models: 'モデル', + 'All time': '全期間', + 'Last 7 days': '過去 7 日間', + 'Last 30 days': '過去 30 日間', + 'Show usage statistics dashboard.': '使用統計ダッシュボードを表示する。', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'APIリクエスト', + 'Tool Calls': 'ツール呼び出し', + 'Success rate': '成功率', + 'Code Changes': 'コード変更', + Tool: 'ツール', + reqs: 'リクエスト', + in: '入力', + out: '出力', + 'In/Out': '入力/出力', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index a066690033..cd0500f426 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -813,8 +813,7 @@ export default { 'O comando /fork requer o feature gate de fork. Defina QWEN_CODE_ENABLE_FORK_SUBAGENT=1 para ativá-lo.', 'The agent tool is unavailable; cannot fork.': 'A ferramenta de agente está indisponível; não é possível criar um fork.', - 'Failed to launch fork: {{error}}': - 'Falha ao iniciar o fork: {{error}}', + 'Failed to launch fork: {{error}}': 'Falha ao iniciar o fork: {{error}}', 'User launched a background fork via /fork: {{directive}}': 'O usuário iniciou um fork em segundo plano via /fork: {{directive}}', 'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.': @@ -1438,6 +1437,21 @@ export default { 'Nenhuma chamada de ferramenta foi feita nesta sessão.', 'Session start time is unavailable, cannot calculate stats.': 'Hora de início da sessão indisponível, não é possível calcular estatísticas.', + Activity: 'Atividade', + Efficiency: 'Eficiência', + Today: 'Hoje', + 'Token Trend': 'Tendência de Tokens', + 'Cache Hit Rate': 'Taxa de cache', + 'Tool Success': 'Sucesso de ferramentas', + 'Tool Leaderboard': 'Ranking de ferramentas', + Time: 'Tempo', + Success: 'Sucesso', + Cache: 'Cache', + Latency: 'Latência', + 'Code Impact': 'Impacto no código', + net: 'líquido', + streak: 'sequência', + best: 'recorde', // ============================================================================ // Command Format Migration @@ -1952,4 +1966,40 @@ export default { Use: 'Uso', '中国 (China)': 'China', '中国 (China) - 阿里云百炼': 'China - 阿里云百炼', + + // Stats Dashboard — Category 2 + 'Activity Heatmap': 'Mapa de Atividade', + Less: 'Menos', + More: 'Mais', + Sessions: 'Sessões', + Duration: 'Duração', + Projects: 'Projetos', + 'Loading stats...': 'Carregando estatísticas...', + '(no data)': '(sem dados)', + d: 'd', + h: 'h', + m: 'm', + Input: 'Entrada', + Models: 'Modelos', + 'All time': 'Todo o período', + 'Last 7 days': 'Últimos 7 dias', + 'Last 30 days': 'Últimos 30 dias', + 'Show usage statistics dashboard.': 'Exibir painel de estatísticas de uso.', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'Requisições API', + 'Tool Calls': 'Chamadas de Ferramenta', + 'Success rate': 'Taxa de sucesso', + 'Code Changes': 'Alterações de Código', + Tool: 'Ferramenta', + reqs: 'reqs', + in: 'ent.', + out: 'saída', + 'In/Out': 'Ent/Saída', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index dd679a7c1d..6dd6c589bc 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -820,8 +820,7 @@ export default { 'Команде /fork требуется feature gate fork. Установите QWEN_CODE_ENABLE_FORK_SUBAGENT=1, чтобы включить его.', 'The agent tool is unavailable; cannot fork.': 'Инструмент агента недоступен; fork создать нельзя.', - 'Failed to launch fork: {{error}}': - 'Не удалось запустить fork: {{error}}', + 'Failed to launch fork: {{error}}': 'Не удалось запустить fork: {{error}}', 'User launched a background fork via /fork: {{directive}}': 'Пользователь запустил фоновый fork через /fork: {{directive}}', 'Forked into a background agent. It inherits this conversation and runs without blocking — track it in the background tasks panel; it reports back when done.': @@ -1353,6 +1352,21 @@ export default { 'В этой сессии не было вызовов инструментов.', 'Session start time is unavailable, cannot calculate stats.': 'Время начала сессии недоступно, невозможно рассчитать статистику.', + Activity: 'Активность', + Efficiency: 'Эффективность', + Today: 'Сегодня', + 'Token Trend': 'Тренд токенов', + 'Cache Hit Rate': 'Попадание в кэш', + 'Tool Success': 'Успех инструментов', + 'Tool Leaderboard': 'Рейтинг инструментов', + Time: 'Время', + Success: 'Успех', + Cache: 'Кэш', + Latency: 'Задержка', + 'Code Impact': 'Изменения кода', + net: 'нетто', + streak: 'серия', + best: 'рекорд', // ============================================================================ // Command Format Migration @@ -1939,4 +1953,41 @@ export default { 'start server': 'запустить сервер', '中国 (China)': 'Китай', '中国 (China) - 阿里云百炼': 'Китай - 阿里云百炼', + + // Stats Dashboard — Category 2 + 'Activity Heatmap': 'Карта активности', + Less: 'Меньше', + More: 'Больше', + Sessions: 'Сессии', + Duration: 'Длительность', + Projects: 'Проекты', + 'Loading stats...': 'Загрузка статистики...', + '(no data)': '(нет данных)', + d: 'д', + h: 'ч', + m: 'м', + Input: 'Ввод', + Models: 'Модели', + 'All time': 'За всё время', + 'Last 7 days': 'Последние 7 дней', + 'Last 30 days': 'Последние 30 дней', + 'Show usage statistics dashboard.': + 'Показать панель статистики использования.', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'API-запросы', + 'Tool Calls': 'Вызовы инструментов', + 'Success rate': 'Успешность', + 'Code Changes': 'Изменения кода', + Tool: 'Инструмент', + reqs: 'запр.', + in: 'вх.', + out: 'вых.', + 'In/Out': 'Вх/Вых', }; diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 7b10bff81b..fb2c4fe73e 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -451,8 +451,6 @@ export default { 'Auto Edit': '自動編輯', YOLO: 'YOLO', 'toggle vim mode on/off': '切換 vim 模式開關', - 'check session stats. Usage: /stats [model|tools]': - '檢查會話統計信息。用法:/stats [model|tools]', 'Show model-specific usage statistics.': '顯示模型相關的使用統計信息', 'Show tool-specific usage statistics.': '顯示工具相關的使用統計信息', 'exit the cli': '退出命令行界面', @@ -762,12 +760,10 @@ export default { '請提供指令。用法:/fork <指令>', 'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.': '回應或工具呼叫正在進行時無法分支。請等待其完成或處理待確認的工具呼叫。', - 'Cannot fork before the first conversation turn.': - '首次對話輪次前無法分支。', + 'Cannot fork before the first conversation turn.': '首次對話輪次前無法分支。', 'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.': '/fork 命令需要啟用 fork 功能開關。設定 QWEN_CODE_ENABLE_FORK_SUBAGENT=1 以啟用。', - 'The agent tool is unavailable; cannot fork.': - 'Agent 工具不可用;無法分支。', + 'The agent tool is unavailable; cannot fork.': 'Agent 工具不可用;無法分支。', 'Failed to launch fork: {{error}}': '啟動分支失敗:{{error}}', 'the background agent could not be started.': '背景智能體無法啟動。', 'User launched a background fork via /fork: {{directive}}': @@ -1066,7 +1062,7 @@ export default { 'Time remaining:': '剩餘時間:', 'Qwen OAuth Authentication Timeout': 'Qwen OAuth 認證超時', 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': - 'OAuth 令牌已過期(超過 {{seconds}} 秒)。請重新選擇認證方法', + 'OAuth token 已過期(超過 {{seconds}} 秒)。請重新選擇認證方法', 'Press any key to return to authentication type selection.': '按任意鍵返回認證類型選擇', 'Waiting for Qwen OAuth authentication...': '正在等待 Qwen OAuth 認證...', @@ -1265,22 +1261,41 @@ export default { 'Tool Time:': '工具時間:', 'Session Stats': '會話統計', 'Model Usage': '模型使用情況', - Reqs: '請求數', 'Input Tokens': '輸入 token 數', 'Output Tokens': '輸出 token 數', 'Savings Highlight:': '節省亮點:', 'of input tokens were served from the cache, reducing costs.': '從緩存載入 token ,降低了成本', 'Tip: For a full token breakdown, run `/stats model`.': - '提示:要查看完整的令牌明細,請運行 `/stats model`', + '提示:要查看完整的 token 明細,請運行 `/stats model`', 'Model Stats For Nerds': '模型統計(技術細節)', 'Tool Stats For Nerds': '工具統計(技術細節)', Metric: '指標', API: 'API', + Session: '會話', + Activity: '概覽', + Efficiency: '性能', + Success: '成功率', + Today: '今天', + 'Token Trend': 'Token 趨勢', + 'Cache Hit Rate': '緩存命中率', + 'Tool Success': '工具成功率', + 'Tool Leaderboard': '工具排行', + Calls: '調用次數', + Time: '耗時', + Reqs: '請求', + Cache: '緩存', + Latency: '延遲', + 'In/Out': '輸入/輸出', + 'Code Impact': '代碼變更', + 'Failed to load stats. Press r to retry.': '載入統計失敗,按 r 重試。', + net: '淨增', + streak: '連續', + best: '最長', Requests: '請求數', Errors: '錯誤數', 'Avg Latency': '平均延遲', - Tokens: '令牌', + Tokens: 'Token', Total: '總計', Prompt: '提示', Cached: '緩存', @@ -1289,7 +1304,6 @@ export default { 'No API calls have been made in this session.': '本次會話中未進行任何 API 調用', 'Tool Name': '工具名稱', - Calls: '調用次數', 'Success Rate': '成功率', 'Avg Duration': '平均耗時', 'User Decision Summary': '用戶決策摘要', @@ -1649,6 +1663,66 @@ export default { "The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.": '排程門控未看到本次記憶整理的時間戳;下一輪記憶整理可能會比平時更早重新觸發。', + // Stats Dashboard — Category 2 (missing from zh-TW) + 'Activity Heatmap': '活動熱力圖', + Less: '少', + More: '多', + Sessions: '會話數', + Duration: '時長', + Projects: '專案統計', + 'Loading stats...': '載入統計...', + '(no data)': '(暫無資料)', + d: '天', + h: '時', + m: '分', + Input: '輸入', + Models: '模型', + 'All time': '所有時間', + 'Last 7 days': '最近 7 天', + 'Last 30 days': '最近 30 天', + 'Show usage statistics dashboard.': '顯示使用統計面板。', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'API 請求', + 'Tool Calls': '工具呼叫', + 'Success rate': '成功率', + 'Code Changes': '程式碼變更', + Tool: '工具', + reqs: '請求', + in: '輸入', + out: '輸出', + + // statsCommand non-interactive output + 'API requests: {{count}}': 'API 請求:{{count}}', + 'Code changes': '程式碼變更', + Cost: '費用', + 'Estimated cost: ${{cost}}': '預估費用:${{cost}}', + 'Files: +{{added}} / -{{removed}} lines': + '檔案:+{{added}} / -{{removed}} 行', + 'N/A': 'N/A', + Name: '名稱', + 'No model usage data yet.': '尚無模型使用資料。', + 'No tool usage data yet.': '尚無工具使用資料。', + 'Prompts: {{count}}': '提示:{{count}}', + 'Session duration: {{duration}}': '會話時長:{{duration}}', + 'Tokens \u2014 prompt: {{prompt}}, output: {{output}}': + 'Token — 輸入:{{prompt}},輸出:{{output}}', + 'Tool calls': '工具呼叫', + 'Tool calls: {{total}} ({{success}} ok, {{fail}} fail)': + '工具呼叫:{{total}}({{success}} 成功,{{fail}} 失敗)', + cached: '快取', + days: '天', + output: '輸出', + prompt: '輸入', + '\u2191 tabs \u00B7 r to cycle dates \u00B7 esc to close': + '\u2191 tab 切換標籤 \u00B7 r 切換時間範圍 \u00B7 esc 關閉', + // === Same-as-English optimization === ' (not in model registry)': '(不在模型註冊表中)', 'start server': '啟動伺服器', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 96454580e4..a8a7a361de 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -492,8 +492,7 @@ export default { 'Auto Edit': '自动编辑', YOLO: 'YOLO', 'toggle vim mode on/off': '切换 vim 模式开关', - 'check session stats. Usage: /stats [model|tools]': - '检查会话统计信息。用法:/stats [model|tools]', + 'Show usage statistics dashboard.': '显示使用统计面板。', 'Show model-specific usage statistics.': '显示模型相关的使用统计信息', 'Show tool-specific usage statistics.': '显示工具相关的使用统计信息', 'exit the cli': '退出命令行界面', @@ -842,12 +841,10 @@ export default { '请提供指令。用法:/fork <指令>', 'Cannot fork while a response or tool call is in progress. Wait for it to finish or resolve the pending tool call.': '响应或工具调用正在进行时无法分支。请等待其完成或处理待确认的工具调用。', - 'Cannot fork before the first conversation turn.': - '首次对话轮次前无法分支。', + 'Cannot fork before the first conversation turn.': '首次对话轮次前无法分支。', 'The /fork command requires the fork feature gate. Set QWEN_CODE_ENABLE_FORK_SUBAGENT=1 to enable it.': '/fork 命令需要启用 fork 功能开关。设置 QWEN_CODE_ENABLE_FORK_SUBAGENT=1 以启用。', - 'The agent tool is unavailable; cannot fork.': - 'Agent 工具不可用;无法分支。', + 'The agent tool is unavailable; cannot fork.': 'Agent 工具不可用;无法分支。', 'Failed to launch fork: {{error}}': '启动分支失败:{{error}}', 'the background agent could not be started.': '后台智能体无法启动。', 'User launched a background fork via /fork: {{directive}}': @@ -1202,7 +1199,7 @@ export default { 'Time remaining:': '剩余时间:', 'Qwen OAuth Authentication Timeout': 'Qwen OAuth 认证超时', 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': - 'OAuth 令牌已过期(超过 {{seconds}} 秒)。请重新选择认证方法', + 'OAuth token 已过期(超过 {{seconds}} 秒)。请重新选择认证方法', 'Press any key to return to authentication type selection.': '按任意键返回认证类型选择', 'Waiting for Qwen OAuth authentication...': '正在等待 Qwen OAuth 认证...', @@ -1425,22 +1422,41 @@ export default { 'Tool Time:': '工具时间:', 'Session Stats': '会话统计', 'Model Usage': '模型使用情况', - Reqs: '请求数', 'Input Tokens': '输入 token 数', 'Output Tokens': '输出 token 数', 'Savings Highlight:': '节省亮点:', 'of input tokens were served from the cache, reducing costs.': '从缓存载入 token ,降低了成本', 'Tip: For a full token breakdown, run `/stats model`.': - '提示:要查看完整的令牌明细,请运行 `/stats model`', + '提示:要查看完整的 token 明细,请运行 `/stats model`', 'Model Stats For Nerds': '模型统计(技术细节)', 'Tool Stats For Nerds': '工具统计(技术细节)', Metric: '指标', API: 'API', + Session: '会话', + Activity: '概览', + Efficiency: '性能', + Success: '成功率', + Today: '今天', + 'Token Trend': 'Token 趋势', + 'Cache Hit Rate': '缓存命中率', + 'Tool Success': '工具成功率', + 'Tool Leaderboard': '工具排行', + Calls: '调用次数', + Time: '耗时', + Reqs: '请求', + Cache: '缓存', + Latency: '延迟', + 'In/Out': '输入/输出', + 'Code Impact': '代码变更', + 'Failed to load stats. Press r to retry.': '加载统计失败,按 r 重试。', + net: '净增', + streak: '连续', + best: '最长', Requests: '请求数', Errors: '错误数', 'Avg Latency': '平均延迟', - Tokens: '令牌', + Tokens: 'Token', Total: '总计', Prompt: '提示', Cached: '缓存', @@ -1449,7 +1465,6 @@ export default { 'No API calls have been made in this session.': '本次会话中未进行任何 API 调用', 'Tool Name': '工具名称', - Calls: '调用次数', 'Success Rate': '成功率', 'Avg Duration': '平均耗时', 'User Decision Summary': '用户决策摘要', @@ -1842,6 +1857,69 @@ export default { "The scheduler gate did not see this dream's timestamp; the next dream cycle may re-fire sooner than usual.": '调度门控未看到本次记忆整理的时间戳;下一轮记忆整理可能会比平时更早重新触发。', + // ============================================================================ + // Stats + // ============================================================================ + + // statsCommand non-interactive output + 'Session duration: {{duration}}': '会话时长:{{duration}}', + 'Prompts: {{count}}': '提示次数:{{count}}', + 'API requests: {{count}}': 'API 请求数:{{count}}', + 'Tokens — prompt: {{prompt}}, output: {{output}}': + 'Tokens — 输入:{{prompt}},输出:{{output}}', + 'Tool calls: {{total}} ({{success}} ok, {{fail}} fail)': + '工具调用:{{total}}({{success}} 成功,{{fail}} 失败)', + 'Files: +{{added}} / -{{removed}} lines': + '文件:+{{added}} / -{{removed}} 行', + prompt: '输入', + output: '输出', + cached: '缓存', + 'Estimated cost: ${{cost}}': '预估费用:${{cost}}', + 'No model usage data yet.': '暂无模型使用数据。', + 'No tool usage data yet.': '暂无工具使用数据。', + + // StatsDialog + Models: '模型', + 'All time': '所有时间', + 'Last 7 days': '最近 7 天', + 'Last 30 days': '最近 30 天', + 'N/A': '无', + Sessions: '会话数', + days: '天', + Input: '输入', + 'Tool calls': '工具调用', + 'Code changes': '代码变更', + Projects: '项目统计', + Name: '名称', + Duration: '时长', + 'Activity Heatmap': '用量热力统计', + 'Loading stats...': '加载统计数据...', + '\u2191 tabs \u00b7 r to cycle dates \u00b7 esc to close': + '\u2191 tab 切换标签 \u00b7 r 切换时间范围 \u00b7 esc 关闭', + Cost: '费用', + Less: '少', + More: '多', + '(no data)': '(无数据)', + d: '天', + h: '时', + m: '分', + + // Stats Dashboard — keyboard hints (not translated) + 'tab \xB7 esc': 'tab \xB7 esc', + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc': + 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc', + 'tab \xB7 r dates \xB7 esc': 'tab \xB7 r dates \xB7 esc', + + // Stats Dashboard — missing labels + 'API Requests': 'API 请求', + 'Tool Calls': '工具调用', + 'Success rate': '成功率', + 'Code Changes': '代码变更', + Tool: '工具', + reqs: '请求', + in: '输入', + out: '输出', + // === Same-as-English optimization === ' (not in model registry)': '(不在模型注册表中)', 'start server': '启动服务器', diff --git a/packages/cli/src/i18n/mustTranslateKeys.ts b/packages/cli/src/i18n/mustTranslateKeys.ts index 0707487ab9..1e8933757b 100644 --- a/packages/cli/src/i18n/mustTranslateKeys.ts +++ b/packages/cli/src/i18n/mustTranslateKeys.ts @@ -96,4 +96,16 @@ export const MUST_TRANSLATE_KEYS = [ 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}', 'Approval mode set to "{{mode}}"', "Set up Qwen Code's status line UI", + 'Activity', + 'Efficiency', + 'Today', + 'Cache Hit Rate', + 'Tool Success', + 'Avg Latency', + 'Tool Leaderboard', + 'Code Impact', + 'streak', + 'best', + 'Token Trend', + 'In/Out', ] as const; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 856396327d..4d347d187b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -195,6 +195,7 @@ import { useSkillsManagerDialog } from './hooks/useSkillsManagerDialog.js'; import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js'; import { useMcpDialog } from './hooks/useMcpDialog.js'; import { useHooksDialog } from './hooks/useHooksDialog.js'; +import { useStatsDialog } from './hooks/useStatsDialog.js'; import { useMemoryDialog } from './hooks/useMemoryDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { buildTerminalNotification } from './hooks/useTerminalNotification.js'; @@ -1093,6 +1094,8 @@ export const AppContainer = (props: AppContainerProps) => { const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog(); const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } = useHooksDialog(); + const { isStatsDialogOpen, openStatsDialog, closeStatsDialog } = + useStatsDialog(); // Ref bridge: the guarded openRewindSelector callback is defined later // (after useDoublePress), but slashCommandActions needs it now. The ref @@ -1140,6 +1143,7 @@ export const AppContainer = (props: AppContainerProps) => { openExtensionsManagerDialog, openMcpDialog, openHooksDialog, + openStatsDialog, openResumeDialog, openRewindSelector: () => openRewindSelectorRef.current(), openDiffDialog, @@ -1169,6 +1173,7 @@ export const AppContainer = (props: AppContainerProps) => { openExtensionsManagerDialog, openMcpDialog, openHooksDialog, + openStatsDialog, openResumeDialog, handleResume, handleBranch, @@ -2337,6 +2342,7 @@ export const AppContainer = (props: AppContainerProps) => { isSkillsManagerDialogOpen || isMcpDialogOpen || isHooksDialogOpen || + isStatsDialogOpen || isApprovalModeDialogOpen || isResumeDialogOpen || isDeleteDialogOpen || @@ -2820,6 +2826,8 @@ export const AppContainer = (props: AppContainerProps) => { closeBackgroundTasksDialog: closeBgTasksDialog, isDiffDialogOpen, closeDiffDialog, + isStatsDialogOpen, + closeStatsDialog, showWorktreeExitDialog, closeWorktreeExitDialog: () => setShowWorktreeExitDialog(false), }); @@ -3360,6 +3368,7 @@ export const AppContainer = (props: AppContainerProps) => { isMcpDialogOpen, // Hooks dialog isHooksDialogOpen, + isStatsDialogOpen, // Feedback dialog isFeedbackDialogOpen, // Per-task token tracking @@ -3488,6 +3497,7 @@ export const AppContainer = (props: AppContainerProps) => { isMcpDialogOpen, // Hooks dialog isHooksDialogOpen, + isStatsDialogOpen, // Feedback dialog isFeedbackDialogOpen, // Per-task token tracking @@ -3566,6 +3576,7 @@ export const AppContainer = (props: AppContainerProps) => { openHooksDialog, // Hooks dialog closeHooksDialog, + closeStatsDialog, // Resume session dialog openResumeDialog, closeResumeDialog, @@ -3647,6 +3658,7 @@ export const AppContainer = (props: AppContainerProps) => { openHooksDialog, // Hooks dialog closeHooksDialog, + closeStatsDialog, // Resume session dialog openResumeDialog, closeResumeDialog, diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 92699e514c..7adbcfda5e 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -17,7 +17,10 @@ vi.mock('@qwen-code/qwen-code-core', async () => { ...actual, uiTelemetryService: { reset: vi.fn(), + getMetrics: vi.fn(() => ({ models: {} })), + getSessionStartTime: vi.fn(() => new Date()), }, + persistSessionUsage: vi.fn(), }; }); @@ -80,6 +83,8 @@ describe('clearCommand', () => { getModel: () => 'test-model', getToolRegistry: () => undefined, getApprovalMode: () => 'default', + getSessionId: () => 'test-session-id', + getProjectRoot: () => '/test/project', getMonitorRegistry: () => ({ getRunning: vi.fn().mockReturnValue([]), abortAll: mockAbortMonitors, @@ -170,6 +175,32 @@ describe('clearCommand', () => { ); }); + it('should persist usage when session has activity before clearing', async () => { + const core = await import('@qwen-code/qwen-code-core'); + ( + core.uiTelemetryService.getMetrics as ReturnType + ).mockReturnValue({ + models: { 'test-model': { api: { totalRequests: 5 } } }, + }); + + await clearCommand.action!(mockContext, ''); + + expect(core.persistSessionUsage).toHaveBeenCalledTimes(1); + }); + + it('should not persist usage when session has no activity', async () => { + const core = await import('@qwen-code/qwen-code-core'); + ( + core.uiTelemetryService.getMetrics as ReturnType + ).mockReturnValue({ + models: {}, + }); + + await clearCommand.action!(mockContext, ''); + + expect(core.persistSessionUsage).not.toHaveBeenCalled(); + }); + it('should handle hook errors gracefully and continue execution', async () => { if (!clearCommand.action) { throw new Error('clearCommand must have an action.'); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index bfedd3e81d..cbbd4cbfc3 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -11,6 +11,7 @@ import { uiTelemetryService, SessionEndReason, ToolNames, + persistSessionUsage, createDebugLogger, } from '@qwen-code/qwen-code-core'; import { @@ -71,6 +72,25 @@ export const clearCommand: SlashCommand = { config.getBackgroundShellRegistry().abortAll(); resetBackgroundStateForSessionSwitch(config); + // Persist current session's usage before resetting metrics + const metrics = uiTelemetryService.getMetrics(); + const hasActivity = Object.values(metrics.models).some( + (m) => m.api.totalRequests > 0, + ); + if (hasActivity) { + try { + persistSessionUsage({ + sessionId: config.getSessionId(), + startTime: context.session.stats.sessionStartTime ?? new Date(), + endTime: new Date(), + project: config.getProjectRoot(), + metrics, + }); + } catch { + // Best-effort — don't block /clear + } + } + const newSessionId = config.startNewSession(); // Reset UI telemetry metrics for the new session diff --git a/packages/cli/src/ui/commands/forkCommand.ts b/packages/cli/src/ui/commands/forkCommand.ts index b2b5e35947..3fb45bf780 100644 --- a/packages/cli/src/ui/commands/forkCommand.ts +++ b/packages/cli/src/ui/commands/forkCommand.ts @@ -175,9 +175,12 @@ export const forkCommand: SlashCommand = { role: 'user', parts: [ { - text: t('User launched a background fork via /fork: {{directive}}', { - directive, - }), + text: t( + 'User launched a background fork via /fork: {{directive}}', + { + directive, + }, + ), }, ], }); diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index d37382729c..0e5295b58f 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -9,7 +9,6 @@ import { statsCommand } from './statsCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; -import { formatDuration } from '../utils/formatters.js'; import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; import type { ModelMetricsCore, ModelMetrics } from '@qwen-code/qwen-code-core'; @@ -34,21 +33,16 @@ describe('statsCommand', () => { mockContext.session.stats.sessionStartTime = startTime; }); - it('should display general session stats when run with no subcommand', () => { + it('should open stats dialog when run with no subcommand in interactive mode', () => { if (!statsCommand.action) throw new Error('Command has no action'); - statsCommand.action(mockContext, ''); + const result = statsCommand.action(mockContext, '') as { + type: string; + dialog: string; + }; - const expectedDuration = formatDuration( - endTime.getTime() - startTime.getTime(), - ); - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - { - type: MessageType.STATS, - duration: expectedDuration, - }, - expect.any(Number), - ); + expect(result).toEqual({ type: 'dialog', dialog: 'stats' }); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); }); it('should display model stats when using the "model" subcommand', () => { @@ -109,7 +103,7 @@ describe('statsCommand', () => { expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); }); - it('should return error if sessionStartTime is not available', async () => { + it('should return info with zero duration if sessionStartTime is not available', async () => { if (!statsCommand.action) throw new Error('Command has no action'); ( @@ -122,10 +116,12 @@ describe('statsCommand', () => { const result = (await statsCommand.action(nonInteractiveContext, '')) as { type: string; messageType: string; + content: string; }; expect(result.type).toBe('message'); - expect(result.messageType).toBe('error'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Session duration: 0s'); }); it('stats model subcommand should return text in non-interactive mode', async () => { diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index e87b5275f1..a7be574660 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { HistoryItemStats } from '../types.js'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; import { type CommandContext, type SlashCommand, type MessageActionReturn, + type OpenDialogActionReturn, CommandKind, } from './types.js'; import { t } from '../../i18n/index.js'; @@ -20,37 +20,19 @@ export const statsCommand: SlashCommand = { name: 'stats', altNames: ['usage'], get description() { - return t('check session stats. Usage: /stats [model|tools]'); + return t('Show usage statistics dashboard.'); }, - argumentHint: '[model|tools]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, - action: (context: CommandContext): MessageActionReturn | void => { - const now = new Date(); - const { sessionStartTime } = context.session.stats; - if (!sessionStartTime) { - if (context.executionMode !== 'interactive') { - return { - type: 'message', - messageType: 'error', - content: t( - 'Session start time is unavailable, cannot calculate stats.', - ), - }; - } - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Session start time is unavailable, cannot calculate stats.'), - }, - Date.now(), - ); - return; - } - const wallDuration = now.getTime() - sessionStartTime.getTime(); - + action: ( + context: CommandContext, + ): OpenDialogActionReturn | MessageActionReturn | void => { if (context.executionMode !== 'interactive') { - const { promptCount, metrics } = context.session.stats; + const now = new Date(); + const { sessionStartTime, promptCount, metrics } = context.session.stats; + const wallDuration = sessionStartTime + ? now.getTime() - sessionStartTime.getTime() + : 0; let totalPromptTokens = 0; let totalCandidateTokens = 0; let totalRequests = 0; @@ -63,22 +45,29 @@ export const statsCommand: SlashCommand = { type: 'message', messageType: 'info', content: [ - `Session duration: ${formatDuration(wallDuration)}`, - `Prompts: ${promptCount}`, - `API requests: ${totalRequests}`, - `Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`, - `Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`, - `Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`, + t('Session duration: {{duration}}', { + duration: formatDuration(wallDuration), + }), + t('Prompts: {{count}}', { count: String(promptCount) }), + t('API requests: {{count}}', { count: String(totalRequests) }), + t('Tokens — prompt: {{prompt}}, output: {{output}}', { + prompt: String(totalPromptTokens), + output: String(totalCandidateTokens), + }), + t('Tool calls: {{total}} ({{success}} ok, {{fail}} fail)', { + total: String(metrics.tools.totalCalls), + success: String(metrics.tools.totalSuccess), + fail: String(metrics.tools.totalFail), + }), + t('Files: +{{added}} / -{{removed}} lines', { + added: String(metrics.files.totalLinesAdded), + removed: String(metrics.files.totalLinesRemoved), + }), ].join('\n'), }; } - const statsItem: HistoryItemStats = { - type: MessageType.STATS, - duration: formatDuration(wallDuration), - }; - - context.ui.addItem(statsItem, Date.now()); + return { type: 'dialog', dialog: 'stats' }; }, subCommands: [ { @@ -97,7 +86,7 @@ export const statsCommand: SlashCommand = { metrics.models, )) { lines.push( - `${modelName}: prompt=${modelMetrics.tokens.prompt}, output=${modelMetrics.tokens.candidates}, cached=${modelMetrics.tokens.cached}`, + `${modelName}: ${t('prompt')}=${modelMetrics.tokens.prompt}, ${t('output')}=${modelMetrics.tokens.candidates}, ${t('cached')}=${modelMetrics.tokens.cached}`, ); const cost = calculateCost({ inputTokens: modelMetrics.tokens.prompt, @@ -106,11 +95,13 @@ export const statsCommand: SlashCommand = { pricing: pricing?.[modelName], }); if (cost != null) { - lines.push(` Estimated cost: $${cost.toFixed(4)}`); + lines.push( + ` ${t('Estimated cost: ${{cost}}', { cost: cost.toFixed(4) })}`, + ); } } if (lines.length === 0) { - lines.push('No model usage data yet.'); + lines.push(t('No model usage data yet.')); } return { type: 'message', @@ -141,10 +132,14 @@ export const statsCommand: SlashCommand = { const content = toolNames.length > 0 ? [ - `Tool calls: ${tools.totalCalls} total (${tools.totalSuccess} ok, ${tools.totalFail} fail)`, + t('Tool calls: {{total}} ({{success}} ok, {{fail}} fail)', { + total: String(tools.totalCalls), + success: String(tools.totalSuccess), + fail: String(tools.totalFail), + }), ...toolNames.map((name) => ` ${name}`), ].join('\n') - : 'No tool usage data yet.'; + : t('No tool usage data yet.'); return { type: 'message', messageType: 'info', content }; } context.ui.addItem( diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2b6ad336ce..31d08fc760 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -188,7 +188,8 @@ export interface OpenDialogActionReturn { | 'hooks' | 'mcp' | 'rewind' - | 'diff'; + | 'diff' + | 'stats'; } /** diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index b2a2390245..df15b564a8 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -19,11 +19,9 @@ * and AgentComposer (with minimal customization). */ -import type { ReactNode } from 'react'; -import { useCallback, useContext, useEffect, useRef } from 'react'; +import type React from 'react'; +import { useCallback } from 'react'; import { Box, Text } from 'ink'; -import { addLayoutListener, type DOMElement } from 'ink/dom'; -import CursorContext from 'ink/components/CursorContext'; import chalk from 'chalk'; import type { TextBuffer } from './shared/text-buffer.js'; import type { Key } from '../hooks/useKeypress.js'; @@ -69,9 +67,7 @@ export interface BaseTextInputProps { /** Placeholder text shown when the buffer is empty. */ placeholder?: string; /** Custom prefix node (defaults to `> `). */ - prefix?: ReactNode; - /** Width of the prefix in terminal columns. Defaults to 2 (for "> "). */ - prefixWidth?: number; + prefix?: React.ReactNode; /** Border color for the input box. */ borderColor?: string; /** Label rendered on the top border line (right-aligned). Plain string for width calculation. */ @@ -82,7 +78,7 @@ export interface BaseTextInputProps { * Custom line renderer for advanced rendering (e.g. syntax highlighting). * When not provided, lines are rendered as plain text with cursor overlay. */ - renderLine?: (opts: RenderLineOptions) => ReactNode; + renderLine?: (opts: RenderLineOptions) => React.ReactNode; } // ─── Default line renderer ────────────────────────────────── @@ -96,7 +92,7 @@ export function defaultRenderLine({ isOnCursorLine, cursorCol, showCursor, -}: RenderLineOptions): ReactNode { +}: RenderLineOptions): React.ReactNode { if (!isOnCursorLine || !showCursor) { return {lineText || ' '}; } @@ -126,34 +122,20 @@ export function defaultRenderLine({ ); } -// ─── Helpers ──────────────────────────────────────────────── - -// Walk up Ink's internal DOM tree to find the root node (ink-root). -// addLayoutListener requires the root node specifically. -function findRootNode( - node: (Record & { parentNode?: unknown }) | null, -): DOMElement | undefined { - if (!node) return undefined; - if (!node.parentNode) - return node['nodeName'] === 'ink-root' ? (node as DOMElement) : undefined; - return findRootNode(node.parentNode as Record); -} - // ─── Component ────────────────────────────────────────────── -export const BaseTextInput = ({ +export const BaseTextInput: React.FC = ({ buffer, onSubmit, onKeypress, showCursor = true, placeholder, prefix, - prefixWidth = 2, borderColor, topRightLabel, isActive = true, renderLine = defaultRenderLine, -}: BaseTextInputProps): ReactNode => { +}) => { // ── Keyboard handling ── const handleKey = useCallback( @@ -267,81 +249,6 @@ export const BaseTextInput = ({ const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor; const scrollVisualRow = buffer.visualScrollRow; - // ── Physical cursor positioning for IME ── - // addLayoutListener fires in resetAfterCommit AFTER calculateLayout() - // but BEFORE onRender() — yoga layout is fresh, terminal not yet written. - // addLayoutListener requires the root node (ink-root), not the component - // node. We find it by walking up the Ink DOM parent chain. - const rootRef = useRef(null); - const cursorCtx = useContext(CursorContext); - - // Use a ref to hold mutable state so the layout listener callback - // always reads the latest values without needing to resubscribe. - const stateRef = useRef({ - showCursor, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - linesToRender, - prefixWidth, - }); - stateRef.current = { - showCursor, - cursorVisualRow, - cursorVisualCol, - scrollVisualRow, - linesToRender, - prefixWidth, - }; - - useEffect(() => { - const rootNode = findRootNode(rootRef.current); - if (!rootNode) return; - const unsub = addLayoutListener(rootNode, () => { - const { - showCursor: sc, - cursorVisualRow: vr, - cursorVisualCol: vc, - scrollVisualRow: sr, - linesToRender: lt, - prefixWidth: pw, - } = stateRef.current; - if (!sc) { - cursorCtx.setCursorPosition(undefined); - return; - } - const node = rootRef.current; - if (!node) return; - let absTop = 0; - let absLeft = 0; - let n: unknown = node; - while (n) { - const nd = n as { - yogaNode?: { getComputedLayout(): { top: number; left: number } }; - parentNode?: unknown; - }; - const layout = nd.yogaNode?.getComputedLayout(); - if (layout) { - absTop += layout.top; - absLeft += layout.left; - } - n = nd.parentNode; - } - const relativeRow = vr - sr; - const lineText = lt[relativeRow] || ''; - const textBeforeCursor = cpSlice(lineText, 0, vc); - const physicalCol = stringWidth(textBeforeCursor); - cursorCtx.setCursorPosition({ - x: absLeft + pw + physicalCol, - y: absTop + relativeRow + 1, - }); - }); - return () => { - unsub(); - cursorCtx.setCursorPosition(undefined); - }; - }, [cursorCtx]); - const resolvedBorderColor = borderColor ?? theme.border.focused; const resolvedPrefix = prefix ?? ( {'> '} @@ -357,7 +264,7 @@ export const BaseTextInput = ({ : '─'.repeat(columns); return ( - + {topBorderLine} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 26f1f135aa..7a75090a08 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -47,6 +47,7 @@ import { SkillsManagerDialog } from './skills/SkillsManagerDialog.js'; import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js'; import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { HooksManagementDialog } from './hooks/HooksManagementDialog.js'; +import { StatsDialog } from './StatsDialog.js'; import { SessionPicker } from './SessionPicker.js'; import { RewindSelector } from './RewindSelector.js'; import { DiffDialog } from './DiffDialog.js'; @@ -456,6 +457,11 @@ export const DialogManager = ({ if (uiState.isHooksDialogOpen) { return ; } + if (uiState.isStatsDialogOpen) { + return ( + + ); + } if (uiState.isMcpDialogOpen) { return ; } diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index e1be80b175..50448dc638 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -1629,15 +1629,6 @@ export const InputPrompt: React.FC = ({ ); - // Calculate prefix width for physical cursor positioning - const prefixWidth = shellModeActive - ? reverseSearchActive - ? 6 // "(r:) " (inner) + " " (outer) = 6 cols - : 2 // "! " = 2 chars - : commandSearchActive - ? 6 // "(r:) " (inner) + " " (outer) = 6 cols - : 2; // "> " or "* " = 2 chars - return ( <> {attachments.length > 0 && ( @@ -1668,7 +1659,6 @@ export const InputPrompt: React.FC = ({ : placeholder } prefix={prefixNode} - prefixWidth={prefixWidth} borderColor={borderColor} topRightLabel={uiState.sessionName || undefined} isActive={!isEmbeddedShellFocused} diff --git a/packages/cli/src/ui/components/StatsActivityTab.tsx b/packages/cli/src/ui/components/StatsActivityTab.tsx new file mode 100644 index 0000000000..2196e102c9 --- /dev/null +++ b/packages/cli/src/ui/components/StatsActivityTab.tsx @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { + buildBrailleLineChart, + MONTH_LABELS, + type LineChartPoint, +} from '../utils/asciiCharts.js'; +import { fmtTokens, fmtDurationShort, TableRow } from './stats-helpers.js'; +import { HeatmapView } from './StatsHeatmapView.js'; +import type { StatsData } from '../utils/statsDataService.js'; +import type { TimeRange } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; + +export const ActivityTab: React.FC<{ + data: StatsData; + bodyWidth: number; + chartMonthOffset: number; + range: TimeRange; +}> = ({ data, bodyWidth, chartMonthOffset, range }) => { + const heatmapWeeks = Math.min( + 26, + Math.max(8, Math.floor((bodyWidth - 4) / 2)), + ); + const col1Width = Math.floor(bodyWidth / 3); + + let totalTokens = 0; + for (const m of Object.values(data.report.models)) { + totalTokens += m.totalTokens; + } + + const dailyTotals = new Map(); + for (const d of data.tokensPerDay) { + dailyTotals.set(d.date, (dailyTotals.get(d.date) || 0) + d.tokens); + } + const allDates = [...dailyTotals.keys()].sort(); + const availableMonths = [...new Set(allDates.map((d) => d.slice(0, 7)))] + .sort() + .reverse(); + const clampedOffset = Math.min( + chartMonthOffset, + Math.max(0, availableMonths.length - 1), + ); + const chartMonth = + range === 'all' && availableMonths.length > 0 + ? availableMonths[clampedOffset]! + : null; + const chartMonthLabel = chartMonth + ? `${MONTH_LABELS[Number(chartMonth.slice(5, 7)) - 1]} ${chartMonth.slice(0, 4)}` + : null; + const canGoLeft = clampedOffset < availableMonths.length - 1; + const canGoRight = clampedOffset > 0; + const filteredTokens = chartMonth + ? data.tokensPerDay.filter((d) => d.date.startsWith(chartMonth)) + : data.tokensPerDay; + + const filteredDailyTotals = new Map(); + for (const d of filteredTokens) { + filteredDailyTotals.set( + d.date, + (filteredDailyTotals.get(d.date) || 0) + d.tokens, + ); + } + + const lineData: LineChartPoint[] = [...filteredDailyTotals.entries()] + .map(([date, value]) => ({ date, value })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const lineChart = buildBrailleLineChart(lineData, bodyWidth - 8, 8); + + return ( + + {/* KPI Row */} + + + {t('Sessions')} + + {data.report.sessionCount} + + {data.delta?.sessions != null && ( + = 0 + ? theme.status.success + : theme.status.error + } + > + {' '} + {data.delta.sessions >= 0 ? '\u25B2' : '\u25BC'} + {Math.abs(data.delta.sessions).toFixed(0)}% + + )} + + + {t('Duration')} + + {fmtDurationShort(data.report.totalDurationMs)} + + {data.delta?.duration != null && ( + = 0 + ? theme.status.success + : theme.status.error + } + > + {' '} + {data.delta.duration >= 0 ? '\u25B2' : '\u25BC'} + {Math.abs(data.delta.duration).toFixed(0)}% + + )} + + + {t('Tokens')} + + {fmtTokens(totalTokens)} + + {data.delta?.tokens != null && ( + = 0 + ? theme.status.success + : theme.status.error + } + > + {' '} + {data.delta.tokens >= 0 ? '\u25B2' : '\u25BC'} + {Math.abs(data.delta.tokens).toFixed(0)}% + + )} + + + + {/* Heatmap with streak */} + + + + + + + {t('streak')}: + + {data.currentStreak} + {t('d')} + + + + {t('best')}: + + {data.longestStreak} + {t('d')} + + + + + + {/* Token Trend Chart */} + + + + {t('Token Trend')} + + {chartMonthLabel && ( + + {' '} + {canGoLeft ? '\u2190 ' : ' '} + {chartMonthLabel} + {canGoRight ? ' \u2192' : ''} + + )} + + {lineChart ? ( + + {lineChart.rows.map((row, ri) => ( + + + {lineChart.yLabels[ri]?.padStart(6) ?? ' '} + {'\u2502'} + + {row.map((cell, ci) => ( + + {cell.char} + + ))} + + ))} + + + {' \u2514'} + {lineChart.xLabels} + + + + + {' '}peak {fmtTokens(lineChart.peak)} + + + + ) : ( + + {' '} + {t('(no data)')} + + )} + + + {/* Project Ranking */} + {data.report.projects.length > 0 && ( + + + {t('Projects')} + + + {data.report.projects.slice(0, 5).map((proj) => { + const name = proj.path.split('/').pop() || proj.path; + const tokens = proj.totalTokens; + return ( + + ); + })} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/StatsDialog.tsx b/packages/cli/src/ui/components/StatsDialog.tsx new file mode 100644 index 0000000000..96e6b1e2f6 --- /dev/null +++ b/packages/cli/src/ui/components/StatsDialog.tsx @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { useState, useEffect, useCallback } from 'react'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { loadStatsData, type StatsData } from '../utils/statsDataService.js'; +import { + metricsToUsageRecord, + type TimeRange, +} from '@qwen-code/qwen-code-core'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { t } from '../../i18n/index.js'; +import { + type StatsTab, + TAB_DEFS, + RANGE_CYCLE, + getRangeLabel, +} from './stats-helpers.js'; +import { SessionTab } from './StatsSessionTab.js'; +import { ActivityTab } from './StatsActivityTab.js'; +import { EfficiencyTab } from './StatsEfficiencyTab.js'; + +const StatsTabs: React.FC<{ activeTab: StatsTab }> = ({ activeTab }) => ( + + {TAB_DEFS.map(({ tab, label }) => { + const active = tab === activeTab; + return ( + + + {` ${label()} `} + + + ); + })} + +); + +const RangeIndicator: React.FC<{ range: TimeRange }> = ({ range }) => ( + + {RANGE_CYCLE.map((r, i) => ( + + + {getRangeLabel(r)} + + {i < RANGE_CYCLE.length - 1 && ( + · + )} + + ))} + +); + +function buildCurrentSessionRecord( + sessionId: string, + startTime: Date, + project: string, + metrics: import('@qwen-code/qwen-code-core').SessionMetrics, +) { + const hasActivity = Object.values(metrics.models).some( + (m) => m.api.totalRequests > 0, + ); + if (!hasActivity) return undefined; + return metricsToUsageRecord( + sessionId, + project, + startTime.getTime(), + Date.now(), + metrics, + ); +} + +interface StatsDialogProps { + onClose: () => void; + width?: number; +} + +export const StatsDialog: React.FC = ({ onClose, width }) => { + const [activeTab, setActiveTab] = useState('session'); + const [rangeIndex, setRangeIndex] = useState(0); + const [chartMonthOffset, setChartMonthOffset] = useState(0); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const { stats } = useSessionStats(); + const config = useConfig(); + + const range = RANGE_CYCLE[rangeIndex]!; + const safeWidth = Math.max(72, width ?? 100); + const bodyWidth = safeWidth - 6; + + useEffect(() => { + let stale = false; + setLoading(true); + const liveRecord = buildCurrentSessionRecord( + stats.sessionId, + stats.sessionStartTime, + config.getProjectRoot(), + stats.metrics, + ); + loadStatsData(range, liveRecord) + .then((d) => { + if (!stale) { + setData(d); + setError(false); + setLoading(false); + } + }) + .catch(() => { + if (!stale) { + setError(true); + setLoading(false); + } + }); + return () => { + stale = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- only reload on range/session change, not every metrics tick + }, [range, stats.sessionId]); + + const handleTabChange = useCallback( + (direction: 1 | -1) => { + const idx = TAB_DEFS.findIndex((td) => td.tab === activeTab); + const next = (idx + direction + TAB_DEFS.length) % TAB_DEFS.length; + setActiveTab(TAB_DEFS[next]!.tab); + }, + [activeTab], + ); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onClose(); + return; + } + if (key.name === 'tab') { + handleTabChange(key.shift ? -1 : 1); + return; + } + if (key.name === 'r') { + setRangeIndex((i) => (i + 1) % RANGE_CYCLE.length); + return; + } + if ( + (key.name === 'left' || key.name === 'h') && + activeTab === 'activity' && + range === 'all' && + data + ) { + const months = [ + ...new Set(data.tokensPerDay.map((d) => d.date.slice(0, 7))), + ]; + const maxOffset = Math.max(0, months.length - 1); + setChartMonthOffset((o) => Math.min(maxOffset, o + 1)); + return; + } + if ( + (key.name === 'right' || key.name === 'l') && + activeTab === 'activity' && + range === 'all' + ) { + setChartMonthOffset((o) => Math.max(0, o - 1)); + return; + } + }, + { isActive: true }, + ); + + const hintText = + activeTab === 'session' + ? 'tab \xB7 esc' + : activeTab === 'activity' && range === 'all' + ? 'tab \xB7 r dates \xB7 \u2190\u2192 month \xB7 esc' + : 'tab \xB7 r dates \xB7 esc'; + + return ( + + + + + + + {activeTab === 'session' && } + {activeTab !== 'session' && loading && ( + {t('Loading stats...')} + )} + {activeTab !== 'session' && !loading && error && ( + + {t('Failed to load stats. Press r to retry.')} + + )} + {activeTab === 'activity' && !loading && data && ( + + )} + {activeTab === 'efficiency' && !loading && data && ( + + )} + + + {activeTab !== 'session' && } + + + + {hintText} + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/StatsEfficiencyTab.tsx b/packages/cli/src/ui/components/StatsEfficiencyTab.tsx new file mode 100644 index 0000000000..6d95f40367 --- /dev/null +++ b/packages/cli/src/ui/components/StatsEfficiencyTab.tsx @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { + fmtTokens, + fmtSuccessBar, + getSuccessColor, + getCacheColor, + TableRow, + getSeriesColors, +} from './stats-helpers.js'; +import type { StatsData } from '../utils/statsDataService.js'; +import { t } from '../../i18n/index.js'; + +export const EfficiencyTab: React.FC<{ + data: StatsData; + bodyWidth: number; +}> = ({ data, bodyWidth }) => { + const SERIES_COLORS = getSeriesColors(); + const cardWidth = Math.floor((bodyWidth - 4) / 3); + const modelEntries = Object.entries(data.report.models).sort( + (a, b) => b[1].totalTokens - a[1].totalTokens, + ); + + return ( + + {/* Performance Cards Row */} + + + {t('Cache Hit Rate')} + + {data.efficiency.cacheHitRate.toFixed(1)}% + + {data.delta?.cacheRate != null && ( + = 0 + ? theme.status.success + : theme.status.error + } + > + {data.delta.cacheRate >= 0 ? '\u25B2' : '\u25BC'}{' '} + {Math.abs(data.delta.cacheRate).toFixed(1)}% + + )} + + + {t('Tool Success')} + + {data.efficiency.toolSuccessRate.toFixed(1)}% + + {data.delta?.toolSuccess != null && ( + = 0 + ? theme.status.success + : theme.status.error + } + > + {data.delta.toolSuccess >= 0 ? '\u25B2' : '\u25BC'}{' '} + {Math.abs(data.delta.toolSuccess).toFixed(1)}% + + )} + + + {t('Avg Latency')} + + {data.efficiency.avgLatencyMs != null + ? `${(data.efficiency.avgLatencyMs / 1000).toFixed(1)}s` + : '\u2014'} + + {data.delta?.avgLatency != null && ( + + {data.delta.avgLatency <= 0 ? '\u25BC' : '\u25B2'}{' '} + {Math.abs(data.delta.avgLatency / 1000).toFixed(1)}s + + )} + + + + {/* Tool Leaderboard */} + {data.toolLeaderboard.length > 0 && ( + + + {t('Tool Leaderboard')} + + + {data.toolLeaderboard.map((tool) => ( + + ))} + + )} + + {/* Model Comparison Table */} + {modelEntries.length > 0 && ( + + + {t('Models')} + + + {modelEntries.map(([name, m], i) => { + const cacheRate = + m.inputTokens > 0 ? (m.cachedTokens / m.inputTokens) * 100 : 0; + const latency = + m.totalLatencyMs > 0 && m.requests > 0 + ? `${(m.totalLatencyMs / m.requests / 1000).toFixed(1)}s` + : '\u2014'; + return ( + + ); + })} + + )} + + {/* Code Impact */} + {(data.report.files.linesAdded > 0 || + data.report.files.linesRemoved > 0) && ( + + + {t('Code Impact')}{' '} + + + +{data.report.files.linesAdded.toLocaleString()} + + / + + -{data.report.files.linesRemoved.toLocaleString()} + + {t('net')}: + = 0 + ? theme.status.success + : theme.status.error + } + > + {data.report.files.linesAdded - data.report.files.linesRemoved >= 0 + ? '+' + : ''} + {( + data.report.files.linesAdded - data.report.files.linesRemoved + ).toLocaleString()} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/StatsHeatmapView.tsx b/packages/cli/src/ui/components/StatsHeatmapView.tsx new file mode 100644 index 0000000000..3e33199de5 --- /dev/null +++ b/packages/cli/src/ui/components/StatsHeatmapView.tsx @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { buildHeatmapData, MONTH_LABELS } from '../utils/asciiCharts.js'; +import { getHeatmapColors } from './stats-helpers.js'; +import type { StatsData } from '../utils/statsDataService.js'; +import { t } from '../../i18n/index.js'; + +export const HeatmapView: React.FC<{ + data: StatsData; + weeks: number; + monthOffset: number; +}> = ({ data, weeks, monthOffset }) => { + const HEATMAP_COLORS = getHeatmapColors(); + const heatmap = buildHeatmapData(data.heatmap, weeks, monthOffset); + + const fmtDate = (d: string) => { + const dt = new Date(d + 'T00:00:00'); + return `${MONTH_LABELS[dt.getMonth()]} ${dt.getDate()}, ${dt.getFullYear()}`; + }; + + return ( + + + + {t('Activity Heatmap')} + + + {' '} + {fmtDate(heatmap.startDate)} - {fmtDate(heatmap.endDate)} + + + + {' '} + {(() => { + const labelAt = new Map(); + for (const cl of heatmap.colLabels) labelAt.set(cl.col, cl.text); + + const out: React.ReactNode[] = []; + let skipCols = 0; + for (let c = 0; c < heatmap.totalCols; c++) { + if (skipCols > 0) { + skipCols--; + continue; + } + const label = labelAt.get(c); + if (label && label.length > 2) { + out.push( + + {label.padEnd(4)} + , + ); + skipCols = 1; + } else if (label) { + out.push( + + {label.padEnd(2)} + , + ); + } else { + out.push({' '}); + } + } + return out; + })()} + + {heatmap.rows.map((row, ri) => ( + + {row.label} + {row.cells.map((cell, ci) => ( + 0 ? HEATMAP_COLORS[cell.intensity] : undefined + } + underline={cell.isToday} + > + {cell.char} + + ))} + + ))} + + + + {' '} + {t('Less')}{' '} + + {([0, 1, 2, 3, 4] as const).map((level) => ( + 0 ? HEATMAP_COLORS[level] : undefined} + > + {level === 0 ? '\u00B7\u00B7' : ' '} + + ))} + {t('More')} + + + ); +}; diff --git a/packages/cli/src/ui/components/StatsSessionTab.tsx b/packages/cli/src/ui/components/StatsSessionTab.tsx new file mode 100644 index 0000000000..76eef3f681 --- /dev/null +++ b/packages/cli/src/ui/components/StatsSessionTab.tsx @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { fmtTokens, getSeriesColors } from './stats-helpers.js'; +import { useSessionStats } from '../contexts/SessionContext.js'; +import { computeSessionStats } from '../utils/computeStats.js'; +import { formatDuration } from '../utils/formatters.js'; +import { + getStatusColor, + TOOL_SUCCESS_RATE_HIGH, + TOOL_SUCCESS_RATE_MEDIUM, + USER_AGREEMENT_RATE_HIGH, + USER_AGREEMENT_RATE_MEDIUM, +} from '../utils/displayUtils.js'; +import { t } from '../../i18n/index.js'; + +export const SessionTab: React.FC = () => { + const SERIES_COLORS = getSeriesColors(); + const { stats } = useSessionStats(); + const { metrics } = stats; + const computed = computeSessionStats(metrics); + const now = new Date(); + const wallDuration = stats.sessionStartTime + ? now.getTime() - stats.sessionStartTime.getTime() + : 0; + + let totalInput = 0; + let totalOutput = 0; + let totalCached = 0; + for (const m of Object.values(metrics.models)) { + totalInput += m.tokens.prompt; + totalOutput += m.tokens.candidates; + totalCached += m.tokens.cached; + } + const cacheRate = totalInput > 0 ? (totalCached / totalInput) * 100 : 0; + + const successColor = getStatusColor(computed.successRate, { + green: TOOL_SUCCESS_RATE_HIGH, + yellow: TOOL_SUCCESS_RATE_MEDIUM, + }); + const agreementColor = getStatusColor(computed.agreementRate, { + green: USER_AGREEMENT_RATE_HIGH, + yellow: USER_AGREEMENT_RATE_MEDIUM, + }); + + const labelWidth = 28; + + return ( + + {/* Session ID */} + + + {t('Session ID:')} + + {stats.sessionId} + + + {/* Interaction Summary */} + + + {t('Interaction Summary')} + + + + {t('Tool Calls:')} + + + {metrics.tools.totalCalls} ({' '} + + ✓ {metrics.tools.totalSuccess} + {' '} + ✗ {metrics.tools.totalFail}{' '} + ) + + + + + {t('Success Rate:')} + + {computed.successRate.toFixed(1)}% + + {computed.totalDecisions > 0 && ( + + + {t('User Agreement:')} + + + {computed.agreementRate.toFixed(1)}%{' '} + + ({computed.totalDecisions} {t('reviewed')}) + + + + )} + {(metrics.files.totalLinesAdded > 0 || + metrics.files.totalLinesRemoved > 0) && ( + + + {t('Code Changes:')} + + + +{metrics.files.totalLinesAdded} + + + + -{metrics.files.totalLinesRemoved} + + + )} + + + {/* Performance */} + + + {t('Performance')} + + + + {t('Wall Time:')} + + {formatDuration(wallDuration)} + + + + {t('Agent Active:')} + + + {formatDuration(computed.agentActiveTime)} + + + + + » {t('API Time:')} + + + {formatDuration(computed.totalApiTime)}{' '} + + ({computed.apiTimePercent.toFixed(1)}%) + + + + + + » {t('Tool Time:')} + + + {formatDuration(computed.totalToolTime)}{' '} + + ({computed.toolTimePercent.toFixed(1)}%) + + + + + + {/* Token Summary */} + + + {t('Tokens')} + + + + {t('Input')}: + + + {totalInput.toLocaleString()} + + + + + {t('Output')}: + + + {totalOutput.toLocaleString()} + + + {totalCached > 0 && ( + + + {t('Cached')}: + + + {totalCached.toLocaleString()} ({cacheRate.toFixed(1)}%) + + + )} + + + {/* Models */} + {Object.keys(metrics.models).length > 0 && ( + + + {t('Models')} + + {Object.entries(metrics.models).map(([name, m], i) => ( + + + {name} + + {m.api.totalRequests} {t('reqs')} · {t('in')}= + {fmtTokens(m.tokens.prompt)} · {t('out')}= + {fmtTokens(m.tokens.candidates)} + + + ))} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index 8940c23be2..a2f6ac0536 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -257,7 +257,6 @@ export const AgentComposer: React.FC = ({ agentId }) => { {approvalModePromptStyle.prefix}{' '} ); - const prefixWidth = 2; // "> " or "* " = 2 chars return ( @@ -290,7 +289,6 @@ export const AgentComposer: React.FC = ({ agentId }) => { showCursor={isInputActive && !agentTabBarFocused} placeholder={' ' + t('Send a message to this agent')} prefix={prefixNode} - prefixWidth={prefixWidth} borderColor={inputBorderColor} isActive={isInputActive && !agentShellFocused} /> diff --git a/packages/cli/src/ui/components/stats-helpers.tsx b/packages/cli/src/ui/components/stats-helpers.tsx new file mode 100644 index 0000000000..91212142fe --- /dev/null +++ b/packages/cli/src/ui/components/stats-helpers.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { t } from '../../i18n/index.js'; +import type { HeatmapIntensity } from '../utils/asciiCharts.js'; +import type { TimeRange } from '@qwen-code/qwen-code-core'; + +export type StatsTab = 'session' | 'activity' | 'efficiency'; + +export const TAB_DEFS: Array<{ tab: StatsTab; label: () => string }> = [ + { tab: 'session', label: () => t('Session') }, + { tab: 'activity', label: () => t('Activity') }, + { tab: 'efficiency', label: () => t('Efficiency') }, +]; + +export const RANGE_CYCLE: TimeRange[] = ['all', 'month', 'week', 'today']; + +export function getHeatmapColors(): Record { + return { + 0: '#161b22', + 1: '#0e4429', + 2: '#006d32', + 3: '#26a641', + 4: '#39d353', + }; +} + +export function getSeriesColors(): string[] { + return [ + theme.text.link, + theme.status.error, + theme.text.accent, + theme.status.success, + theme.status.warning, + theme.text.code, + ]; +} + +export function getRangeLabel(range: string): string { + const labels: Record = { + today: t('Today'), + all: t('All time'), + week: t('Last 7 days'), + month: t('Last 30 days'), + }; + return labels[range] ?? range; +} + +export function fmtTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return `${n}`; +} + +export function fmtDurationShort(ms: number): string { + const totalMinutes = Math.floor(ms / 60_000); + const days = Math.floor(totalMinutes / 1440); + const hours = Math.floor((totalMinutes % 1440) / 60); + const minutes = totalMinutes % 60; + const d = t('d'); + const h = t('h'); + const m = t('m'); + if (days > 0) return `${days}${d} ${hours}${h} ${minutes}${m}`; + if (hours > 0) return `${hours}${h} ${minutes}${m}`; + return `${minutes}${m}`; +} + +export function fmtSuccessBar(rate: number): string { + const filled = Math.max(0, Math.min(10, Math.round(rate / 10))); + return '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled); +} + +export function getSuccessColor(rate: number): string { + if (rate >= 95) return theme.status.success; + if (rate >= 80) return theme.status.warning; + return theme.status.error; +} + +export function getCacheColor(rate: number): string { + if (rate >= 85) return theme.status.success; + if (rate >= 70) return theme.status.warning; + return theme.status.error; +} + +export const TableRow: React.FC<{ + cells: Array<{ text: string; width: number; color?: string; bold?: boolean }>; +}> = ({ cells }) => ( + + {cells.map((cell, i) => ( + + + {cell.text} + + + ))} + +); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 0ff206620e..6df598ae67 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -94,6 +94,7 @@ export interface UIActions { openHooksDialog: () => void; // Hooks dialog closeHooksDialog: () => void; + closeStatsDialog: () => void; // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index b277ba5846..81721594f6 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -167,6 +167,7 @@ export interface UIState { isMcpDialogOpen: boolean; // Hooks dialog isHooksDialogOpen: boolean; + isStatsDialogOpen: boolean; // Feedback dialog isFeedbackDialogOpen: boolean; // Per-task token tracking diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 3825f55c76..52b11ebeb8 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -121,6 +121,7 @@ export interface SlashCommandProcessorActions { openExtensionsManagerDialog: () => void; openMcpDialog: () => void; openHooksDialog: () => void; + openStatsDialog: () => void; openRewindSelector: () => void; openDiffDialog: () => void; openHelpDialog: () => void; @@ -756,6 +757,9 @@ export const useSlashCommandProcessor = ( case 'hooks': actions.openHooksDialog(); return { type: 'handled' }; + case 'stats': + actions.openStatsDialog(); + return { type: 'handled' }; case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index 93d880be28..42d76506bf 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -65,6 +65,9 @@ export interface DialogCloseOptions { isDiffDialogOpen?: boolean; closeDiffDialog?: () => void; + isStatsDialogOpen?: boolean; + closeStatsDialog?: () => void; + // Worktree exit dialog (Phase C) showWorktreeExitDialog?: boolean; closeWorktreeExitDialog?: () => void; @@ -144,6 +147,11 @@ export function useDialogClose(options: DialogCloseOptions) { // priority dialogs in `DialogManager` (theme, auth, settings, …) // already appear above this block in their own priority order. Only // the diff-vs-background pair previously matched the wrong way. + if (options.isStatsDialogOpen && options.closeStatsDialog) { + options.closeStatsDialog(); + return true; + } + if (options.isDiffDialogOpen && options.closeDiffDialog) { // /diff dialog — same rationale as the background-tasks dialog: // Ctrl+C should dismiss the dialog rather than fall through to the diff --git a/packages/cli/src/ui/hooks/useStatsDialog.ts b/packages/cli/src/ui/hooks/useStatsDialog.ts new file mode 100644 index 0000000000..f05cc4a773 --- /dev/null +++ b/packages/cli/src/ui/hooks/useStatsDialog.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export interface UseStatsDialogReturn { + isStatsDialogOpen: boolean; + openStatsDialog: () => void; + closeStatsDialog: () => void; +} + +export const useStatsDialog = (): UseStatsDialogReturn => { + const [isStatsDialogOpen, setIsStatsDialogOpen] = useState(false); + const openStatsDialog = useCallback(() => setIsStatsDialogOpen(true), []); + const closeStatsDialog = useCallback(() => setIsStatsDialogOpen(false), []); + return { isStatsDialogOpen, openStatsDialog, closeStatsDialog }; +}; diff --git a/packages/cli/src/ui/utils/asciiCharts.test.ts b/packages/cli/src/ui/utils/asciiCharts.test.ts new file mode 100644 index 0000000000..8a4b948045 --- /dev/null +++ b/packages/cli/src/ui/utils/asciiCharts.test.ts @@ -0,0 +1,238 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildHeatmapData, buildBrailleLineChart } from './asciiCharts.js'; + +describe('buildHeatmapData', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-06-04T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns 7 rows for empty data', () => { + const result = buildHeatmapData({}, 8); + expect(result.rows).toHaveLength(7); + expect(result.rows[0]!.label.trim()).toBe('Mon'); + expect(result.rows[6]!.label.trim()).toBe('Sun'); + }); + + it('all cells have intensity 0 for empty data', () => { + const result = buildHeatmapData({}, 8); + for (const row of result.rows) { + for (const cell of row.cells) { + expect(cell.intensity).toBe(0); + } + } + }); + + it('assigns non-zero intensity for days with data', () => { + const data: Record = { + '2025-06-02': 100, + '2025-06-03': 500, + '2025-06-04': 1000, + }; + const result = buildHeatmapData(data, 8); + + const allCells = result.rows.flatMap((r) => r.cells); + const filled = allCells.filter((c) => c.intensity > 0); + expect(filled.length).toBeGreaterThanOrEqual(3); + }); + + it('marks today cell with isToday flag', () => { + const data: Record = { '2025-06-04': 100 }; + const result = buildHeatmapData(data, 8); + + const allCells = result.rows.flatMap((r) => r.cells); + const todayCells = allCells.filter((c) => c.isToday); + expect(todayCells).toHaveLength(1); + expect(todayCells[0]!.intensity).toBeGreaterThan(0); + }); + + it('startDate and endDate are valid date strings', () => { + const result = buildHeatmapData({}, 8); + expect(result.startDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(result.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(new Date(result.startDate).getTime()).toBeLessThan( + new Date(result.endDate).getTime(), + ); + }); + + it('generates column labels with month names', () => { + const result = buildHeatmapData({}, 12); + const monthLabels = result.colLabels.filter((cl) => cl.text.length === 3); + expect(monthLabels.length).toBeGreaterThan(0); + const validMonths = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + for (const label of monthLabels) { + expect(validMonths).toContain(label.text); + } + }); + + it('computes dynamic thresholds from data distribution', () => { + const data: Record = { + '2025-05-01': 10, + '2025-05-02': 50, + '2025-05-03': 100, + '2025-05-04': 500, + '2025-05-05': 1000, + }; + const result = buildHeatmapData(data, 8); + + const allCells = result.rows.flatMap((r) => r.cells); + const intensities = new Set(allCells.map((c) => c.intensity)); + expect(intensities.size).toBeGreaterThan(1); + }); + + it('pads all rows to same length', () => { + const result = buildHeatmapData({}, 8); + const lengths = result.rows.map((r) => r.cells.length); + expect(new Set(lengths).size).toBe(1); + }); + + it('totalCols matches cell count per row', () => { + const result = buildHeatmapData({}, 10); + expect(result.totalCols).toBe(result.rows[0]!.cells.length); + }); + + it('supports monthOffset to shift view backward', () => { + const data: Record = { '2025-04-15': 100 }; + const noOffset = buildHeatmapData(data, 8, 0); + const withOffset = buildHeatmapData(data, 8, 1); + expect(new Date(withOffset.endDate).getTime()).toBeLessThan( + new Date(noOffset.endDate).getTime(), + ); + }); +}); + +describe('buildBrailleLineChart', () => { + it('returns null for empty data', () => { + const result = buildBrailleLineChart([], 40, 8); + expect(result).toBeNull(); + }); + + it('returns null when all values are zero', () => { + const data = [ + { date: '2025-06-01', value: 0 }, + { date: '2025-06-02', value: 0 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + expect(result).toBeNull(); + }); + + it('returns correct number of rows', () => { + const data = [ + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 200 }, + { date: '2025-06-03', value: 150 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + expect(result).not.toBeNull(); + expect(result!.rows).toHaveLength(8); + }); + + it('rows have correct width', () => { + const data = [ + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 200 }, + ]; + const chartWidth = 30; + const result = buildBrailleLineChart(data, chartWidth, 6); + expect(result).not.toBeNull(); + for (const row of result!.rows) { + expect(row).toHaveLength(chartWidth); + } + }); + + it('peak equals the maximum value in data', () => { + const data = [ + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 500 }, + { date: '2025-06-03', value: 300 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + expect(result!.peak).toBe(500); + }); + + it('marks data point cells with isDataPoint', () => { + const data = [ + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 200 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + const dpCells = result!.rows.flatMap((r) => r.filter((c) => c.isDataPoint)); + expect(dpCells.length).toBeGreaterThan(0); + for (const cell of dpCells) { + expect(cell.char).toBe('*'); + expect(cell.filled).toBe(true); + } + }); + + it('has filled cells between data points (Bresenham line)', () => { + const data = [ + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 500 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + const filledCells = result!.rows.flatMap((r) => r.filter((c) => c.filled)); + expect(filledCells.length).toBeGreaterThan(2); + }); + + it('generates yLabels matching row count', () => { + const data = [ + { date: '2025-06-01', value: 1000 }, + { date: '2025-06-02', value: 2000 }, + ]; + const chartHeight = 6; + const result = buildBrailleLineChart(data, 40, chartHeight); + expect(result!.yLabels).toHaveLength(chartHeight); + }); + + it('generates xLabels string', () => { + const data = [ + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 200 }, + { date: '2025-06-03', value: 300 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + expect(typeof result!.xLabels).toBe('string'); + expect(result!.xLabels.length).toBe(40); + }); + + it('handles single data point', () => { + const data = [{ date: '2025-06-01', value: 100 }]; + const result = buildBrailleLineChart(data, 40, 8); + expect(result).not.toBeNull(); + expect(result!.peak).toBe(100); + }); + + it('sorts data by date regardless of input order', () => { + const data = [ + { date: '2025-06-03', value: 300 }, + { date: '2025-06-01', value: 100 }, + { date: '2025-06-02', value: 200 }, + ]; + const result = buildBrailleLineChart(data, 40, 8); + expect(result).not.toBeNull(); + expect(result!.peak).toBe(300); + }); +}); diff --git a/packages/cli/src/ui/utils/asciiCharts.ts b/packages/cli/src/ui/utils/asciiCharts.ts new file mode 100644 index 0000000000..639aa25321 --- /dev/null +++ b/packages/cli/src/ui/utils/asciiCharts.ts @@ -0,0 +1,386 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +const HEATMAP_CHARS = ['··', ' ', ' ', ' ', ' '] as const; +export const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'] as const; + +function getMonthLabel(month: number): string { + return MONTH_LABELS[month]!; +} + +export type HeatmapIntensity = 0 | 1 | 2 | 3 | 4; + +export interface HeatmapCell { + char: string; + intensity: HeatmapIntensity; + isToday?: boolean; +} + +export interface HeatmapRow { + label: string; + cells: HeatmapCell[]; +} + +export interface HeatmapColLabel { + col: number; + text: string; +} + +export interface HeatmapData { + colLabels: HeatmapColLabel[]; + totalCols: number; + startDate: string; + endDate: string; + rows: HeatmapRow[]; +} + +function intensityLevel( + count: number, + thresholds: [number, number, number, number], +): HeatmapIntensity { + if (count === 0) return 0; + if (count <= thresholds[0]) return 1; + if (count <= thresholds[1]) return 2; + if (count <= thresholds[2]) return 3; + return 4; +} + +function computeThresholds( + data: Record, +): [number, number, number, number] { + const values = Object.values(data) + .filter((v) => v > 0) + .sort((a, b) => a - b); + if (values.length === 0) return [1, 2, 4, 8]; + const p = (pct: number) => + values[Math.floor(values.length * pct)] || values[values.length - 1]!; + return [p(0.25), p(0.5), p(0.75), p(0.9)]; +} + +function formatDateKey(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +export function buildHeatmapData( + data: Record, + weeks: number = 52, + monthOffset: number = 0, +): HeatmapData { + const now = new Date(); + now.setHours(0, 0, 0, 0); + + // Use the later of today or latest data date as the end reference + const dataKeys = Object.keys(data) + .filter((k) => data[k]! > 0) + .sort(); + let endDate = new Date(now); + if (dataKeys.length > 0) { + const latest = new Date(dataKeys[dataKeys.length - 1]!); + latest.setHours(0, 0, 0, 0); + if (latest.getTime() > endDate.getTime()) endDate = latest; + } + + // Shift endDate back by monthOffset months + if (monthOffset > 0) { + endDate.setDate(1); + endDate.setMonth(endDate.getMonth() - monthOffset); + endDate.setMonth(endDate.getMonth() + 1, 0); + } + + const endDay = endDate.getDay(); + const endOffset = endDay === 0 ? 6 : endDay - 1; + const totalDays = weeks * 7 + endOffset + 1; + + const startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - totalDays + 1); + const startDay = startDate.getDay(); + const mondayOffset = startDay === 0 ? 1 : startDay === 1 ? 0 : 8 - startDay; + startDate.setDate(startDate.getDate() + mondayOffset); + + const thresholds = computeThresholds(data); + + const grid: HeatmapCell[][] = []; + for (let row = 0; row < 7; row++) { + grid.push([]); + } + + let col = 0; + const colMondays: Array<{ col: number; date: Date }> = []; + + const todayKey = formatDateKey(new Date()); + const cursor = new Date(startDate); + while (cursor <= endDate) { + const dayOfWeek = cursor.getDay(); + const row = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const key = formatDateKey(cursor); + const count = data[key] || 0; + const level = intensityLevel(count, thresholds); + const isToday = key === todayKey; + grid[row]!.push({ char: HEATMAP_CHARS[level]!, intensity: level, isToday }); + + if (dayOfWeek === 1) { + colMondays.push({ col, date: new Date(cursor) }); + col++; + } + + cursor.setDate(cursor.getDate() + 1); + } + + // Build colLabels: month names at month boundaries, day numbers in between + // Rules: + // - Month label (e.g. "Sep") occupies 2 cols (4 chars), left-aligned + // - Day number (e.g. "15") occupies 1 col (2 chars), left-aligned + // - At least 1 empty col gap between any labels + // - At least 1 empty col gap after month label before first day + // - At least 1 empty col gap before next month label + const colLabels: HeatmapColLabel[] = []; + const occupied = new Set(); + let prevMonth = -1; + + // Pass 1: place month labels + for (const cm of colMondays) { + const month = cm.date.getMonth(); + if (month !== prevMonth) { + colLabels.push({ col: cm.col, text: getMonthLabel(month) }); + occupied.add(cm.col); + occupied.add(cm.col + 1); + prevMonth = month; + } + } + + // Handle first month if started mid-week + if (colLabels.length === 0 || colLabels[0]!.col > 0) { + const firstMonth = startDate.getMonth(); + const label = getMonthLabel(firstMonth); + if (colLabels.length === 0 || colLabels[0]!.text !== label) { + colLabels.unshift({ col: 0, text: label }); + occupied.add(0); + occupied.add(1); + } + } + + // Build set of month-label columns for gap checks + const monthCols = new Set(colLabels.map((cl) => cl.col)); + + // Pass 2: fill day numbers in gaps + // Month label at col C occupies C and C+1. First day can go at C+3 (2 col gap). + // Between days, leave at least 1 empty col gap. + let nextAvail = 0; + for (const cm of colMondays) { + if (occupied.has(cm.col)) { + if (monthCols.has(cm.col)) nextAvail = cm.col + 3; + continue; + } + if (cm.col < nextAvail) continue; + // Don't place right before or 1 col before a month label + const nextMonth = [...monthCols].find((mc) => mc > cm.col); + if (nextMonth !== undefined && nextMonth - cm.col <= 1) continue; + colLabels.push({ col: cm.col, text: String(cm.date.getDate()) }); + occupied.add(cm.col); + nextAvail = cm.col + 2; + } + + colLabels.sort((a, b) => a.col - b.col); + + const labelWidth = 4; + const maxCells = Math.max(...grid.map((r) => r.length)); + for (const row of grid) { + while (row.length < maxCells) { + row.push({ char: ' ', intensity: 0 }); + } + } + + const rows: HeatmapRow[] = []; + for (let row = 0; row < 7; row++) { + rows.push({ + label: (DAY_LABELS[row] || '').padEnd(labelWidth), + cells: grid[row]!, + }); + } + + return { + colLabels, + totalCols: maxCells, + startDate: formatDateKey(startDate), + endDate: formatDateKey(endDate), + rows, + }; +} + +export interface LineChartPoint { + date: string; + value: number; +} + +export interface BrailleLineCell { + char: string; + filled: boolean; + isDataPoint?: boolean; +} + +export interface BrailleLineResult { + rows: BrailleLineCell[][]; // rows[0] = top row + yLabels: string[]; // one label per row, right-aligned + xLabels: string; // date axis string + peak: number; +} + +function fmtAxisValue(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}m`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`; + return `${n}`; +} + +export function buildBrailleLineChart( + data: LineChartPoint[], + chartWidth: number, + chartHeight: number = 8, +): BrailleLineResult | null { + if (data.length === 0) return null; + + const pixelW = chartWidth * 2; + const pixelH = chartHeight * 4; + + const sorted = [...data].sort((a, b) => a.date.localeCompare(b.date)); + + const peak = Math.max(...sorted.map((p) => p.value)); + if (peak === 0) return null; + + const pixels: boolean[][] = []; + for (let y = 0; y < pixelH; y++) { + pixels.push(new Array(pixelW).fill(false) as boolean[]); + } + + const mappedPoints: Array<{ px: number; py: number }> = sorted.map((p, i) => { + const px = + sorted.length === 1 + ? Math.floor(pixelW / 2) + : Math.round((i / (sorted.length - 1)) * (pixelW - 1)); + let py: number; + if (p.value === 0) { + py = pixelH - 1; + } else { + const normalized = p.value / peak; + py = Math.min(pixelH - 2, Math.floor((1 - normalized) * (pixelH - 1))); + } + return { px, py }; + }); + + // Bresenham's line between consecutive mapped points + for (let i = 0; i < mappedPoints.length; i++) { + const { px, py } = mappedPoints[i]!; + if (py >= 0 && py < pixelH && px >= 0 && px < pixelW) { + pixels[py]![px] = true; + } + + if (i > 0) { + const { px: x0, py: y0 } = mappedPoints[i - 1]!; + const x1 = px; + const y1 = py; + const dx = Math.abs(x1 - x0); + const dy = -Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + let cx = x0; + let cy = y0; + while (true) { + if (cx >= 0 && cx < pixelW && cy >= 0 && cy < pixelH) { + pixels[cy]![cx] = true; + } + if (cx === x1 && cy === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { + err += dy; + cx += sx; + } + if (e2 <= dx) { + err += dx; + cy += sy; + } + } + } + } + + const dataPointCells = new Set(); + for (const mp of mappedPoints) { + const cc = Math.floor(mp.px / 2); + const cr = Math.floor(mp.py / 4); + dataPointCells.add(`${cc},${cr}`); + } + + const rows: BrailleLineCell[][] = []; + const DOT_BITS = [ + [0x01, 0x08], + [0x02, 0x10], + [0x04, 0x20], + [0x40, 0x80], + ]; + + for (let charRow = 0; charRow < chartHeight; charRow++) { + const row: BrailleLineCell[] = []; + for (let charCol = 0; charCol < chartWidth; charCol++) { + let bits = 0; + for (let dr = 0; dr < 4; dr++) { + const py = charRow * 4 + dr; + if (py >= pixelH) continue; + for (let dc = 0; dc < 2; dc++) { + const px = charCol * 2 + dc; + if (px < pixelW && pixels[py]![px]) { + bits |= DOT_BITS[dr]![dc]!; + } + } + } + const isDP = dataPointCells.has(`${charCol},${charRow}`); + row.push({ + char: isDP ? '*' : String.fromCharCode(0x2800 + bits), + filled: bits !== 0 || isDP, + isDataPoint: isDP, + }); + } + rows.push(row); + } + + const yLabels: string[] = []; + for (let r = 0; r < chartHeight; r++) { + const fraction = 1 - r / (chartHeight - 1); + const value = Math.round(peak * fraction); + yLabels.push(fmtAxisValue(value)); + } + + // X-axis: place a label at each data point's character column, + // skipping when labels would be closer than 3 characters apart. + const xChars: string[] = new Array(chartWidth).fill(' '); + let nextAllowed = 0; + for (let di = 0; di < mappedPoints.length; di++) { + const cc = Math.floor(mappedPoints[di]!.px / 2); + if (cc < nextAllowed || cc >= chartWidth) continue; + const day = String(new Date(sorted[di]!.date + 'T00:00:00').getDate()); + if (cc + day.length > chartWidth) continue; + for (let k = 0; k < day.length; k++) xChars[cc + k] = day[k]!; + nextAllowed = cc + day.length + 1; + } + const xLabels = xChars.join(''); + + return { rows, yLabels, xLabels, peak }; +} diff --git a/packages/cli/src/ui/utils/statsDataService.test.ts b/packages/cli/src/ui/utils/statsDataService.test.ts new file mode 100644 index 0000000000..df0d34d0f2 --- /dev/null +++ b/packages/cli/src/ui/utils/statsDataService.test.ts @@ -0,0 +1,488 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { + UsageSummaryRecord, + AggregatedReport, +} from '@qwen-code/qwen-code-core'; + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadUsageHistory: vi.fn(), + }; +}); + +import { loadUsageHistory } from '@qwen-code/qwen-code-core'; +import { + loadStatsData, + getPreviousRangeBounds, + computeDelta, +} from './statsDataService.js'; + +const mockedLoadUsageHistory = vi.mocked(loadUsageHistory); + +function makeRecord( + overrides?: Partial, +): UsageSummaryRecord { + return { + version: 1, + sessionId: 'sess-1', + timestamp: Date.now(), + startTime: Date.now() - 60000, + project: '/my/project', + durationMs: 60000, + totalLatencyMs: 2000, + models: { + 'qwen-max': { + requests: 3, + inputTokens: 1000, + outputTokens: 500, + cachedTokens: 200, + thoughtsTokens: 50, + totalTokens: 1550, + }, + }, + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + byName: { + edit: { count: 3, success: 2, fail: 1, totalDurationMs: 1500 }, + bash: { count: 2, success: 2, fail: 0, totalDurationMs: 800 }, + }, + }, + files: { + linesAdded: 20, + linesRemoved: 5, + }, + ...overrides, + }; +} + +describe('getPreviousRangeBounds', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-05T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns null for "all" range', () => { + expect(getPreviousRangeBounds('all')).toBeNull(); + }); + + it('returns yesterday 00:00 to today 00:00 for "today"', () => { + const result = getPreviousRangeBounds('today'); + expect(result).not.toBeNull(); + + const now = new Date(); + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); + + expect(result!.start.getTime()).toBe(yesterdayStart.getTime()); + expect(result!.end.getTime()).toBe(todayStart.getTime()); + }); + + it('returns 14 days ago to 7 days ago for "week"', () => { + const result = getPreviousRangeBounds('week'); + expect(result).not.toBeNull(); + + const now = new Date(); + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const fourteenDaysAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + expect(result!.start.getTime()).toBe(fourteenDaysAgo.getTime()); + expect(result!.end.getTime()).toBe(sevenDaysAgo.getTime()); + }); + + it('returns 60 days ago to 30 days ago for "month"', () => { + const result = getPreviousRangeBounds('month'); + expect(result).not.toBeNull(); + + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const sixtyDaysAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); + + expect(result!.start.getTime()).toBe(sixtyDaysAgo.getTime()); + expect(result!.end.getTime()).toBe(thirtyDaysAgo.getTime()); + }); +}); + +describe('computeDelta', () => { + function makeReport(overrides?: Partial): AggregatedReport { + return { + timeRange: 'all', + periodStart: new Date(0), + periodEnd: new Date(), + sessionCount: 10, + totalDurationMs: 600000, + totalLatencyMs: 5000, + totalRequests: 20, + models: { + 'qwen-max': { + requests: 20, + inputTokens: 10000, + outputTokens: 5000, + cachedTokens: 2000, + thoughtsTokens: 500, + totalTokens: 15500, + totalLatencyMs: 5000, + }, + }, + tools: { + totalCalls: 50, + totalSuccess: 45, + totalFail: 5, + topTools: [], + }, + files: { linesAdded: 100, linesRemoved: 30 }, + projects: [], + ...overrides, + }; + } + + it('computes percentage change for sessions', () => { + const current = makeReport({ sessionCount: 12 }); + const previous = makeReport({ sessionCount: 10 }); + + const delta = computeDelta(current, previous); + + // (12-10)/10 * 100 = 20% + expect(delta.sessions).toBeCloseTo(20); + }); + + it('computes percentage change for duration', () => { + const current = makeReport({ totalDurationMs: 120000 }); + const previous = makeReport({ totalDurationMs: 100000 }); + + const delta = computeDelta(current, previous); + + // (120000-100000)/100000 * 100 = 20% + expect(delta.duration).toBeCloseTo(20); + }); + + it('computes percentage change for tokens', () => { + const current = makeReport({ + models: { + 'qwen-max': { + requests: 10, + inputTokens: 5000, + outputTokens: 2500, + cachedTokens: 1000, + thoughtsTokens: 0, + totalTokens: 7500, + totalLatencyMs: 2000, + }, + }, + }); + const previous = makeReport({ + models: { + 'qwen-max': { + requests: 10, + inputTokens: 4000, + outputTokens: 2000, + cachedTokens: 800, + thoughtsTokens: 0, + totalTokens: 6000, + totalLatencyMs: 1500, + }, + }, + }); + + const delta = computeDelta(current, previous); + + // current totalTokens: 7500, previous: 6000, (7500-6000)/6000*100 = 25% + expect(delta.tokens).toBeCloseTo(25); + }); + + it('computes absolute diff for cacheRate', () => { + // current: cachedTokens=3000, inputTokens=10000 -> 30% + // previous: cachedTokens=2000, inputTokens=10000 -> 20% + // diff: 30 - 20 = 10 + const current = makeReport({ + models: { + m: { + requests: 5, + inputTokens: 10000, + outputTokens: 3000, + cachedTokens: 3000, + thoughtsTokens: 0, + totalTokens: 13000, + totalLatencyMs: 4000, + }, + }, + }); + const previous = makeReport({ + models: { + m: { + requests: 5, + inputTokens: 10000, + outputTokens: 3000, + cachedTokens: 2000, + thoughtsTokens: 0, + totalTokens: 12000, + totalLatencyMs: 3500, + }, + }, + }); + + const delta = computeDelta(current, previous); + + expect(delta.cacheRate).toBeCloseTo(10); + }); + + it('computes absolute diff for toolSuccess', () => { + const current = makeReport({ + tools: { totalCalls: 100, totalSuccess: 90, totalFail: 10, topTools: [] }, + }); + const previous = makeReport({ + tools: { totalCalls: 80, totalSuccess: 64, totalFail: 16, topTools: [] }, + }); + + const delta = computeDelta(current, previous); + + // current: 90/100*100=90%, previous: 64/80*100=80%, diff=10 + expect(delta.toolSuccess).toBeCloseTo(10); + }); + + it('computes absolute diff for avgLatency', () => { + const current = makeReport({ totalLatencyMs: 5000, totalRequests: 10 }); + const previous = makeReport({ totalLatencyMs: 4000, totalRequests: 10 }); + + const delta = computeDelta(current, previous); + + // current avg: 500, previous avg: 400, diff: 100 + expect(delta.avgLatency).toBeCloseTo(100); + }); + + it('returns null values when previous has zero denominators', () => { + const current = makeReport({ sessionCount: 5 }); + const previous = makeReport({ + sessionCount: 0, + totalDurationMs: 0, + totalLatencyMs: 0, + totalRequests: 0, + models: {}, + tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, topTools: [] }, + }); + + const delta = computeDelta(current, previous); + + expect(delta.sessions).toBeNull(); + expect(delta.duration).toBeNull(); + expect(delta.tokens).toBeNull(); + expect(delta.cacheRate).toBeNull(); + expect(delta.toolSuccess).toBeNull(); + expect(delta.avgLatency).toBeNull(); + }); +}); + +describe('loadStatsData - new fields', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns delta as null for range "all"', async () => { + const now = Date.now(); + const records = [makeRecord({ timestamp: now - 1000 })]; + mockedLoadUsageHistory.mockResolvedValue(records); + + const result = await loadStatsData('all'); + + expect(result.delta).toBeNull(); + }); + + it('returns computed delta for range "today"', async () => { + const now = new Date(); + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const yesterdayMid = todayStart.getTime() - 12 * 60 * 60 * 1000; // yesterday noon + + const todayRecord = makeRecord({ + sessionId: 'today-1', + timestamp: todayStart.getTime() + 3600000, + startTime: todayStart.getTime() + 3600000 - 60000, + durationMs: 60000, + totalLatencyMs: 1000, + models: { + 'qwen-max': { + requests: 2, + inputTokens: 500, + outputTokens: 200, + cachedTokens: 100, + thoughtsTokens: 0, + totalTokens: 700, + }, + }, + tools: { totalCalls: 10, totalSuccess: 8, totalFail: 2, byName: {} }, + }); + + const yesterdayRecord = makeRecord({ + sessionId: 'yesterday-1', + timestamp: yesterdayMid, + startTime: yesterdayMid - 60000, + durationMs: 30000, + totalLatencyMs: 800, + models: { + 'qwen-max': { + requests: 2, + inputTokens: 400, + outputTokens: 150, + cachedTokens: 50, + thoughtsTokens: 0, + totalTokens: 550, + }, + }, + tools: { totalCalls: 5, totalSuccess: 4, totalFail: 1, byName: {} }, + }); + + mockedLoadUsageHistory.mockResolvedValue([yesterdayRecord, todayRecord]); + + const result = await loadStatsData('today'); + + expect(result.delta).not.toBeNull(); + // sessions: 1 today vs 1 yesterday => 0% + expect(result.delta!.sessions).toBeCloseTo(0); + // duration: 60000 vs 30000 => 100% + expect(result.delta!.duration).toBeCloseTo(100); + }); + + it('computes efficiency fields correctly', async () => { + const now = Date.now(); + const records = [ + makeRecord({ + timestamp: now - 1000, + totalLatencyMs: 3000, + models: { + 'qwen-max': { + requests: 10, + inputTokens: 2000, + outputTokens: 1000, + cachedTokens: 500, + thoughtsTokens: 0, + totalTokens: 3000, + }, + }, + tools: { + totalCalls: 20, + totalSuccess: 18, + totalFail: 2, + byName: {}, + }, + }), + ]; + mockedLoadUsageHistory.mockResolvedValue(records); + + const result = await loadStatsData('all'); + + // cacheHitRate = 500/2000*100 = 25 + expect(result.efficiency.cacheHitRate).toBeCloseTo(25); + // toolSuccessRate = 18/20*100 = 90 + expect(result.efficiency.toolSuccessRate).toBeCloseTo(90); + // avgLatencyMs = 3000/10 = 300 + expect(result.efficiency.avgLatencyMs).toBeCloseTo(300); + }); + + it('handles zero inputTokens for cacheHitRate', async () => { + const now = Date.now(); + const records = [ + makeRecord({ + timestamp: now - 1000, + models: {}, + tools: { totalCalls: 0, totalSuccess: 0, totalFail: 0, byName: {} }, + }), + ]; + mockedLoadUsageHistory.mockResolvedValue(records); + + const result = await loadStatsData('all'); + + expect(result.efficiency.cacheHitRate).toBe(0); + expect(result.efficiency.toolSuccessRate).toBe(0); + expect(result.efficiency.avgLatencyMs).toBeNull(); + }); + + it('computes toolLeaderboard from topTools', async () => { + const now = Date.now(); + const records = [ + makeRecord({ + timestamp: now - 1000, + tools: { + totalCalls: 15, + totalSuccess: 13, + totalFail: 2, + byName: { + edit: { count: 8, success: 7, fail: 1, totalDurationMs: 3000 }, + bash: { count: 5, success: 4, fail: 1, totalDurationMs: 2000 }, + grep: { count: 2, success: 2, fail: 0, totalDurationMs: 400 }, + }, + }, + }), + ]; + mockedLoadUsageHistory.mockResolvedValue(records); + + const result = await loadStatsData('all'); + + expect(result.toolLeaderboard.length).toBeLessThanOrEqual(8); + expect(result.toolLeaderboard[0]).toEqual({ + name: 'edit', + count: 8, + totalDurationMs: 3000, + successRate: (7 / 8) * 100, + }); + expect(result.toolLeaderboard[1]).toEqual({ + name: 'bash', + count: 5, + totalDurationMs: 2000, + successRate: (4 / 5) * 100, + }); + expect(result.toolLeaderboard[2]).toEqual({ + name: 'grep', + count: 2, + totalDurationMs: 400, + successRate: 100, + }); + }); + + it('limits toolLeaderboard to 8 entries', async () => { + const now = Date.now(); + const byName: Record< + string, + { count: number; success: number; fail: number; totalDurationMs: number } + > = {}; + for (let i = 0; i < 12; i++) { + byName[`tool-${i}`] = { + count: 12 - i, + success: 12 - i, + fail: 0, + totalDurationMs: 100, + }; + } + const records = [ + makeRecord({ + timestamp: now - 1000, + tools: { totalCalls: 78, totalSuccess: 78, totalFail: 0, byName }, + }), + ]; + mockedLoadUsageHistory.mockResolvedValue(records); + + const result = await loadStatsData('all'); + + expect(result.toolLeaderboard.length).toBe(8); + }); +}); diff --git a/packages/cli/src/ui/utils/statsDataService.ts b/packages/cli/src/ui/utils/statsDataService.ts new file mode 100644 index 0000000000..b873d524f6 --- /dev/null +++ b/packages/cli/src/ui/utils/statsDataService.ts @@ -0,0 +1,322 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + loadUsageHistory, + aggregateUsage, + getTimeRangeBounds, + type AggregatedReport, + type TimeRange, + type UsageSummaryRecord, +} from '@qwen-code/qwen-code-core'; + +export interface StatsData { + report: AggregatedReport; + heatmap: Record; + currentStreak: number; + longestStreak: number; + tokensPerDay: Array<{ date: string; model: string; tokens: number }>; + delta: { + sessions: number | null; + duration: number | null; + tokens: number | null; + cacheRate: number | null; + toolSuccess: number | null; + avgLatency: number | null; + } | null; + efficiency: { + cacheHitRate: number; + toolSuccessRate: number; + avgLatencyMs: number | null; + }; + toolLeaderboard: Array<{ + name: string; + count: number; + totalDurationMs: number; + successRate: number; + }>; +} + +function calculateStreaks(dates: string[]): { + currentStreak: number; + longestStreak: number; +} { + if (dates.length === 0) return { currentStreak: 0, longestStreak: 0 }; + + const parsed = dates + .map((d) => { + const dt = new Date(d + 'T00:00:00'); + dt.setHours(0, 0, 0, 0); + return dt; + }) + .sort((a, b) => a.getTime() - b.getTime()); + + let currentStreak = 1; + let longestStreak = 1; + + for (let i = 1; i < parsed.length; i++) { + const diff = Math.round( + (parsed[i]!.getTime() - parsed[i - 1]!.getTime()) / (1000 * 60 * 60 * 24), + ); + if (diff === 1) { + currentStreak++; + if (currentStreak > longestStreak) longestStreak = currentStreak; + } else if (diff > 1) { + currentStreak = 1; + } + } + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const lastDate = parsed[parsed.length - 1]!; + const daysSinceLast = Math.round( + (today.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24), + ); + if (daysSinceLast > 1) currentStreak = 0; + + return { currentStreak, longestStreak }; +} + +function buildHeatmap( + records: UsageSummaryRecord[], + start: Date, + end: Date, +): Record { + const heatmap: Record = {}; + for (const r of records) { + if (r.timestamp < start.getTime() || r.timestamp > end.getTime()) continue; + if (!r.models) continue; + const ts = new Date(r.timestamp); + const key = `${ts.getFullYear()}-${String(ts.getMonth() + 1).padStart(2, '0')}-${String(ts.getDate()).padStart(2, '0')}`; + let totalTokens = 0; + for (const m of Object.values(r.models)) { + totalTokens += + m.totalTokens || m.inputTokens + m.outputTokens + m.thoughtsTokens; + } + heatmap[key] = (heatmap[key] || 0) + totalTokens; + } + return heatmap; +} + +function buildTokensPerDay( + records: UsageSummaryRecord[], + start: Date, + end: Date, +): Array<{ date: string; model: string; tokens: number }> { + const dayModel = new Map(); + for (const r of records) { + const ts = new Date(r.timestamp); + if (r.timestamp < start.getTime() || r.timestamp > end.getTime()) continue; + if (!r.models) continue; + const dateKey = `${ts.getFullYear()}-${String(ts.getMonth() + 1).padStart(2, '0')}-${String(ts.getDate()).padStart(2, '0')}`; + for (const [model, m] of Object.entries(r.models)) { + const key = `${dateKey}|${model}`; + const tokens = + m.totalTokens || m.inputTokens + m.outputTokens + m.thoughtsTokens; + dayModel.set(key, (dayModel.get(key) || 0) + tokens); + } + } + const result: Array<{ date: string; model: string; tokens: number }> = []; + for (const [key, tokens] of dayModel) { + const [date, model] = key.split('|') as [string, string]; + result.push({ date, model, tokens }); + } + return result.sort((a, b) => a.date.localeCompare(b.date)); +} + +export function getPreviousRangeBounds( + range: TimeRange, +): { start: Date; end: Date } | null { + if (range === 'all') return null; + + const now = new Date(); + switch (range) { + case 'today': { + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const yesterdayStart = new Date(todayStart); + yesterdayStart.setDate(yesterdayStart.getDate() - 1); + return { start: yesterdayStart, end: todayStart }; + } + case 'week': { + const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const fourteenDaysAgo = new Date( + now.getTime() - 14 * 24 * 60 * 60 * 1000, + ); + return { start: fourteenDaysAgo, end: sevenDaysAgo }; + } + case 'month': { + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + const sixtyDaysAgo = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); + return { start: sixtyDaysAgo, end: thirtyDaysAgo }; + } + default: + return null; + } +} + +export function computeDelta( + current: AggregatedReport, + previous: AggregatedReport, +): { + sessions: number | null; + duration: number | null; + tokens: number | null; + cacheRate: number | null; + toolSuccess: number | null; + avgLatency: number | null; +} { + const pctChange = (cur: number, prev: number): number | null => { + if (prev === 0) return null; + return ((cur - prev) / prev) * 100; + }; + + let currentTotalTokens = 0; + let currentInputTokens = 0; + let currentCachedTokens = 0; + for (const m of Object.values(current.models)) { + currentTotalTokens += m.totalTokens; + currentInputTokens += m.inputTokens; + currentCachedTokens += m.cachedTokens; + } + + let previousTotalTokens = 0; + let previousInputTokens = 0; + let previousCachedTokens = 0; + for (const m of Object.values(previous.models)) { + previousTotalTokens += m.totalTokens; + previousInputTokens += m.inputTokens; + previousCachedTokens += m.cachedTokens; + } + + const currentCacheRate = + currentInputTokens > 0 + ? (currentCachedTokens / currentInputTokens) * 100 + : null; + const previousCacheRate = + previousInputTokens > 0 + ? (previousCachedTokens / previousInputTokens) * 100 + : null; + const cacheRateDelta = + currentCacheRate !== null && previousCacheRate !== null + ? currentCacheRate - previousCacheRate + : null; + + const currentToolSuccess = + current.tools.totalCalls > 0 + ? (current.tools.totalSuccess / current.tools.totalCalls) * 100 + : null; + const previousToolSuccess = + previous.tools.totalCalls > 0 + ? (previous.tools.totalSuccess / previous.tools.totalCalls) * 100 + : null; + const toolSuccessDelta = + currentToolSuccess !== null && previousToolSuccess !== null + ? currentToolSuccess - previousToolSuccess + : null; + + const currentAvgLatency = + current.totalRequests > 0 + ? current.totalLatencyMs / current.totalRequests + : null; + const previousAvgLatency = + previous.totalRequests > 0 + ? previous.totalLatencyMs / previous.totalRequests + : null; + const avgLatencyDelta = + currentAvgLatency !== null && previousAvgLatency !== null + ? currentAvgLatency - previousAvgLatency + : null; + + return { + sessions: pctChange(current.sessionCount, previous.sessionCount), + duration: pctChange(current.totalDurationMs, previous.totalDurationMs), + tokens: pctChange(currentTotalTokens, previousTotalTokens), + cacheRate: cacheRateDelta, + toolSuccess: toolSuccessDelta, + avgLatency: avgLatencyDelta, + }; +} + +export async function loadStatsData( + range: TimeRange, + currentSession?: UsageSummaryRecord, +): Promise { + const persisted = await loadUsageHistory(); + let records = persisted; + if (currentSession) { + records = persisted.filter((r) => r.sessionId !== currentSession.sessionId); + records.push(currentSession); + } + const report = aggregateUsage(records, range); + const { start, end } = getTimeRangeBounds(range); + + const heatmap = buildHeatmap(records, start, end); + const heatmapDates = Object.keys(heatmap); + const { currentStreak, longestStreak } = calculateStreaks(heatmapDates); + + const tokensPerDay = buildTokensPerDay(records, start, end); + + let delta: StatsData['delta'] = null; + const prevBounds = getPreviousRangeBounds(range); + if (prevBounds) { + const prevFiltered = records.filter( + (r) => + r.timestamp >= prevBounds.start.getTime() && + r.timestamp < prevBounds.end.getTime(), + ); + if (prevFiltered.length > 0) { + const previousReport = aggregateUsage(prevFiltered, 'all'); + delta = computeDelta(report, previousReport); + } + } + + let totalInputTokens = 0; + let totalCachedTokens = 0; + for (const m of Object.values(report.models)) { + totalInputTokens += m.inputTokens; + totalCachedTokens += m.cachedTokens; + } + const cacheHitRate = + totalInputTokens > 0 ? (totalCachedTokens / totalInputTokens) * 100 : 0; + const toolSuccessRate = + report.tools.totalCalls > 0 + ? (report.tools.totalSuccess / report.tools.totalCalls) * 100 + : 0; + const avgLatencyMs = + report.totalRequests > 0 + ? report.totalLatencyMs / report.totalRequests + : null; + + const efficiency: StatsData['efficiency'] = { + cacheHitRate, + toolSuccessRate, + avgLatencyMs, + }; + + const toolLeaderboard: StatsData['toolLeaderboard'] = report.tools.topTools + .slice(0, 8) + .map((t) => ({ + name: t.name, + count: t.count, + totalDurationMs: t.totalDurationMs, + successRate: t.count > 0 ? (t.success / t.count) * 100 : 0, + })); + + return { + report, + heatmap, + currentStreak, + longestStreak, + tokensPerDay, + delta, + efficiency, + toolLeaderboard, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 717098a81d..73bdb9e7e3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -171,6 +171,7 @@ export * from './services/shellExecutionService.js'; export * from './services/monitorRegistry.js'; export * from './services/backgroundShellRegistry.js'; export * from './services/toolUseSummary.js'; +export * from './services/usageHistoryService.js'; export * from './utils/bareMode.js'; // ============================================================================ diff --git a/packages/core/src/services/usageHistoryService.test.ts b/packages/core/src/services/usageHistoryService.test.ts new file mode 100644 index 0000000000..53dbc741c8 --- /dev/null +++ b/packages/core/src/services/usageHistoryService.test.ts @@ -0,0 +1,351 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { metricsToUsageRecord, aggregateUsage } from './usageHistoryService.js'; +import { ToolCallDecision } from '../telemetry/tool-call-decision.js'; +import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +import type { UsageSummaryRecord } from './usageHistoryService.js'; + +function makeMetrics(overrides?: Partial): SessionMetrics { + return { + models: { + 'qwen-max': { + api: { + totalRequests: 5, + totalErrors: 0, + totalLatencyMs: 3200, + }, + tokens: { + prompt: 1000, + candidates: 500, + total: 1500, + cached: 200, + thoughts: 100, + }, + bySource: {}, + }, + }, + tools: { + totalCalls: 10, + totalSuccess: 8, + totalFail: 2, + totalDurationMs: 5000, + totalDecisions: { + [ToolCallDecision.ACCEPT]: 5, + [ToolCallDecision.REJECT]: 1, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 4, + }, + byName: { + edit: { + count: 6, + success: 5, + fail: 1, + durationMs: 3000, + decisions: { + [ToolCallDecision.ACCEPT]: 3, + [ToolCallDecision.REJECT]: 1, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 2, + }, + }, + bash: { + count: 4, + success: 3, + fail: 1, + durationMs: 2000, + decisions: { + [ToolCallDecision.ACCEPT]: 2, + [ToolCallDecision.REJECT]: 0, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 2, + }, + }, + }, + }, + files: { + totalLinesAdded: 50, + totalLinesRemoved: 10, + }, + ...overrides, + }; +} + +describe('metricsToUsageRecord', () => { + it('populates totalLatencyMs from sum of model api.totalLatencyMs', () => { + const metrics = makeMetrics({ + models: { + 'qwen-max': { + api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 2000 }, + tokens: { + prompt: 500, + candidates: 200, + total: 700, + cached: 0, + thoughts: 0, + }, + bySource: {}, + }, + 'qwen-turbo': { + api: { totalRequests: 2, totalErrors: 1, totalLatencyMs: 1500 }, + tokens: { + prompt: 300, + candidates: 100, + total: 400, + cached: 50, + thoughts: 0, + }, + bySource: {}, + }, + }, + }); + + const record = metricsToUsageRecord( + 'session-1', + '/project', + 1000, + 5000, + metrics, + ); + + expect(record.totalLatencyMs).toBe(3500); // 2000 + 1500 + }); + + it('populates totalDurationMs for each tool in byName', () => { + const metrics = makeMetrics(); + + const record = metricsToUsageRecord( + 'session-2', + '/project', + 1000, + 6000, + metrics, + ); + + expect(record.tools.byName['edit']).toEqual({ + count: 6, + success: 5, + fail: 1, + totalDurationMs: 3000, + }); + expect(record.tools.byName['bash']).toEqual({ + count: 4, + success: 3, + fail: 1, + totalDurationMs: 2000, + }); + }); + + it('sets totalLatencyMs to 0 when no models present', () => { + const metrics = makeMetrics({ models: {} }); + + const record = metricsToUsageRecord( + 'session-3', + '/project', + 0, + 1000, + metrics, + ); + + expect(record.totalLatencyMs).toBe(0); + }); + + it('preserves existing fields correctly alongside new fields', () => { + const metrics = makeMetrics(); + + const record = metricsToUsageRecord( + 'session-4', + '/my/project', + 1000, + 4000, + metrics, + ); + + expect(record.version).toBe(1); + expect(record.sessionId).toBe('session-4'); + expect(record.project).toBe('/my/project'); + expect(record.durationMs).toBe(3000); + expect(record.totalLatencyMs).toBe(3200); + expect(record.tools.totalCalls).toBe(10); + expect(record.tools.totalSuccess).toBe(8); + expect(record.tools.totalFail).toBe(2); + expect(record.files.linesAdded).toBe(50); + expect(record.files.linesRemoved).toBe(10); + }); +}); + +function makeRecord( + overrides?: Partial, +): UsageSummaryRecord { + return { + version: 1, + sessionId: 'sess-1', + timestamp: Date.now(), + startTime: Date.now() - 60000, + project: '/my/project', + durationMs: 60000, + totalLatencyMs: 2000, + models: { + 'qwen-max': { + requests: 3, + inputTokens: 1000, + outputTokens: 500, + cachedTokens: 100, + thoughtsTokens: 50, + totalTokens: 1550, + }, + }, + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + byName: { + edit: { count: 3, success: 2, fail: 1, totalDurationMs: 1500 }, + bash: { count: 2, success: 2, fail: 0, totalDurationMs: 800 }, + }, + }, + files: { + linesAdded: 20, + linesRemoved: 5, + }, + ...overrides, + }; +} + +describe('aggregateUsage', () => { + it('accumulates totalLatencyMs from records', () => { + const records = [ + makeRecord({ totalLatencyMs: 2000 }), + makeRecord({ totalLatencyMs: 3000 }), + ]; + + const report = aggregateUsage(records, 'all'); + + expect(report.totalLatencyMs).toBe(5000); + }); + + it('handles records without totalLatencyMs (backward compat)', () => { + const r1 = makeRecord({ totalLatencyMs: 1500 }); + const r2 = makeRecord({ totalLatencyMs: undefined }); + + const report = aggregateUsage([r1, r2], 'all'); + + expect(report.totalLatencyMs).toBe(1500); + }); + + it('accumulates totalRequests by summing model requests', () => { + const records = [ + makeRecord({ + models: { + 'qwen-max': { + requests: 3, + inputTokens: 100, + outputTokens: 50, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 150, + }, + 'qwen-turbo': { + requests: 2, + inputTokens: 80, + outputTokens: 40, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 120, + }, + }, + }), + makeRecord({ + models: { + 'qwen-max': { + requests: 4, + inputTokens: 200, + outputTokens: 100, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 300, + }, + }, + }), + ]; + + const report = aggregateUsage(records, 'all'); + + // 3 + 2 + 4 = 9 + expect(report.totalRequests).toBe(9); + }); + + it('includes totalDurationMs in topTools', () => { + const records = [ + makeRecord({ + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + byName: { + edit: { count: 3, success: 2, fail: 1, totalDurationMs: 1500 }, + bash: { count: 2, success: 2, fail: 0, totalDurationMs: 800 }, + }, + }, + }), + makeRecord({ + tools: { + totalCalls: 3, + totalSuccess: 3, + totalFail: 0, + byName: { + edit: { count: 2, success: 2, fail: 0, totalDurationMs: 1000 }, + grep: { count: 1, success: 1, fail: 0, totalDurationMs: 200 }, + }, + }, + }), + ]; + + const report = aggregateUsage(records, 'all'); + + const editTool = report.tools.topTools.find((t) => t.name === 'edit'); + expect(editTool).toBeDefined(); + expect(editTool!.totalDurationMs).toBe(2500); // 1500 + 1000 + + const bashTool = report.tools.topTools.find((t) => t.name === 'bash'); + expect(bashTool).toBeDefined(); + expect(bashTool!.totalDurationMs).toBe(800); + + const grepTool = report.tools.topTools.find((t) => t.name === 'grep'); + expect(grepTool).toBeDefined(); + expect(grepTool!.totalDurationMs).toBe(200); + }); + + it('handles tools without totalDurationMs (backward compat)', () => { + const records = [ + makeRecord({ + tools: { + totalCalls: 2, + totalSuccess: 2, + totalFail: 0, + byName: { + edit: { count: 2, success: 2, fail: 0 }, + }, + }, + }), + ]; + + const report = aggregateUsage(records, 'all'); + + const editTool = report.tools.topTools.find((t) => t.name === 'edit'); + expect(editTool).toBeDefined(); + expect(editTool!.totalDurationMs).toBe(0); + }); + + it('returns zero for all new fields when no records match', () => { + const report = aggregateUsage([], 'all'); + + expect(report.totalLatencyMs).toBe(0); + expect(report.totalRequests).toBe(0); + expect(report.tools.topTools).toEqual([]); + }); +}); diff --git a/packages/core/src/services/usageHistoryService.ts b/packages/core/src/services/usageHistoryService.ts new file mode 100644 index 0000000000..308520e449 --- /dev/null +++ b/packages/core/src/services/usageHistoryService.ts @@ -0,0 +1,428 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { Storage } from '../config/storage.js'; +import * as jsonl from '../utils/jsonl-utils.js'; +import { UiTelemetryService } from '../telemetry/uiTelemetry.js'; +import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +import type { UiEvent } from '../telemetry/uiTelemetry.js'; +import type { ChatRecord } from './chatRecordingService.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('USAGE_HISTORY'); + +export interface UsageSummaryRecord { + version: 1; + sessionId: string; + timestamp: number; + startTime: number; + project: string; + durationMs: number; + totalLatencyMs?: number; + models: Record< + string, + { + requests: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + thoughtsTokens: number; + totalTokens: number; + totalLatencyMs?: number; + } + >; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + byName: Record< + string, + { count: number; success: number; fail: number; totalDurationMs?: number } + >; + }; + files: { + linesAdded: number; + linesRemoved: number; + }; +} + +export type TimeRange = 'today' | 'week' | 'month' | 'all'; + +export interface AggregatedReport { + timeRange: TimeRange; + periodStart: Date; + periodEnd: Date; + sessionCount: number; + totalDurationMs: number; + totalLatencyMs: number; + totalRequests: number; + models: Record< + string, + { + requests: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + thoughtsTokens: number; + totalTokens: number; + totalLatencyMs: number; + } + >; + tools: { + totalCalls: number; + totalSuccess: number; + totalFail: number; + topTools: Array<{ + name: string; + count: number; + success: number; + fail: number; + totalDurationMs: number; + }>; + }; + files: { + linesAdded: number; + linesRemoved: number; + }; + projects: Array<{ + path: string; + sessionCount: number; + totalDurationMs: number; + totalTokens: number; + }>; +} + +function getUsageHistoryPath(): string { + return path.join(Storage.getGlobalQwenDir(), 'usage_record.jsonl'); +} + +export function persistSessionUsage(params: { + sessionId: string; + startTime: Date; + endTime: Date; + project: string; + metrics: SessionMetrics; +}): void { + const { sessionId, startTime, endTime, project, metrics } = params; + const record = metricsToUsageRecord( + sessionId, + project, + startTime.getTime(), + endTime.getTime(), + metrics, + ); + jsonl.writeLineSync(getUsageHistoryPath(), record); +} + +export function metricsToUsageRecord( + sessionId: string, + project: string, + startTime: number, + endTime: number, + metrics: SessionMetrics, +): UsageSummaryRecord { + const models: UsageSummaryRecord['models'] = {}; + let totalLatencyMs = 0; + for (const [name, m] of Object.entries(metrics.models)) { + totalLatencyMs += m.api.totalLatencyMs; + models[name] = { + requests: m.api.totalRequests, + inputTokens: m.tokens.prompt, + outputTokens: m.tokens.candidates, + cachedTokens: m.tokens.cached, + thoughtsTokens: m.tokens.thoughts, + totalTokens: + m.tokens.total || + m.tokens.prompt + m.tokens.candidates + m.tokens.thoughts, + totalLatencyMs: m.api.totalLatencyMs, + }; + } + const toolsByName: UsageSummaryRecord['tools']['byName'] = {}; + for (const [name, stats] of Object.entries(metrics.tools.byName)) { + toolsByName[name] = { + count: stats.count, + success: stats.success, + fail: stats.fail, + totalDurationMs: stats.durationMs, + }; + } + return { + version: 1, + sessionId, + timestamp: endTime, + startTime, + project, + durationMs: endTime - startTime, + totalLatencyMs, + models, + tools: { + totalCalls: metrics.tools.totalCalls, + totalSuccess: metrics.tools.totalSuccess, + totalFail: metrics.tools.totalFail, + byName: toolsByName, + }, + files: { + linesAdded: metrics.files.totalLinesAdded, + linesRemoved: metrics.files.totalLinesRemoved, + }, + }; +} + +async function rebuildFromSessionJsonl(): Promise { + const projectsDir = path.join(Storage.getGlobalQwenDir(), 'projects'); + try { + if (!fs.existsSync(projectsDir)) return []; + } catch (e) { + debugLogger.debug( + `rebuildFromSessionJsonl: cannot access projectsDir: ${e}`, + ); + return []; + } + + const results: UsageSummaryRecord[] = []; + const seenSessionIds = new Set(); + let projectDirs: string[]; + try { + projectDirs = fs.readdirSync(projectsDir); + } catch (e) { + debugLogger.debug(`rebuildFromSessionJsonl: cannot read projectsDir: ${e}`); + return []; + } + + for (const projDir of projectDirs) { + const chatsDir = path.join(projectsDir, projDir, 'chats'); + let files: string[]; + try { + files = fs.readdirSync(chatsDir).filter((f) => f.endsWith('.jsonl')); + } catch (e) { + debugLogger.debug( + `rebuildFromSessionJsonl: cannot read chatsDir ${chatsDir}: ${e}`, + ); + continue; + } + + for (const file of files) { + try { + const filePath = path.join(chatsDir, file); + const records = await jsonl.read(filePath); + if (records.length === 0) continue; + + const firstRecord = records[0]!; + const sessionId = firstRecord.sessionId; + if (seenSessionIds.has(sessionId)) continue; + seenSessionIds.add(sessionId); + const project = firstRecord.cwd; + + const telemetry = new UiTelemetryService(); + let hasEvents = false; + + for (const record of records) { + if (record.type === 'system' && record.subtype === 'ui_telemetry') { + const payload = record.systemPayload as + | { uiEvent?: UiEvent } + | undefined; + if (payload?.uiEvent) { + telemetry.addEvent(payload.uiEvent); + hasEvents = true; + } + } + } + + if (!hasEvents) continue; + + const startTime = new Date(firstRecord.timestamp).getTime(); + const lastRecord = records[records.length - 1]!; + const endTime = new Date(lastRecord.timestamp).getTime(); + if (isNaN(startTime) || isNaN(endTime) || !sessionId) continue; + + results.push( + metricsToUsageRecord( + sessionId, + project, + startTime, + endTime, + telemetry.getMetrics(), + ), + ); + } catch (e) { + debugLogger.debug( + `rebuildFromSessionJsonl: failed to process ${file}: ${e}`, + ); + continue; + } + } + } + + if (results.length > 0) { + const usagePath = getUsageHistoryPath(); + for (const record of results) { + jsonl.writeLineSync(usagePath, record); + } + } + + return results; +} + +export async function loadUsageHistory(): Promise { + try { + const records = await jsonl.read(getUsageHistoryPath()); + const filtered = records.filter((r) => r.version === 1); + if (filtered.length > 0) return filtered; + } catch (e) { + debugLogger.debug(`loadUsageHistory: failed to read usage file: ${e}`); + } + + return rebuildFromSessionJsonl(); +} + +export function getTimeRangeBounds(range: TimeRange): { + start: Date; + end: Date; +} { + const now = new Date(); + const end = now; + let start: Date; + switch (range) { + case 'today': { + start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + break; + } + case 'week': { + start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + } + case 'month': { + start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + } + case 'all': + start = new Date(0); + break; + default: + start = new Date(0); + break; + } + return { start, end }; +} + +export function aggregateUsage( + records: UsageSummaryRecord[], + range: TimeRange, +): AggregatedReport { + const { start, end } = getTimeRangeBounds(range); + const filtered = records.filter((r) => { + const ts = r.timestamp; + return ts >= start.getTime() && ts <= end.getTime(); + }); + + const models: AggregatedReport['models'] = Object.create(null); + let totalCalls = 0; + let totalSuccess = 0; + let totalFail = 0; + let totalDurationMs = 0; + let totalLatencyMs = 0; + let totalRequests = 0; + let linesAdded = 0; + let linesRemoved = 0; + const toolCounts = new Map< + string, + { count: number; success: number; fail: number; totalDurationMs: number } + >(); + const projectMap = new Map< + string, + { + sessionCount: number; + totalDurationMs: number; + totalTokens: number; + } + >(); + + for (const r of filtered) { + if (!r.models || !r.tools?.byName || !r.files) continue; + totalDurationMs += r.durationMs; + totalLatencyMs += r.totalLatencyMs ?? 0; + totalCalls += r.tools.totalCalls; + totalSuccess += r.tools.totalSuccess; + totalFail += r.tools.totalFail; + linesAdded += r.files.linesAdded; + linesRemoved += r.files.linesRemoved; + + for (const [name, m] of Object.entries(r.models)) { + totalRequests += m.requests; + const existing = models[name]; + if (existing) { + existing.requests += m.requests; + existing.inputTokens += m.inputTokens; + existing.outputTokens += m.outputTokens; + existing.cachedTokens += m.cachedTokens; + existing.thoughtsTokens += m.thoughtsTokens; + existing.totalTokens += m.totalTokens; + existing.totalLatencyMs += m.totalLatencyMs ?? 0; + } else { + models[name] = { ...m, totalLatencyMs: m.totalLatencyMs ?? 0 }; + } + } + + for (const [name, stats] of Object.entries(r.tools.byName)) { + const existing = toolCounts.get(name); + if (existing) { + existing.count += stats.count; + existing.success += stats.success; + existing.fail += stats.fail; + existing.totalDurationMs += stats.totalDurationMs ?? 0; + } else { + toolCounts.set(name, { + count: stats.count, + success: stats.success, + fail: stats.fail, + totalDurationMs: stats.totalDurationMs ?? 0, + }); + } + } + + let sessionTokens = 0; + for (const m of Object.values(r.models)) { + sessionTokens += m.totalTokens; + } + const proj = projectMap.get(r.project); + if (proj) { + proj.sessionCount++; + proj.totalDurationMs += r.durationMs; + proj.totalTokens += sessionTokens; + } else { + projectMap.set(r.project, { + sessionCount: 1, + totalDurationMs: r.durationMs, + totalTokens: sessionTokens, + }); + } + } + + const topTools = [...toolCounts.entries()] + .map(([name, stats]) => ({ name, ...stats })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const projects = [...projectMap.entries()] + .map(([p, stats]) => ({ path: p, ...stats })) + .sort((a, b) => b.totalTokens - a.totalTokens); + + return { + timeRange: range, + periodStart: start, + periodEnd: end, + sessionCount: filtered.length, + totalDurationMs, + totalLatencyMs, + totalRequests, + models, + tools: { totalCalls, totalSuccess, totalFail, topTools }, + files: { linesAdded, linesRemoved }, + projects, + }; +} diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index c148a95792..d267c83cb0 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -146,6 +146,7 @@ export class UiTelemetryService extends EventEmitter { #metrics: SessionMetrics = createInitialMetrics(); #lastPromptTokenCount = 0; #lastCachedContentTokenCount = 0; + #sessionStartTime: Date = new Date(); addEvent(event: UiEvent) { switch (event['event.name']) { @@ -185,6 +186,10 @@ export class UiTelemetryService extends EventEmitter { }); } + getSessionStartTime(): Date { + return this.#sessionStartTime; + } + getLastCachedContentTokenCount(): number { return this.#lastCachedContentTokenCount; } @@ -200,6 +205,7 @@ export class UiTelemetryService extends EventEmitter { this.#metrics = createInitialMetrics(); this.#lastPromptTokenCount = 0; this.#lastCachedContentTokenCount = 0; + this.#sessionStartTime = new Date(); this.emit('update', { metrics: this.#metrics, lastPromptTokenCount: this.#lastPromptTokenCount, diff --git a/patches/ink+7.0.3.patch b/patches/ink+7.0.3.patch deleted file mode 100644 index 9b28cb09cb..0000000000 --- a/patches/ink+7.0.3.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/node_modules/ink/package.json b/node_modules/ink/package.json ---- a/node_modules/ink/package.json -+++ b/node_modules/ink/package.json -@@ -14,2 +14,10 @@ -- "types": "./build/index.d.ts", -- "default": "./build/index.js" -+ ".": { -+ "types": "./build/index.d.ts", -+ "default": "./build/index.js" -+ }, -+ "./dom": { -+ "default": "./build/dom.js" -+ }, -+ "./components/CursorContext": { -+ "default": "./build/components/CursorContext.js" -+ }