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:
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:
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<
string,
{ count: number; success: number; fail: number; totalDurationMs?: number }
>;
};
files: {
linesAdded: number;
linesRemoved: number;
};
}Update metricsToUsageRecord to populate the new fields:
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
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:
import {
aggregateUsage,
type UsageSummaryRecord,
type TimeRange,
} from './usageHistoryService.js';
function makeRecord(
overrides: Partial<UsageSummaryRecord> = {},
): 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:
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:
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
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:
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<typeof import('@qwen-code/qwen-code-core')>();
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:
export interface StatsData {
report: AggregatedReport;
heatmap: Record<string, number>;
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:
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:
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:
export async function loadStatsData(
range: TimeRange,
currentSession?: UsageSummaryRecord,
): Promise<StatsData> {
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
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:
function buildHeatmap(
records: UsageSummaryRecord[],
start: Date,
end: Date,
): Record<string, number> {
const heatmap: Record<string, number> = {};
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
isTodayflag to HeatmapCell
In packages/cli/src/ui/utils/asciiCharts.ts, update the interface:
export interface HeatmapCell {
char: string;
intensity: HeatmapIntensity;
isToday?: boolean;
}In buildHeatmapData, after computing each cell, mark today:
// 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:
{row.cells.map((cell, ci) => (
<Text
key={ci}
backgroundColor={HEATMAP_COLORS[cell.intensity]}
bold={cell.isToday}
underline={cell.isToday}
>
{cell.isToday ? '▪▪' : cell.char}
</Text>
))}- Step 4: Verify visually by running
npm run devand 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
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
const RANGE_CYCLE: TimeRange[] = ['today', 'week', 'month', 'all'];Update getRangeLabel:
function getRangeLabel(range: string): string {
const labels: Record<string, string> = {
today: t('Today'),
all: t('All time'),
week: t('Last 7 days'),
month: t('Last 30 days'),
};
return labels[range] ?? range;
}- Step 3: Commit
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:
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<string, number>();
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 (
<Box flexDirection="column">
{/* KPI Row */}
<Box flexDirection="row" marginBottom={1}>
<Box width={col1Width}>
<Text color={theme.text.secondary}>{t('Sessions')} </Text>
<Text bold color={theme.text.primary}>{data.report.sessionCount}</Text>
{data.delta?.sessions != null && (
<Text color={data.delta.sessions >= 0 ? theme.status.success : theme.status.error}>
{' '}{data.delta.sessions >= 0 ? '▲' : '▼'}{Math.abs(data.delta.sessions).toFixed(0)}%
</Text>
)}
</Box>
<Box width={col1Width}>
<Text color={theme.text.secondary}>{t('Duration')} </Text>
<Text bold color={theme.text.primary}>{fmtDurationShort(data.report.totalDurationMs)}</Text>
{data.delta?.duration != null && (
<Text color={data.delta.duration >= 0 ? theme.status.success : theme.status.error}>
{' '}{data.delta.duration >= 0 ? '▲' : '▼'}{Math.abs(data.delta.duration).toFixed(0)}%
</Text>
)}
</Box>
<Box>
<Text color={theme.text.secondary}>{t('Tokens')} </Text>
<Text bold color={theme.status.warning}>{fmtTokens(totalTokens)}</Text>
{data.delta?.tokens != null && (
<Text color={data.delta.tokens >= 0 ? theme.status.success : theme.status.error}>
{' '}{data.delta.tokens >= 0 ? '▲' : '▼'}{Math.abs(data.delta.tokens).toFixed(0)}%
</Text>
)}
</Box>
</Box>
{/* Heatmap */}
<Box>
<HeatmapView data={data} weeks={heatmapWeeks} monthOffset={clampedOffset} />
<Box marginLeft={2} flexDirection="column">
<Box>
<Text color={theme.text.secondary}>{t('streak')}: </Text>
<Text color={theme.status.success} bold>{data.currentStreak}d</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>{t('best')}: </Text>
<Text color={theme.status.warning} bold>{data.longestStreak}d</Text>
</Box>
</Box>
</Box>
{/* Token Trend */}
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold color={theme.text.primary}>{t('Token Trend')}</Text>
{chartMonthLabel && (
<Text color={theme.text.accent}>
{' '}{canGoLeft ? '← ' : ' '}{chartMonthLabel}{canGoRight ? ' →' : ''}
</Text>
)}
</Box>
{overviewChart ? (
<>
{overviewChart.rows.map((row, ri) => (
<Box key={ri}>
<Text color={theme.text.secondary}>{row.yLabel}{row.border}</Text>
{row.cells.map((cell, ci) => (
<Text key={ci} color={cell.seriesIndex >= 0 ? theme.text.accent : theme.text.secondary}>
{cell.char}
</Text>
))}
</Box>
))}
<Box>
<Text color={theme.text.secondary}>{overviewChart.xAxisRow.yLabel}{overviewChart.xAxisRow.border}</Text>
{overviewChart.xAxisRow.cells.map((cell, ci) => (
<Text key={ci} color={theme.text.secondary}>{cell.char}</Text>
))}
</Box>
<Box>
<Text color={theme.text.secondary}>{overviewChart.xLabelRow.yLabel}{overviewChart.xLabelRow.border}</Text>
{overviewChart.xLabelRow.cells.map((cell, ci) => (
<Text key={ci} color={theme.text.secondary}>{cell.char}</Text>
))}
</Box>
</>
) : (
<Text color={theme.text.secondary}>{' '}{t('(no data)')}</Text>
)}
</Box>
{/* Project Ranking */}
{data.report.projects.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>{t('Projects')}</Text>
<TableRow cells={[
{ text: ' ' + t('Project'), width: 22, color: theme.text.secondary },
{ text: t('Sessions'), width: 10, color: theme.text.secondary },
{ text: t('Tokens'), width: 10, color: theme.text.secondary },
{ text: t('Duration'), width: 10, color: theme.text.secondary },
]} />
{data.report.projects.slice(0, 5).map((proj) => {
const name = proj.path.split('/').pop() || proj.path;
const tokens = proj.totalInputTokens + proj.totalOutputTokens;
return (
<TableRow key={proj.path} cells={[
{ text: ' ' + name.slice(0, 18), width: 22, color: theme.text.primary },
{ text: String(proj.sessionCount), width: 10, color: theme.text.primary },
{ text: fmtTokens(tokens), width: 10, color: theme.status.warning },
{ text: fmtDurationShort(proj.totalDurationMs), width: 10, color: theme.text.secondary },
]} />
);
})}
</Box>
)}
</Box>
);
};- 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:
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
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:
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 (
<Box flexDirection="column">
{/* Performance Cards */}
<Box flexDirection="row" marginBottom={1}>
<Box width={cardWidth} flexDirection="column" borderStyle="single" borderColor={theme.border.default} paddingX={1}>
<Text color={theme.text.secondary}>{t('Cache Hit Rate')}</Text>
<Text bold color={getCacheColor(data.efficiency.cacheHitRate)}>
{data.efficiency.cacheHitRate.toFixed(1)}%
</Text>
{data.delta?.cacheRate != null && (
<Text color={data.delta.cacheRate >= 0 ? theme.status.success : theme.status.error}>
{data.delta.cacheRate >= 0 ? '▲' : '▼'} {Math.abs(data.delta.cacheRate).toFixed(1)}%
</Text>
)}
</Box>
<Box width={cardWidth} flexDirection="column" borderStyle="single" borderColor={theme.border.default} paddingX={1} marginLeft={1}>
<Text color={theme.text.secondary}>{t('Tool Success')}</Text>
<Text bold color={getSuccessColor(data.efficiency.toolSuccessRate)}>
{data.efficiency.toolSuccessRate.toFixed(1)}%
</Text>
{data.delta?.toolSuccess != null && (
<Text color={data.delta.toolSuccess >= 0 ? theme.status.success : theme.status.error}>
{data.delta.toolSuccess >= 0 ? '▲' : '▼'} {Math.abs(data.delta.toolSuccess).toFixed(1)}%
</Text>
)}
</Box>
<Box width={cardWidth} flexDirection="column" borderStyle="single" borderColor={theme.border.default} paddingX={1} marginLeft={1}>
<Text color={theme.text.secondary}>{t('Avg Latency')}</Text>
<Text bold color={theme.text.accent}>
{data.efficiency.avgLatencyMs != null
? `${(data.efficiency.avgLatencyMs / 1000).toFixed(1)}s`
: '—'}
</Text>
{data.delta?.avgLatency != null && (
<Text color={data.delta.avgLatency <= 0 ? theme.status.success : theme.status.error}>
{data.delta.avgLatency <= 0 ? '▲' : '▼'} {Math.abs(data.delta.avgLatency / 1000).toFixed(1)}s
</Text>
)}
</Box>
</Box>
{/* Tool Leaderboard */}
{data.toolLeaderboard.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>{t('Tool Leaderboard')}</Text>
<TableRow cells={[
{ text: ' ' + t('Tool'), width: 12, color: theme.text.secondary },
{ text: t('Calls'), width: 8, color: theme.text.secondary },
{ text: t('Time'), width: 9, color: theme.text.secondary },
{ text: t('Success'), width: 20, color: theme.text.secondary },
]} />
{data.toolLeaderboard.map((tool) => (
<TableRow key={tool.name} cells={[
{ text: ' ' + tool.name.slice(0, 10), width: 12, color: theme.text.accent },
{ text: String(tool.count), width: 8, color: theme.text.primary },
{ text: `${(tool.totalDurationMs / 1000).toFixed(1)}s`, width: 9, color: theme.text.secondary },
{ text: `${fmtSuccessBar(tool.successRate)} ${tool.successRate.toFixed(0)}%`, width: 20, color: getSuccessColor(tool.successRate) },
]} />
))}
</Box>
)}
{/* Model Comparison */}
{modelEntries.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>{t('Models')}</Text>
<TableRow cells={[
{ text: ' ' + t('Model'), width: 20, color: theme.text.secondary },
{ text: t('Reqs'), width: 7, color: theme.text.secondary },
{ text: 'In/Out', width: 14, color: theme.text.secondary },
{ text: t('Cache'), width: 7, color: theme.text.secondary },
{ text: t('Latency'), width: 8, color: theme.text.secondary },
]} />
{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 (
<TableRow key={name} cells={[
{ text: `● ${name.slice(0, 17)}`, width: 20, color: SERIES_COLORS[i % SERIES_COLORS.length] },
{ text: String(m.requests), width: 7, color: theme.text.primary },
{ text: `${fmtTokens(m.inputTokens)}/${fmtTokens(m.outputTokens)}`, width: 14, color: theme.text.primary },
{ text: `${cacheRate.toFixed(0)}%`, width: 7, color: getCacheColor(cacheRate) },
{ text: latency, width: 8, color: theme.text.accent },
]} />
);
})}
</Box>
)}
{/* Code Impact */}
{(data.report.files.linesAdded > 0 || data.report.files.linesRemoved > 0) && (
<Box>
<Text bold color={theme.text.primary}>{t('Code Impact')} </Text>
<Text color={theme.status.success}>+{data.report.files.linesAdded.toLocaleString()}</Text>
<Text color={theme.text.primary}> / </Text>
<Text color={theme.status.error}>-{data.report.files.linesRemoved.toLocaleString()}</Text>
<Text color={theme.text.secondary}> {t('net')}: </Text>
<Text color={theme.status.success}>
+{(data.report.files.linesAdded - data.report.files.linesRemoved).toLocaleString()}
</Text>
</Box>
)}
</Box>
);
};- Step 2: Wire EfficiencyTab into the main render
In the StatsDialog render body, replace activeTab === 'models' with:
{activeTab === 'efficiency' && !loading && data && (
<EfficiencyTab data={data} bodyWidth={bodyWidth} />
)}Remove the chartFilter state and the e key handler (no longer needed).
Update the hints text:
{
activeTab === 'session'
? t('tab · esc')
: t('tab · r dates · ←→ month · esc');
}- Step 3: Commit
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):
// 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
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
rcycles through today/week/month/all -
Verify ←→ navigates months in chart
-
Step 5: Commit
git add packages/cli/src/ui/components/StatsDialog.tsx
git commit -m "refactor(stats): remove dead ChartView/ModelsTab code"