feat(stats): add interactive /stats dashboard with cross-session tracking (#4779)

* docs(stats): add dashboard design spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(stats): add cross-session usage tracking service

Add usageHistoryService to core with JSONL-based persistence, session
replay from chat history with sessionId deduplication, time-range
aggregation, and per-model/tool/file breakdown including latency fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(stats): add stats data service and ASCII chart utilities

Add statsDataService for delta calculations, efficiency metrics, tool
leaderboard, and heatmap/trend data. Add asciiCharts with braille line
chart (Bresenham rendering) and GitHub-style contribution heatmap.
Includes 38 unit tests covering both modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(stats): implement interactive /stats dashboard

Add three-tab dialog: Session (live metrics), Activity (KPIs, heatmap,
braille token trend chart, project ranking), and Efficiency (cache rate,
tool success, latency cards, tool leaderboard, model comparison table).

Supports tab/shift-tab navigation, r to cycle time ranges (all/month/
week/today), left/right to pan months in the trend chart, esc to close.

Persist usage on /clear for accurate cross-session tracking. Update
statsCommand tests for new dialog behavior and clearCommand tests for
telemetry mock. Update /stats documentation in commands.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(i18n): add stats dashboard translations for all locales

Add translations for stats dashboard UI strings in zh, zh-TW, ca, de,
fr, ja, pt, ru. Add stats keys to en.js baseline and mustTranslateKeys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(stats): use terminal default background for inactive heatmap cells

Intensity 0 cells (no activity) now render without backgroundColor,
inheriting the terminal's native background instead of a hardcoded
color that renders incorrectly across different terminal themes.
Also fix green gradient direction: brighter = more activity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(stats): use dot markers for inactive heatmap cells

Inactive cells render as '··' with no background color instead of
colored blocks, matching common contribution graph designs. Active
cells keep their green gradient backgrounds. Fix gradient direction
so brighter green = more activity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(stats): restore full session stats from original StatsDisplay

Add back Session ID, Success Rate with color thresholds, User Agreement
rate, Performance breakdown (Wall Time, Agent Active, API Time %, Tool
Time %), and full token counts that were present in the original exit
screen but missing from the new Session tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(stats): address PR review — timezone bugs, double-count, cleanup

- Fix monthOffset overflow: setDate(1) before subtracting months to
  prevent day-count overflow (e.g. Mar 31 → Feb)
- Fix UTC date-parse off-by-one: append 'T00:00:00' to date-only
  strings in calculateStreaks and HeatmapView fmtDate
- Fix current session double-counted after rebuild: deduplicate by
  sessionId when injecting live session into loadStatsData
- Remove unused bodyWidth prop from SessionTab
- Remove 13 unused i18n keys (Overview, Favorite model, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(stats): add NaN guard, catch unhandled promise, fix useEffect race

- Guard against malformed chat records with NaN timestamps in rebuild
- Add .catch() to loadStatsData promise to prevent TUI crash
- Add stale flag to useEffect to prevent race on rapid range cycling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): sync locale files with en.js baseline for CI check

Add 19 missing translations to zh-TW.js, remove extra keys from
zh.js and zh-TW.js that were deleted from en.js in prior cleanup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(stats): use fake timers in getPreviousRangeBounds tests

The test compared new Date() in the assertion against new Date() inside
the function, which could differ by 1ms across a millisecond boundary.
Pin system time to prevent flaky CI failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): restore Session and Success keys removed in error

These keys are still referenced by t('Session') in stats-helpers.tsx
and t('Success') in StatsEfficiencyTab.tsx. Add to en.js baseline and
restore zh/zh-TW translations.

* fix(stats): address R3 review — malformed record guard, arrow fix, error state

- Skip malformed records in aggregateUsage (missing tools/files/models)
- Use Object.create(null) to prevent prototype pollution on model names
- Fix Avg Latency delta arrow direction (▼ for decrease, ▲ for increase)
- Clamp fmtSuccessBar to prevent RangeError on corrupt data
- Add error state UI when loadStatsData fails

* fix(stats): address R4 review — DST fix, token consistency, tests, cleanup

- Fix DST bug in getPreviousRangeBounds('today') using setDate
- Unify token counting: project ranking uses totalTokens (same as KPI)
- Add clearCommand tests for persistSessionUsage with/without activity
- Remove dead code (unreachable sorted.length check)
- Fix heatmap legend to use dot markers matching grid cells
- Add 'Failed to load stats' i18n key to en/zh/zh-TW

* fix(stats): include thoughtsTokens in totalTokens fallback calculation

* Revert "feat(input): move physical cursor to visual cursor for IME input (#4652)"

This reverts commit 77458ad21f.

* fix(stats): prevent Yoga layout from compressing StatsDialog content

Add flexShrink={0} to the outer Box of StatsDialog so that Yoga's
default flex-shrink behavior does not compress KPI cards, charts, and
tables when the dialog exceeds the available terminal height. The parent
container in DefaultAppLayout already applies height + overflow="hidden"
to clip overflow — this fix ensures content retains its natural size
instead of being squeezed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(stats): add debug logging to catch blocks and strengthen test assertion

- Add debugLogger to all catch blocks in usageHistoryService.ts for
  observability when file I/O or parsing fails
- Strengthen test assertion from toContain('Session duration') to
  toContain('Session duration: 0s') to verify the zero-duration fallback

---------

Co-authored-by: a.ran <benguanran.bgr@alibaba-inc.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
贲冠然 2026-06-10 14:59:08 +08:00 committed by GitHub
parent 32fbedabd6
commit 5adc4feaa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 5813 additions and 434 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<string, {
count: number;
success: number;
fail: number;
totalDurationMs?: number; // NEW: sum of tool execution time
}>;
};
}
```
### 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<string, number>` 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

View file

@ -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

195
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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());

View file

@ -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',
};

View file

@ -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',
};

View file

@ -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',
};

View file

@ -872,8 +872,7 @@ export default {
'La commande /fork nécessite le feature gate fork. Définissez QWEN_CODE_ENABLE_FORK_SUBAGENT=1 pour lactiver.',
'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',
};

View file

@ -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': '入力/出力',
};

View file

@ -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',
};

View file

@ -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': 'Вх/Вых',
};

View file

@ -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': '啟動伺服器',

View file

@ -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': '启动服务器',

View file

@ -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;

View file

@ -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,

View file

@ -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<typeof vi.fn>
).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<typeof vi.fn>
).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.');

View file

@ -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

View file

@ -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,
},
),
},
],
});

View file

@ -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 () => {

View file

@ -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(

View file

@ -188,7 +188,8 @@ export interface OpenDialogActionReturn {
| 'hooks'
| 'mcp'
| 'rewind'
| 'diff';
| 'diff'
| 'stats';
}
/**

View file

@ -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 <Text>{lineText || ' '}</Text>;
}
@ -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<string, unknown> & { 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<string, unknown>);
}
// ─── Component ──────────────────────────────────────────────
export const BaseTextInput = ({
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
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 ?? (
<Text color={theme.text.accent}>{'> '}</Text>
@ -357,7 +264,7 @@ export const BaseTextInput = ({
: '─'.repeat(columns);
return (
<Box ref={rootRef} flexDirection="column">
<Box flexDirection="column">
<Text color={resolvedBorderColor} wrap="truncate-end">
{topBorderLine}
</Text>

View file

@ -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 <HooksManagementDialog onClose={uiActions.closeHooksDialog} />;
}
if (uiState.isStatsDialogOpen) {
return (
<StatsDialog onClose={uiActions.closeStatsDialog} width={mainAreaWidth} />
);
}
if (uiState.isMcpDialogOpen) {
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
}

View file

@ -1629,15 +1629,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
</Text>
);
// 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<InputPromptProps> = ({
: placeholder
}
prefix={prefixNode}
prefixWidth={prefixWidth}
borderColor={borderColor}
topRightLabel={uiState.sessionName || undefined}
isActive={!isEmbeddedShellFocused}

View file

@ -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<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 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<string, number>();
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 (
<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 ? '\u25B2' : '\u25BC'}
{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 ? '\u25B2' : '\u25BC'}
{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 ? '\u25B2' : '\u25BC'}
{Math.abs(data.delta.tokens).toFixed(0)}%
</Text>
)}
</Box>
</Box>
{/* Heatmap with streak */}
<Box flexDirection="row">
<Box flexDirection="column" flexGrow={1}>
<HeatmapView
data={data}
weeks={heatmapWeeks}
monthOffset={clampedOffset}
/>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text color={theme.text.secondary}>{t('streak')}: </Text>
<Text color={theme.status.success} bold>
{data.currentStreak}
{t('d')}
</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>{t('best')}: </Text>
<Text color={theme.status.warning} bold>
{data.longestStreak}
{t('d')}
</Text>
</Box>
</Box>
</Box>
{/* Token Trend Chart */}
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold color={theme.text.primary}>
{t('Token Trend')}
</Text>
{chartMonthLabel && (
<Text color={theme.text.accent}>
{' '}
{canGoLeft ? '\u2190 ' : ' '}
{chartMonthLabel}
{canGoRight ? ' \u2192' : ''}
</Text>
)}
</Box>
{lineChart ? (
<Box flexDirection="column">
{lineChart.rows.map((row, ri) => (
<Box key={ri}>
<Text color={theme.text.secondary}>
{lineChart.yLabels[ri]?.padStart(6) ?? ' '}
{'\u2502'}
</Text>
{row.map((cell, ci) => (
<Text
key={ci}
color={
cell.filled ? theme.text.accent : theme.text.secondary
}
>
{cell.char}
</Text>
))}
</Box>
))}
<Box>
<Text color={theme.text.secondary}>
{' \u2514'}
{lineChart.xLabels}
</Text>
</Box>
<Box marginTop={0}>
<Text color={theme.text.secondary}>
{' '}peak {fmtTokens(lineChart.peak)}
</Text>
</Box>
</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.totalTokens;
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>
);
};

View file

@ -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 }) => (
<Box flexDirection="row">
{TAB_DEFS.map(({ tab, label }) => {
const active = tab === activeTab;
return (
<Box key={tab} marginLeft={tab === 'session' ? 0 : 1}>
<Text
color={active ? theme.background.primary : theme.text.primary}
backgroundColor={active ? theme.text.accent : undefined}
>
{` ${label()} `}
</Text>
</Box>
);
})}
</Box>
);
const RangeIndicator: React.FC<{ range: TimeRange }> = ({ range }) => (
<Box flexDirection="row" marginTop={1}>
{RANGE_CYCLE.map((r, i) => (
<Box key={r}>
<Text
bold={r === range}
color={r === range ? theme.text.accent : theme.text.secondary}
underline={r === range}
>
{getRangeLabel(r)}
</Text>
{i < RANGE_CYCLE.length - 1 && (
<Text color={theme.text.secondary}> · </Text>
)}
</Box>
))}
</Box>
);
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<StatsDialogProps> = ({ onClose, width }) => {
const [activeTab, setActiveTab] = useState<StatsTab>('session');
const [rangeIndex, setRangeIndex] = useState(0);
const [chartMonthOffset, setChartMonthOffset] = useState(0);
const [data, setData] = useState<StatsData | null>(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 (
<Box flexDirection="column" width={safeWidth} flexShrink={0}>
<Box
borderColor={theme.border.default}
borderStyle="single"
width={safeWidth}
>
<Box
flexDirection="column"
paddingX={2}
paddingY={1}
width={safeWidth - 2}
>
<StatsTabs activeTab={activeTab} />
<Box marginTop={1}>
{activeTab === 'session' && <SessionTab />}
{activeTab !== 'session' && loading && (
<Text color={theme.text.secondary}>{t('Loading stats...')}</Text>
)}
{activeTab !== 'session' && !loading && error && (
<Text color={theme.status.error}>
{t('Failed to load stats. Press r to retry.')}
</Text>
)}
{activeTab === 'activity' && !loading && data && (
<ActivityTab
data={data}
bodyWidth={bodyWidth}
chartMonthOffset={chartMonthOffset}
range={range}
/>
)}
{activeTab === 'efficiency' && !loading && data && (
<EfficiencyTab data={data} bodyWidth={bodyWidth} />
)}
</Box>
{activeTab !== 'session' && <RangeIndicator range={range} />}
<Box marginTop={1}>
<Text italic color={theme.text.secondary}>
{hintText}
</Text>
</Box>
</Box>
</Box>
</Box>
);
};

View file

@ -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 (
<Box flexDirection="column">
{/* Performance Cards Row */}
<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 ? '\u25B2' : '\u25BC'}{' '}
{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 ? '\u25B2' : '\u25BC'}{' '}
{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`
: '\u2014'}
</Text>
{data.delta?.avgLatency != null && (
<Text
color={
data.delta.avgLatency <= 0
? theme.status.success
: theme.status.error
}
>
{data.delta.avgLatency <= 0 ? '\u25BC' : '\u25B2'}{' '}
{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: 16,
color: theme.text.secondary,
},
{ text: t('Calls'), width: 10, color: theme.text.secondary },
{ text: t('Time'), width: 12, color: theme.text.secondary },
{ text: t('Success'), width: 22, color: theme.text.secondary },
]}
/>
{data.toolLeaderboard.map((tool) => (
<TableRow
key={tool.name}
cells={[
{
text: ' ' + tool.name.slice(0, 14),
width: 16,
color: theme.text.accent,
},
{
text: String(tool.count),
width: 10,
color: theme.text.primary,
},
{
text: `${(tool.totalDurationMs / 1000).toFixed(1)}s`,
width: 12,
color: theme.text.secondary,
},
{
text: `${fmtSuccessBar(tool.successRate)} ${tool.successRate.toFixed(0)}%`,
width: 22,
color: getSuccessColor(tool.successRate),
},
]}
/>
))}
</Box>
)}
{/* Model Comparison Table */}
{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: t('In/Out'), width: 14, color: theme.text.secondary },
{ text: t('Thoughts'), width: 9, 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 =
m.totalLatencyMs > 0 && m.requests > 0
? `${(m.totalLatencyMs / m.requests / 1000).toFixed(1)}s`
: '\u2014';
return (
<TableRow
key={name}
cells={[
{
text: `\u25CF ${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: fmtTokens(m.thoughtsTokens),
width: 9,
color: theme.text.secondary,
},
{
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={
data.report.files.linesAdded - data.report.files.linesRemoved >= 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()}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -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 (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('Activity Heatmap')}
</Text>
<Text color={theme.text.accent}>
{' '}
{fmtDate(heatmap.startDate)} - {fmtDate(heatmap.endDate)}
</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>{' '}</Text>
{(() => {
const labelAt = new Map<number, string>();
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(
<Text key={c} color={theme.text.secondary}>
{label.padEnd(4)}
</Text>,
);
skipCols = 1;
} else if (label) {
out.push(
<Text key={c} color={theme.text.secondary}>
{label.padEnd(2)}
</Text>,
);
} else {
out.push(<Text key={c}>{' '}</Text>);
}
}
return out;
})()}
</Box>
{heatmap.rows.map((row, ri) => (
<Box key={ri}>
<Text color={theme.text.secondary}>{row.label}</Text>
{row.cells.map((cell, ci) => (
<Text
key={ci}
backgroundColor={
cell.intensity > 0 ? HEATMAP_COLORS[cell.intensity] : undefined
}
underline={cell.isToday}
>
{cell.char}
</Text>
))}
</Box>
))}
<Text> </Text>
<Box>
<Text color={theme.text.secondary}>
{' '}
{t('Less')}{' '}
</Text>
{([0, 1, 2, 3, 4] as const).map((level) => (
<Text
key={level}
backgroundColor={level > 0 ? HEATMAP_COLORS[level] : undefined}
>
{level === 0 ? '\u00B7\u00B7' : ' '}
</Text>
))}
<Text color={theme.text.secondary}> {t('More')}</Text>
</Box>
</Box>
);
};

View file

@ -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 (
<Box flexDirection="column">
{/* Session ID */}
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Session ID:')}</Text>
</Box>
<Text color={theme.text.primary}>{stats.sessionId}</Text>
</Box>
{/* Interaction Summary */}
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Interaction Summary')}
</Text>
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Tool Calls:')}</Text>
</Box>
<Text color={theme.text.primary}>
{metrics.tools.totalCalls} ({' '}
<Text color={theme.status.success}>
{metrics.tools.totalSuccess}
</Text>{' '}
<Text color={theme.status.error}> {metrics.tools.totalFail}</Text>{' '}
)
</Text>
</Box>
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Success Rate:')}</Text>
</Box>
<Text color={successColor}>{computed.successRate.toFixed(1)}%</Text>
</Box>
{computed.totalDecisions > 0 && (
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('User Agreement:')}</Text>
</Box>
<Text color={agreementColor}>
{computed.agreementRate.toFixed(1)}%{' '}
<Text color={theme.text.secondary}>
({computed.totalDecisions} {t('reviewed')})
</Text>
</Text>
</Box>
)}
{(metrics.files.totalLinesAdded > 0 ||
metrics.files.totalLinesRemoved > 0) && (
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Code Changes:')}</Text>
</Box>
<Text color={theme.status.success}>
+{metrics.files.totalLinesAdded}
</Text>
<Text color={theme.text.primary}> </Text>
<Text color={theme.status.error}>
-{metrics.files.totalLinesRemoved}
</Text>
</Box>
)}
</Box>
{/* Performance */}
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Performance')}
</Text>
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Wall Time:')}</Text>
</Box>
<Text color={theme.text.primary}>{formatDuration(wallDuration)}</Text>
</Box>
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Agent Active:')}</Text>
</Box>
<Text color={theme.text.primary}>
{formatDuration(computed.agentActiveTime)}
</Text>
</Box>
<Box paddingLeft={2}>
<Box width={26}>
<Text color={theme.text.secondary}>» {t('API Time:')}</Text>
</Box>
<Text color={theme.text.primary}>
{formatDuration(computed.totalApiTime)}{' '}
<Text color={theme.text.secondary}>
({computed.apiTimePercent.toFixed(1)}%)
</Text>
</Text>
</Box>
<Box paddingLeft={2}>
<Box width={26}>
<Text color={theme.text.secondary}>» {t('Tool Time:')}</Text>
</Box>
<Text color={theme.text.primary}>
{formatDuration(computed.totalToolTime)}{' '}
<Text color={theme.text.secondary}>
({computed.toolTimePercent.toFixed(1)}%)
</Text>
</Text>
</Box>
</Box>
{/* Token Summary */}
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Tokens')}
</Text>
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Input')}:</Text>
</Box>
<Text color={theme.status.warning}>
{totalInput.toLocaleString()}
</Text>
</Box>
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Output')}:</Text>
</Box>
<Text color={theme.status.warning}>
{totalOutput.toLocaleString()}
</Text>
</Box>
{totalCached > 0 && (
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Cached')}:</Text>
</Box>
<Text color={theme.status.success}>
{totalCached.toLocaleString()} ({cacheRate.toFixed(1)}%)
</Text>
</Box>
)}
</Box>
{/* Models */}
{Object.keys(metrics.models).length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Models')}
</Text>
{Object.entries(metrics.models).map(([name, m], i) => (
<Box key={name}>
<Text color={SERIES_COLORS[i % SERIES_COLORS.length]}> </Text>
<Text color={theme.text.primary}>{name} </Text>
<Text color={theme.text.secondary}>
{m.api.totalRequests} {t('reqs')} · {t('in')}=
{fmtTokens(m.tokens.prompt)} · {t('out')}=
{fmtTokens(m.tokens.candidates)}
</Text>
</Box>
))}
</Box>
)}
</Box>
);
};

View file

@ -257,7 +257,6 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
{approvalModePromptStyle.prefix}{' '}
</Text>
);
const prefixWidth = 2; // "> " or "* " = 2 chars
return (
<StreamingContext.Provider value={streamingState}>
@ -290,7 +289,6 @@ export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
showCursor={isInputActive && !agentTabBarFocused}
placeholder={' ' + t('Send a message to this agent')}
prefix={prefixNode}
prefixWidth={prefixWidth}
borderColor={inputBorderColor}
isActive={isInputActive && !agentShellFocused}
/>

View file

@ -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<HeatmapIntensity, string> {
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<string, string> = {
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 }) => (
<Box>
{cells.map((cell, i) => (
<Box key={i} width={cell.width}>
<Text color={cell.color} bold={cell.bold}>
{cell.text}
</Text>
</Box>
))}
</Box>
);

View file

@ -94,6 +94,7 @@ export interface UIActions {
openHooksDialog: () => void;
// Hooks dialog
closeHooksDialog: () => void;
closeStatsDialog: () => void;
// Resume session dialog
openResumeDialog: () => void;
closeResumeDialog: () => void;

View file

@ -167,6 +167,7 @@ export interface UIState {
isMcpDialogOpen: boolean;
// Hooks dialog
isHooksDialogOpen: boolean;
isStatsDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
// Per-task token tracking

View file

@ -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' };

View file

@ -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

View file

@ -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 };
};

View file

@ -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<string, number> = {
'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<string, number> = { '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<string, number> = {
'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<string, number> = { '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);
});
});

View file

@ -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<string, number>,
): [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<string, number>,
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<number>();
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<string>();
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 };
}

View file

@ -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<typeof import('@qwen-code/qwen-code-core')>();
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>,
): 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>): 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);
});
});

View file

@ -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<string, number>;
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<string, number> {
const heatmap: Record<string, number> = {};
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<string, number>();
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<StatsData> {
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,
};
}

View file

@ -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';
// ============================================================================

View file

@ -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>): 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>,
): 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([]);
});
});

View file

@ -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<UsageSummaryRecord[]> {
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<string>();
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<ChatRecord>(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<UsageSummaryRecord[]> {
try {
const records = await jsonl.read<UsageSummaryRecord>(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,
};
}

View file

@ -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,

View file

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