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" -+ }