diff --git a/CHANGELOG.md b/CHANGELOG.md index 075e06ea..e1c08aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,60 @@ All notable changes to this fork compared to upstream [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) (last upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangelog.com). +## [2.3.2] — Unreleased + +### Added +- **Project activity matrix (brought forward from the planned v2.3.3)** — the + existing Projects page now gives Claude and Codex the same Token-only 30/90-day + project × day heatmap and daily stacked trend. Exact tooltips and explicit + complete/partial coverage keep the view auditable; bounded project rows, + matrix cells, and trend series roll the long tail into **Other projects**. + It reuses provider indexes already built during normal refresh, so opening, + switching, or expanding the view performs no source-JSONL read and adds no + watcher, timer, cache, worker, dependency, or network path. +- **Curated display currencies (#91)** — a single compact Settings dropdown now + selects USD (default) or one of thirteen common display currencies. Conversion + uses a bundled 2026-09-09 ECB-derived snapshot; rates are deterministic, + offline, and not user-editable. Stored prices, aggregation, sorting, and + persistence remain in USD, converted estimates carry an `≈` marker, and + provider-native usage credits are never converted. +- **Complete chart drill-down paths** — both providers support All time month → + day and Last 30 days day → hour expansion wherever materialized aggregates + exist. Mouse, Enter, and Space share the same selection, disclosure, focus, + nested-collapse, and reload-restoration behavior without reading JSONL on + click. +- **Provider-qualified chart names** — chart regions and heatmaps expose stable, + localized provider, scope, chart-type, and selected-metric names. Names update + with metric switches and remain unique across Compare. + +### Changed +- **Project insight schedule** — the formerly planned v2.3.3 project matrix is + included in this v2.3.2 candidate; the later roadmap now starts after X-06 + instead of carrying a duplicate implementation phase. +- **State-preserving live refresh** — ordinary updates replace only the active + provider panel and preserve the selected tab, drill-down chain, chart/hour + selection, temporary Optimizer input, keyboard focus, and nearest scroll + anchor. Structural changes and failed delivery still fall back safely to a + complete Webview document. +- **Exact dashboard ranges** — Today remains 24 configured-zone hours and Last + 30 days remains today plus the preceding 29 calendar dates. Missing buckets + are represented as zero without extending source aggregates. +- **Quieter hourly charts** — zero-usage hours retain their axis positions, + table rows, click details, tooltips, and accessible values, but no longer + repeat `0` above every empty bar. Real activity with unavailable pricing still + displays `—`. + +### Fixed +- **Resilient provider watchers** — failed Claude or Codex watchers re-arm with + bounded exponential backoff while polling remains available; recovery and + disposal cannot create a retry hot loop. +- **Efficient Codex title lookup** — validated file identity and stat metadata + avoid repeatedly streaming an unchanged `session_index.jsonl`; titles remain + memory-only and path-redacted. +- **Quota and Webview boundaries** — bounded quota compaction preserves series + endpoints and reset boundaries, and dynamic sharing failures render as text + instead of interpreted HTML. + ## [2.3.1] — Unreleased ### Added diff --git a/LOCAL-DATA.md b/LOCAL-DATA.md index c8a50812..65785be4 100644 --- a/LOCAL-DATA.md +++ b/LOCAL-DATA.md @@ -4,8 +4,8 @@ Claude Code Usage is local-first. It reads provider-owned usage logs, stores only bounded derived state needed for fast refresh and reset-aware estimates, and never treats API-equivalent cost as a bill. -This page is the user-facing inventory for v2.3.1. The normative field-level -contract is in +This page is the user-facing inventory for the v2.3.x development line. The +v2.3.1 normative field-level contract is in [`docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md). ## What the extension reads and retains @@ -16,7 +16,7 @@ contract is in | Codex usage records | `$CODEX_HOME/sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl` | A versioned incremental index of pseudonymous file keys, offsets, numeric usage, dates, model/effort, and sanitized structural aggregates | Until rebuild, explicit clear, schema replacement, or host removal | None | | Codex titles | Exactly `$CODEX_HOME/session_index.jsonl` | `id → thread_name` is streamed for runtime display; titles are not written to the index | Runtime only | None | | Quota observations | Claude's official quota response or structured Codex rate-limit events | Provider, machine-local anonymous account epoch, observed/reset time, period, used/remaining fraction, anonymous window identity, source, confidence, and quality flags | At most 180 days and 512 observations per provider/account/period after boundary-preserving compaction; clear separately or with all derived data | Claude quota lookup contacts Anthropic when enabled; Codex quota evidence is local | -| Settings and UI preferences | User choices | Typed extension settings, selected tab/filters, bounded background-work state, heatmap title/range/privacy preview | Until reset, clear, or uninstall; ordinary VS Code settings may participate in Settings Sync | No extension-initiated transfer | +| Settings and UI preferences | User choices | Typed extension settings, selected tab/filters, bounded background-work state, heatmap title/range/privacy preview, and the optional display-currency preset | Until reset, clear, or uninstall; ordinary VS Code settings may participate in Settings Sync | No extension-initiated transfer; no exchange-rate lookup | | Share destination | User-entered GitHub target | Optional `owner/repository/path`; no GitHub credential | Until sharing preferences are reset | Only after an explicit publish action and exact destination confirmation | | Advice evidence | Derived local aggregates and explicit feedback | Coarse observations, recommendations, ratings, snoozes, comparable-task metrics, coverage, and versions | Bounded local ledger; clear independently | Nothing by default; only the exact previewed request is sent after a separate action | | Advice API key | User-provided secret | Secret value in VS Code SecretStorage only | Until the key or all derived data is cleared | Used only as the authorization credential for the explicitly configured endpoint | @@ -37,6 +37,14 @@ cannot establish one coherent account/window, the dashboard shows only used API-equivalent value and explains why it cannot claim an account-level unused allowance. +Display-currency conversion is presentation-only. Pricing, aggregation, +persistence, sorting, and comparisons remain USD-denominated. The extension +stores only one validated preset code and resolves it against bundled reference +rates dated 2026-09-09. Rates are not editable and no lookup is performed. +Converted estimates carry `≈`; provider-native money such as actual usage +credits remains in its reported currency. The snapshot derives currency-per-USD +values from the [ECB euro reference rates](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html). + ## Combined heatmap and exports The Compare heatmap reuses daily provider aggregates; it does not scan logs diff --git a/LOCAL-DATA.zh-CN.md b/LOCAL-DATA.zh-CN.md index 6fb06ea5..16fc4b0b 100644 --- a/LOCAL-DATA.zh-CN.md +++ b/LOCAL-DATA.zh-CN.md @@ -4,7 +4,7 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 并保留快速刷新、重置识别和额度估算所必需的有界派生状态;API 等效成本始终 只是估算,不是账单。 -本文是 v2.3.1 面向用户的数据清单。字段级规范见 +本文是 v2.3.x 开发线面向用户的数据清单。v2.3.1 字段级规范见 [`docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md`](docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md)。 ## 插件读取和保留什么 @@ -15,7 +15,7 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 | Codex 用量记录 | `$CODEX_HOME/sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl` | 版本化增量索引:匿名文件键、偏移量、数值用量、日期、模型/effort 和已净化结构聚合 | 保留到重建、明确清除、schema 替换或宿主移除 | 无 | | Codex 标题 | 仅 `$CODEX_HOME/session_index.jsonl` | 流式读取 `id → thread_name` 供运行时显示;标题不写入索引 | 仅运行时 | 无 | | 额度观测 | Claude 官方额度响应或 Codex 结构化 rate-limit 事件 | 供应商、本机匿名账号 epoch、观测/重置时间、周期、已用/剩余比例、匿名窗口标识、来源、可信度和质量标志 | 保留不超过 180 天;边界保护压缩后每个供应商/账号/周期最多 512 条;可单独或随全部派生数据清除 | 启用时 Claude 额度查询会访问 Anthropic;Codex 额度证据只来自本地 | -| 设置与 UI 偏好 | 用户选择 | 类型化设置、标签页/筛选、有限后台任务状态、热力图标题/范围/隐私预览 | 保留到重置、清除或卸载;普通 VS Code 设置可能参与 Settings Sync | 插件不主动传输 | +| 设置与 UI 偏好 | 用户选择 | 类型化设置、标签页/筛选、有限后台任务状态、热力图标题/范围/隐私预览,以及可选显示币种预设 | 保留到重置、清除或卸载;普通 VS Code 设置可能参与 Settings Sync | 插件不主动传输,也不查询汇率 | | 分享目标 | 用户输入的 GitHub 目标 | 可选 `owner/repository/path`;不保存 GitHub 凭据 | 保留到重置分享偏好 | 仅在用户明确发布并确认精确目标后发生 | | 建议证据 | 本地派生聚合与明确反馈 | 粗粒度观察、建议、评分、暂停、可比任务指标、覆盖率和版本 | 有界本地台账;可独立清除 | 默认不发送;只有单独确认后才发送精确预览过的请求 | | 建议 API Key | 用户自备密钥 | 仅存于 VS Code SecretStorage | 保留到清除密钥或全部派生数据 | 只作为用户明确配置端点的授权凭据 | @@ -32,6 +32,13 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 不同匿名账号 fingerprint 不会合并。如果本地 Codex 日志无法证明属于同一账号 和同一窗口,仪表盘只显示已用 API 等效价值,并说明为何不能声称账号级未用额度。 +显示币种换算只作用于呈现层。定价、聚合、持久化、排序和比较仍全部以 USD 为基准。 +插件只保存一个通过校验的币种预设代码,并使用日期为 2026-09-09 的内置参考汇率; +汇率不可调整,也不会联网查询。换算后的估算值统一标记 `≈`,供应商原生金额 +(例如真实 usage credits)继续按其上报币种显示。币种兑 USD 数值由 +[欧洲央行欧元参考汇率](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) +换算得到。 + ## 综合热力图与导出 “对比”热力图直接复用供应商日聚合,不会再次扫描日志,也不会新增第二套统计缓存。 diff --git a/README-en.md b/README-en.md index ab665cea..b8bf7eb0 100644 --- a/README-en.md +++ b/README-en.md @@ -26,7 +26,7 @@ Hover the quota indicator for a breakdown: ![Dashboard](images/v2-dashboard-en.png) -### v2.3.1 Codex and Compare +### v2.3 Codex and Compare ![Claude Today, Simplified Chinese, dark theme](images/v2.3.1/claude-today-zh-CN-dark.png) @@ -36,11 +36,14 @@ Hover the quota indicator for a breakdown: ![Combined Claude and Codex heatmap, English, light theme](images/v2.3.1/compare-heatmap-en-light.png) -These four v2.3.1 captures use the production renderer, synthetic fixtures, and VS Code Light+/Dark+ theme variables—not personal usage or billing evidence. Native VSIX installation is checked separately. +![Project activity matrix, English, dark theme](images/v2.3.2/project-activity-matrix-en-dark.png) + +These five v2.3 captures use the production renderer, synthetic fixtures, and VS Code Light+/Dark+ theme variables—not personal usage or billing evidence. Native VSIX installation is checked separately. - Codex shows **Today token usage** and separate **remaining quota**: 36% used means `wk 64%`. Hover shows utilisation bars, resets, and wrapped notes. Quota is last-observed local evidence, not a live balance. - Period details start collapsed; sharing settings sit below the preview. Sharing is on by default with an off switch and quantile, logarithmic, or linear intensity. - CLI calls count only when persistent sessions leave usage-bearing logs. Non-persisted calls cannot be backfilled; Today and Last 30 days use the configured timezone. +- Projects adds a Token-only 30/90-day project × day heatmap and stacked daily trend for both providers, with exact tooltips, explicit coverage, bounded rows, and an Other-projects tail. ## Features @@ -53,34 +56,14 @@ These four v2.3.1 captures use the production renderer, synthetic fixtures, and - **Multi-vendor pricing** — Opus 4.x / Sonnet 4.x / Haiku 4.5 verified against Anthropic's public pricing; reference rates for OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen with family-aware fallback. `Refresh Token Pricing` pulls live LiteLLM data. - **Personalisation** — language, timezone, decimal places, compact numbers, project grouping, dashboard auto-refresh toggle. -## What's new in v2.3.1 - -- Exact pricing and context metadata now cover **GPT-6 Astra** - (`gpt-6-astra`, 1.05M context) and **Claude Fable 5.1 / Mythos 5.1** - (`claude-fable-5-1` / `claude-mythos-5-1`, 1M context). Fable 5.1 uses its - model-specific `$0.25 / MTok` cache-read rate without changing historical - Fable 5 data. GPT-6 API-equivalent values use the official Standard - short-context rates; the request-wide surcharge above 272K input is excluded - because aggregate local logs cannot establish that per-request threshold. -- Claude estimates can optionally use direct Anthropic or AWS Bedrock in-region - on-demand rates for current Opus, Sonnet, and Haiku families. Switching the - backend reprices unchanged logs immediately; Sonnet 5 uses the standard rate - after its launch promotion ended on 31 August 2026. -- Last 30 days is the configured-timezone current date plus the preceding 29 dates. Today ≤ Last 30 days ≤ All time, repeated refresh/reindex is stable, all breakdowns reconcile, and months run oldest-first. Claude's Workflows summary uses the same rolling range and assigns each run by its configured-zone start date. Chart metric switches and drill-down labels render daily/monthly usage keys without host-timezone rollback; Advice and Optimizer snooze dates also use the configured timezone and UI locale. -- Structured reasoning-effort variants normalize without model-name guessing. Non-zero unknown is explained; zero unknown is hidden. -- Bounded anonymous quota observations preserve irregular and same-day resets. Every valid same-window used fraction contributes to labelled total and unused subscription-durability estimates, including the current window. Approximate or unattributed evidence is low confidence. If local Codex quota series overlap, the current window uses the latest real observation for a low-confidence blended estimate; ambiguous completed windows remain used-only. Unused value never becomes negative. -- Compare now leads with a combined Claude + Codex activity heatmap that reuses daily aggregates, works with either provider alone, and keeps both components in every tooltip. -- The preview-first share studio supports deterministic local SVG, a combined card, copyable Markdown, title/range controls, a privacy preview, and curated/custom colors. It is on by default and can be hidden with one Settings switch. Academic Violet is the default; intensity can use quantile (default), logarithmic, or linear mapping. Optional public-GitHub publication is separately confirmed at the exact destination. -- Codex Token composition now shows **uncached usage** (uncached input + output) above the non-overlapping uncached-input / cached-input / output stack; reasoning remains a subset of output. -- Codex scrolling stays smooth while indexing: scroll state is saved once after a gesture and live index progress patches text without rebuilding the page. Per-model green headlines now show exact-model API-equivalent price with coverage help; unknown models show `—`, while effort headlines retain neutral uncached-token values. -- OpenAI-compatible optimizer requests now send `reasoning_effort` without the unsupported top-level `thinking` parameter, resolving [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94). -- Claude and Codex now share dashboard density, hierarchy, disclosure, focus, ARIA, narrow-width, and light/dark design tokens while retaining provider-specific metric meanings. -- Get AI Advice and Usage Optimizer share one exact-preview and explicit-send boundary. There is no default or background AI request, and local evidence/feedback remains bounded. - -The reviewed draft is `v2.3.1`; the human-controlled publish workflow stamps package metadata from that release tag. - -## Codex Beta in v2.3 +## What's new in v2.3 +- **Refined throughout the v2.3 line** — GPT-6 Astra and Fable 5.1 model + metadata, optional AWS Bedrock pricing, a fixed-reference display-currency + selector, a Token-only 30/90-day project activity matrix, complete + month/day/hour drill-downs, state-preserving refresh, accessible charts, and + lower watcher/title-index overhead. Patch-level + details stay in the changelog and GitHub Releases. - Codex usage records are discovered only from `sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl`; credential, database, and unknown files remain excluded. Separately, the extension streams exactly `$CODEX_HOME/session_index.jsonl` to map `id` to `thread_name` for truthful thread titles. Absolute paths are redacted and titles remain memory-only. Usage-record JSONL lines are streamed and temporarily parsed only to extract allowlisted usage and structural metadata; prompt, response, command, and tool-argument fields are not inspected or used for analysis, and are never retained or persisted. - **Processed** means input + output, **uncached usage** means uncached input + output, **cached input** remains a subset of input, and reasoning remains a subset of output. The overview also shows input cache hit rate as cached input / input. Codex billing cost is not shown. Its first summary card is a clearly labelled API-equivalent cost estimate for the selected scope, and the All-time view shows the weekly trend on the same pricing basis; Claude / Codex / Compare keep each provider's accounting separate. - Codex **Today** means the current calendar day in the configured timezone. It adds exact hourly API-equivalent cost beside a separate token-composition view; daily and monthly primary charts also default to API-equivalent cost while token composition stays separately visible. Only exact known-model prices contribute, so unknown models remain unpriced and pricing coverage stays visible. The additive, schema-3-compatible hourly sidecar keeps sparse buckets for the rolling last 30 days, is checkpointed and resumable, evicts day 31, and serves date expansion without reading JSONL on click. Codex monthly charts and tables list months oldest-first. diff --git a/README-id.md b/README-id.md index 603ef099..1bcb16fa 100644 --- a/README-id.md +++ b/README-id.md @@ -26,7 +26,7 @@ Arahkan kursor ke indikator kuota untuk melihat rinciannya: ![Dashboard](images/v2-dashboard-en.png) -### Codex dan Perbandingan v2.3.1 +### Codex dan Perbandingan v2.3 ![Claude hari ini, Tionghoa Sederhana, tema gelap](images/v2.3.1/claude-today-zh-CN-dark.png) @@ -36,11 +36,14 @@ Arahkan kursor ke indikator kuota untuk melihat rinciannya: ![Heatmap gabungan Claude dan Codex, Inggris, tema terang](images/v2.3.1/compare-heatmap-en-light.png) -Empat gambar v2.3.1 ini menggunakan renderer produksi, data sintetis, dan variabel tema VS Code Light+/Dark+, bukan bukti penggunaan pribadi atau tagihan. Instalasi VSIX native diverifikasi secara terpisah. +![Matriks aktivitas proyek, Inggris, tema gelap](images/v2.3.2/project-activity-matrix-en-dark.png) + +Lima gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel tema VS Code Light+/Dark+, bukan bukti penggunaan pribadi atau tagihan. Instalasi VSIX native diverifikasi secara terpisah. - Codex menampilkan **token terpakai hari ini** dan **sisa kuota** secara terpisah: penggunaan mingguan 36% berarti `wk 64%`. Tooltip tetap menampilkan bilah penggunaan, waktu reset, dan catatan berbaris. Kuota berasal dari pengamatan lokal terakhir, bukan saldo langsung. - Detail periode tertutup secara default; pengaturan berbagi berada di bawah pratinjau. Berbagi aktif secara default dan dapat dimatikan, dengan intensitas kuantil, logaritmik, atau linear. - Panggilan CLI dihitung hanya jika sesi persisten menyimpan log dengan usage. Panggilan tanpa log tidak dapat dipulihkan; Hari ini dan 30 hari terakhir memakai zona waktu yang dikonfigurasi. +- Projects menambahkan heatmap Token 30/90 hari “proyek × tanggal” dan tren harian bertumpuk untuk kedua penyedia, dengan tooltip presisi, cakupan eksplisit, baris terbatas, dan ekor “Proyek lain”. ## Fitur @@ -53,34 +56,14 @@ Empat gambar v2.3.1 ini menggunakan renderer produksi, data sintetis, dan variab - **Harga multi-vendor** — Opus 4.x / Sonnet 4.x / Haiku 4.5 diverifikasi terhadap harga publik Anthropic; tarif referensi untuk OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen dengan fallback berbasis family model. `Refresh Token Pricing` menarik data LiteLLM langsung. - **Personalisasi** — bahasa, zona waktu, angka desimal, angka ringkas, pengelompokan proyek, toggle penyegaran otomatis dashboard. -## Yang baru di v2.3.1 - -- Metadata harga dan konteks yang tepat kini mencakup **GPT-6 Astra** - (`gpt-6-astra`, konteks 1,05 juta token) serta **Claude Fable 5.1 / Mythos - 5.1** (`claude-fable-5-1` / `claude-mythos-5-1`, konteks 1 juta token). - Fable 5.1 memakai tarif baca cache khusus `$0.25 / juta token` tanpa mengubah - riwayat Fable 5. Nilai ekuivalen API GPT-6 memakai tarif Standard resmi untuk - konteks pendek. Biaya tambahan untuk seluruh permintaan di atas 272K input - tidak disertakan karena log agregat lokal tidak dapat memastikan ambang per - permintaan tersebut. -- Estimasi Claude dapat memakai tarif langsung Anthropic atau tarif on-demand - AWS Bedrock in-region untuk keluarga Opus, Sonnet, dan Haiku saat ini. - Mengganti backend langsung menghitung ulang log yang tidak berubah; Sonnet 5 - memakai tarif standar setelah promosi peluncurannya berakhir 31 Agustus 2026. -- 30 hari terakhir berarti hari ini dan 29 tanggal sebelumnya dalam zona waktu konfigurasi. Total dapat direkonsiliasi, refresh berulang stabil, dan bulan tampil dari lama ke baru. Ringkasan Workflows Claude memakai rentang bergulir yang sama dan memasukkan setiap eksekusi berdasarkan tanggal mulai di zona waktu konfigurasi. Saat metrik grafik diganti atau label drill-down dirender, kunci penggunaan harian/bulanan tidak lagi mundur sehari atau sebulan akibat zona waktu host. Tanggal berakhirnya penundaan Saran AI dan Usage Optimizer juga memakai zona waktu konfigurasi dan bahasa UI. -- Reasoning effort terstruktur tidak ditebak dari nama model. `unknown` bukan nol dijelaskan; nilai nol disembunyikan. -- Pengamatan kuota anonim yang terbatas mempertahankan reset tidak teratur dan pada hari yang sama. Fraksi valid dalam satu jendela menghasilkan estimasi total dan sisa daya tahan langganan, termasuk periode berjalan. Bukti perkiraan atau tanpa atribusi diberi keyakinan rendah. Jika series kuota Codex lokal tumpang tindih, periode berjalan memakai pengamatan nyata terbaru untuk estimasi gabungan berkeyakinan rendah; periode selesai yang atribusinya ambigu tetap hanya menampilkan nilai terpakai. -- Perbandingan menampilkan heatmap aktivitas gabungan Claude + Codex dari agregat harian yang sudah ada dan tetap berfungsi dengan satu penyedia. -- Studio berbagi yang mengutamakan pratinjau mencakup SVG lokal deterministik, kartu, Markdown, judul/rentang, pratinjau privasi, dan warna yang dapat disesuaikan. Academic Violet menjadi default; pemetaan intensitas dapat memakai kuantil (default), logaritmik, atau linear. Publikasi GitHub publik mengonfirmasi tujuan tepat secara terpisah. -- Komposisi Token Codex sekarang menampilkan **penggunaan tanpa cache** (input tanpa cache + output) di atas stack input tanpa cache / input cache / output yang tidak tumpang tindih; reasoning tetap merupakan bagian dari output. -- Codex tetap dapat digulir dengan mulus saat pengindeksan: status gulir disimpan sekali setelah gerakan selesai dan progres indeks langsung hanya memperbarui teks tanpa membangun ulang halaman. Ringkasan hijau per model kini mengikuti makna Claude, yaitu harga ekuivalen API untuk model yang cocok tepat beserta cakupan harga. Model tak dikenal menampilkan `—`, sedangkan effort tetap memakai nilai Token tanpa cache berwarna netral. -- Permintaan Optimizer yang kompatibel dengan OpenAI kini hanya mengirim `reasoning_effort` tanpa parameter tingkat atas `thinking` yang tidak didukung, menyelesaikan [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94). -- Claude/Codex berbagi token desain untuk kepadatan, hierarki, fokus, ARIA, lebar sempit, serta tema terang/gelap tanpa menyamakan arti metrik. - -Draft Release yang ditinjau adalah `v2.3.1`; alur publikasi manual menetapkan versi paket dari tag tersebut. - -## Codex Beta di v2.3 +## Yang baru di v2.3 +- **Penyempurnaan di seluruh seri v2.3** — metadata model GPT-6 Astra dan + Fable 5.1, harga AWS Bedrock opsional, pilihan mata uang tampilan dengan kurs + referensi tetap, matriks aktivitas proyek khusus Token 30/90 hari, drill-down + bulan/hari/jam lengkap, refresh yang mempertahankan state, grafik aksesibel, + serta beban watcher dan indeks judul yang lebih rendah. + Detail per patch hanya disimpan di CHANGELOG dan GitHub Releases. - Catatan penggunaan Codex hanya ditemukan dari `sessions/**/*.jsonl` dan `archived_sessions/**/*.jsonl`; file kredensial, database, dan file tak dikenal tetap dikecualikan. Secara terpisah, ekstensi hanya melakukan streaming terhadap `$CODEX_HOME/session_index.jsonl` untuk memetakan `id` ke `thread_name` bagi judul thread yang sebenarnya. Jalur absolut dalam judul disamarkan dan judul hanya disimpan di memori. Setiap baris JSONL penggunaan di-stream dan diparse sementara hanya untuk mengambil metadata penggunaan dan struktur dalam daftar izin; field prompt, respons, perintah, dan argumen alat tidak diperiksa atau dipakai untuk analisis, serta tidak pernah disimpan atau dipersistenkan. - **Diproses** = input + output, **Penggunaan tanpa cache** = Input tanpa cache + output, **cached input** tetap bagian dari input, dan reasoning bagian dari output. Ringkasan juga menampilkan tingkat hit cache input sebagai cached input / input. Biaya tagihan Codex tidak ditampilkan. Kartu ringkasan pertama menampilkan estimasi biaya ekuivalen API untuk cakupan terpilih dengan label yang jelas, sedangkan tampilan Sepanjang Waktu menunjukkan tren mingguan dengan dasar harga yang sama. Claude / Codex / Compare tetap memisahkan makna tiap penyedia. - **Hari Ini** pada Codex berarti hari kalender saat ini dalam zona waktu yang dikonfigurasi. Tampilan ini menambahkan biaya ekuivalen API per jam yang tepat di samping tampilan komposisi Token yang terpisah. Grafik utama harian dan bulanan juga memakai biaya ekuivalen API sebagai default, sementara komposisi Token tetap tersedia secara terpisah. Hanya harga model yang dikenal dan cocok tepat yang dihitung; model tak dikenal tetap tanpa harga dan cakupan harga selalu terlihat. Sidecar per jam yang bersifat tambahan dan kompatibel dengan schema 3 menyimpan bucket tanggal / jam yang jarang untuk 30 hari bergulir, mendukung checkpoint dan resume, membuang hari ke-31, dan membuka tanggal tanpa membaca JSONL. Grafik dan tabel bulanan Codex menampilkan bulan dari yang paling lama ke yang terbaru. diff --git a/README-ja.md b/README-ja.md index 5ec8ebd9..17f90839 100644 --- a/README-ja.md +++ b/README-ja.md @@ -26,7 +26,7 @@ ![ダッシュボード](images/v2-dashboard-en.png) -### v2.3.1 Codex と比較 +### v2.3 Codex と比較 ![Claude 本日、簡体字中国語、ダークテーマ](images/v2.3.1/claude-today-zh-CN-dark.png) @@ -36,11 +36,14 @@ ![Claude と Codex の統合ヒートマップ、英語、ライトテーマ](images/v2.3.1/compare-heatmap-en-light.png) -上の4枚は本番ダッシュボードのレンダラー、合成データ、VS Code Light+/Dark+ のテーマ変数から生成しています。個人の使用量や請求の証拠ではありません。ネイティブ VSIX のインストールは別途検証します。 +![プロジェクト活動マトリクス、英語、ダークテーマ](images/v2.3.2/project-activity-matrix-en-dark.png) + +上の5枚は本番ダッシュボードのレンダラー、合成データ、VS Code Light+/Dark+ のテーマ変数から生成しています。個人の使用量や請求の証拠ではありません。ネイティブ VSIX のインストールは別途検証します。 - Codex は**本日の使用済みトークン**と**残りクォータ**を別表示します。週の使用率が36%なら `wk 64%`。ツールチップには使用率バー、リセット時刻、折り返した説明を表示します。クォータは最後のローカル観測であり、リアルタイム残高ではありません。 - 期間詳細は初期状態で折りたたみ、共有設定はプレビューの下に配置。共有は既定で有効、無効化も可能です。色の強度は分位数・対数・線形から選択できます。 - CLI の計上には usage を含む永続セッションログが必要です。未保存の呼び出しは復元できません。本日と過去30日は設定タイムゾーンを使用します。 +- Projects に、両プロバイダー共通の Token 専用30/90日「プロジェクト × 日」ヒートマップと日別積み上げ推移を追加しました。正確なツールチップ、カバレッジ表示、上限付き行数、「その他のプロジェクト」の長尾を備えます。 ## 主な機能 @@ -53,34 +56,13 @@ - **マルチベンダー価格** — Opus 4.x / Sonnet 4.x / Haiku 4.5 は Anthropic 公式価格で検証済み。OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen の参考価格(ファミリー認識フォールバック付き)。`Refresh Token Pricing` で LiteLLM の最新価格を取得。 - **パーソナライズ** — 言語、タイムゾーン、小数点桁数、コンパクト数値、プロジェクトのグループ化、ダッシュボード自動更新の切り替え。 -## v2.3.1 の新機能 - -- 正確な料金とコンテキスト情報が **GPT-6 Astra**(`gpt-6-astra`、 - 105 万トークン)および **Claude Fable 5.1 / Mythos 5.1** - (`claude-fable-5-1` / `claude-mythos-5-1`、100 万トークン)に対応しました。 - Fable 5.1 は専用のキャッシュ読み取り料金 `$0.25 / 100 万トークン` を使い、 - 従来の Fable 5 履歴は変更しません。GPT-6 の API 相当額には公式 Standard - の短コンテキスト料金を使用します。ローカル集計ログでは各リクエストが - 272K 入力を超えたか判定できないため、その場合のリクエスト全体への追加料金は - 含めません。 -- Claude の見積もりでは、Anthropic 直接料金または AWS Bedrock の in-region - オンデマンド料金を選択でき、現行の Opus・Sonnet・Haiku 系列を対象にします。 - 切り替えると未変更のログも直ちに再計算され、Sonnet 5 には 2026 年 8 月 31 日の - 開始記念価格終了後の標準料金を使用します。 -- 直近 30 日は設定タイムゾーンの今日と過去 29 日です。合計は照合可能で、再更新は安定し、月は古い順に表示します。Claude の Workflows サマリーも同じローリング範囲を使い、設定タイムゾーンでの開始日に基づいて各実行を集計します。チャート指標の切り替えやドリルダウンラベルの表示でも、日/月の使用量キーがホストのタイムゾーンによって前日・前月へずれることはありません。AI アドバイスと Usage Optimizer の保留期限も、設定タイムゾーンと UI 言語で表示します。 -- 構造化 reasoning effort をモデル名から推測しません。非ゼロの `unknown` は理由を示し、ゼロは表示しません。 -- 上限付き匿名クォータ観測が不規則・同日リセットを保持します。有効な同一ウィンドウの割合から、現在期間を含む総枠と未使用分の耐久性推定を表示します。近似・未帰属は低信頼度です。ローカルの Codex クォータ系列が重複していても、現在期間は最新の実測値による低信頼度の混合推定を表示し、帰属が曖昧な完了期間だけを使用済み表示に留めます。 -- 比較タブは既存の日次集計から Claude + Codex 統合活動ヒートマップを表示し、単一プロバイダーでも機能します。 -- プレビュー優先の共有スタジオは、決定的ローカル SVG、共有カード、Markdown、タイトル/範囲、プライバシープレビュー、調整可能な色に対応します。Academic Violet が既定で、強度マッピングは分位数(既定)、対数、線形から選べます。公開 GitHub 送信は正確な宛先を別途確認します。 -- Codex の Token 構成は、重複しない「非キャッシュ入力 / キャッシュ入力 / 出力」スタックの上に **非キャッシュ使用量**(非キャッシュ入力 + 出力)を明示します。推論は引き続き出力の部分集合です。 -- Codex はインデックス作成中も滑らかにスクロールします。スクロール状態は操作後に一度だけ保存され、ライブ進捗はページ全体を再構築せずテキストだけを更新します。モデル別の緑色サマリーは Claude と同じ意味になり、完全一致モデルの API 等価コストと価格カバレッジを表示します。未知モデルは `—`、effort は中立色の非キャッシュ Token 表示です。 -- OpenAI 互換の Optimizer リクエストは `reasoning_effort` のみを送り、未対応のトップレベル `thinking` を送信しないため、[#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94) を解決します。 -- Claude/Codex は密度、階層、フォーカス、ARIA、狭幅、明暗テーマの token を共有しつつ、指標の意味は分離します。 - -レビュー済み Release ドラフトは `v2.3.1` です。手動公開ワークフローが、その tag からパッケージ版を設定します。 - -## v2.3 Codex Beta +## v2.3 の新機能 +- **v2.3 系列全体の改善** — GPT-6 Astra と Fable 5.1 のモデル情報、任意の + AWS Bedrock 料金、固定参照レートの表示通貨選択、Token 専用30/90日 + プロジェクト活動マトリクス、月・日・時間の完全なドリルダウン、状態を保つ更新、アクセシブルなチャート、watcher と + タイトル索引の負荷軽減を追加しました。パッチ単位の詳細は CHANGELOG と + GitHub Releases にのみ掲載します。 - Codex の使用量レコードは `sessions/**/*.jsonl` と `archived_sessions/**/*.jsonl` からだけ検出し、認証情報、DB、未知のファイルは対象外です。これとは別に、`$CODEX_HOME/session_index.jsonl` だけをストリームで読み込み、実際のスレッド名に使う `id` → `thread_name` の対応を取得します。名前に含まれる絶対パスは伏せ、名前はメモリ内だけに保持します。使用量 JSONL の各行は、許可リスト内の使用量・構造メタデータを抽出するためだけにストリーム処理で一時解析します。プロンプト、応答、コマンド、ツール引数の各フィールドは参照も分析利用もせず、保持・永続化もしません。 - **処理済み** = 入力 + 出力、**非キャッシュ使用量** = 非キャッシュ入力 + 出力、**キャッシュ入力**は入力の部分集合、reasoning は出力の部分集合です。概要には「キャッシュ入力 / 入力」による入力キャッシュヒット率も表示します。Codex の請求コストは表示しません。先頭のサマリーカードには選択範囲の API 等価コスト推定を明示し、全期間ビューには同じ単価基準の週間推移を表示します。Claude / Codex / Compare は各プロバイダーの集計方法を分けて扱います。 - Codex の**今日**は、設定したタイムゾーンの現在の暦日です。正確な時間別 API 等価コストを表示し、Token 構成は独立したビューとして残します。日別・月別のメインチャートも API 等価コストを既定表示にし、Token 構成は別に確認できます。正確に一致する既知モデルだけを価格計算に含めるため、未知モデルは価格未設定のままで、価格カバレッジは常に表示されます。schema 3 と互換性のある追加型の時間別 sidecar は、直近 30 日の疎な日付 / 時間バケットを保持し、checkpoint 保存と再開、31 日目の削除に対応します。日付を展開しても JSONL は読みません。Codex の月別チャートと表は古い月から順に表示します。 diff --git a/README-ko.md b/README-ko.md index 3e452860..d9b5ec2e 100644 --- a/README-ko.md +++ b/README-ko.md @@ -26,7 +26,7 @@ ![대시보드](images/v2-dashboard-en.png) -### v2.3.1 Codex 및 비교 +### v2.3 Codex 및 비교 ![Claude 오늘, 중국어 간체, 어두운 테마](images/v2.3.1/claude-today-zh-CN-dark.png) @@ -36,11 +36,14 @@ ![Claude와 Codex 통합 히트맵, 영어, 밝은 테마](images/v2.3.1/compare-heatmap-en-light.png) -위 4장은 실제 대시보드 렌더러, 합성 데이터, VS Code Light+/Dark+ 테마 변수로 생성했습니다. 개인 사용량이나 청구 증거가 아니며, 네이티브 VSIX 설치는 별도로 검증합니다. +![프로젝트 활동 매트릭스, 영어, 어두운 테마](images/v2.3.2/project-activity-matrix-en-dark.png) + +위 5장은 실제 대시보드 렌더러, 합성 데이터, VS Code Light+/Dark+ 테마 변수로 생성했습니다. 개인 사용량이나 청구 증거가 아니며, 네이티브 VSIX 설치는 별도로 검증합니다. - Codex는 **오늘 사용한 토큰**과 **남은 쿼터**를 따로 표시합니다. 주간 사용률 36%이면 `wk 64%`입니다. 툴팁에는 사용률 막대, 초기화 시각, 줄바꿈 설명이 유지됩니다. 쿼터는 마지막 로컬 관측이며 실시간 잔액이 아닙니다. - 기간 상세는 기본으로 접고 공유 설정은 미리보기 아래에 둡니다. 공유는 기본 활성화이며 끌 수 있고, 색 강도는 분위수·로그·선형 중 선택합니다. - CLI 호출은 usage가 있는 영구 세션 로그를 남겨야 집계됩니다. 저장하지 않은 호출은 복원할 수 없습니다. 오늘과 최근 30일은 설정된 시간대를 사용합니다. +- Projects에 두 공급자 공통의 Token 전용 30/90일 ‘프로젝트 × 날짜’ 히트맵과 일별 누적 추세를 추가했습니다. 정확한 툴팁, 적용 범위, 제한된 행 수, ‘기타 프로젝트’ 꼬리를 유지합니다. ## 기능 @@ -53,34 +56,13 @@ - **멀티 벤더 가격** — Opus 4.x / Sonnet 4.x / Haiku 4.5는 Anthropic 공식 가격으로 검증. OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 참고 가격(패밀리 인식 폴백 포함). `Refresh Token Pricing`으로 LiteLLM 최신 가격 가져오기. - **개인화** — 언어, 시간대, 소수 자릿수, 간략 숫자, 프로젝트 그룹화, 대시보드 자동 새로고침 토글. -## v2.3.1 새로운 기능 - -- 정확한 가격 및 컨텍스트 메타데이터가 **GPT-6 Astra**(`gpt-6-astra`, - 105만 토큰)와 **Claude Fable 5.1 / Mythos 5.1** - (`claude-fable-5-1` / `claude-mythos-5-1`, 100만 토큰)을 지원합니다. - Fable 5.1은 전용 캐시 읽기 요금인 `$0.25 / 백만 토큰`을 사용하며 기존 - Fable 5 기록은 바꾸지 않습니다. GPT-6 API 등가액은 공식 Standard 단기 - 컨텍스트 요금을 사용합니다. 로컬 집계 로그로는 개별 요청이 272K 입력을 - 넘었는지 확인할 수 없으므로 해당 요청 전체에 적용되는 추가 요금은 포함하지 - 않습니다. -- Claude 추정치는 Anthropic 직접 요금 또는 AWS Bedrock in-region 온디맨드 요금을 - 선택해 현재 Opus·Sonnet·Haiku 계열에 적용할 수 있습니다. 백엔드를 바꾸면 변경되지 - 않은 로그도 즉시 다시 계산하며, Sonnet 5에는 2026년 8월 31일 출시 프로모션 종료 - 이후의 표준 요금을 사용합니다. -- 최근 30일은 설정 시간대의 오늘과 이전 29일입니다. 합계가 대조되고 반복 새로고침이 안정적이며 월은 오래된 순서로 표시됩니다. Claude Workflows 요약도 같은 롤링 범위를 사용하고 설정 시간대의 시작일에 따라 각 실행을 집계합니다. 차트 지표를 전환하거나 드릴다운 레이블을 표시할 때도 일/월 사용량 키가 호스트 시간대 때문에 전날이나 전월로 밀리지 않습니다. AI 조언과 Usage Optimizer의 일시 중지 만료일도 설정 시간대와 UI 언어로 표시합니다. -- 구조화 reasoning effort를 모델 이름에서 추측하지 않습니다. 0이 아닌 `unknown`은 이유를 설명하고 0은 숨깁니다. -- 제한된 익명 쿼터 관측이 불규칙·당일 재설정을 보존합니다. 같은 창의 유효 비율로 현재 기간을 포함한 총한도와 미사용 내구성 추정치를 표시합니다. 근사·미귀속 증거는 낮은 신뢰도로 표시합니다. 로컬 Codex 쿼터 계열이 겹쳐도 현재 기간은 마지막 실제 관측으로 신뢰도 낮은 혼합 추정을 표시하고, 귀속이 모호한 완료 기간만 사용분 전용으로 유지합니다. -- 비교 탭은 기존 일별 집계로 Claude + Codex 통합 활동 히트맵을 표시하며 단일 공급자에서도 작동합니다. -- 미리보기 중심 공유 스튜디오는 결정적 로컬 SVG, 공유 카드, Markdown, 제목/기간, 개인정보 미리보기와 조절 가능한 색상을 지원합니다. Academic Violet이 기본이며 강도 매핑은 분위수(기본), 로그, 선형 중에서 선택할 수 있습니다. 공개 GitHub 전송은 정확한 대상을 별도로 확인합니다. -- Codex Token 구성은 겹치지 않는 「캐시되지 않은 입력 / 캐시 입력 / 출력」스택 위에 **캐시되지 않은 사용량**(캐시되지 않은 입력 + 출력)을 명확히 표시합니다. 추론은 계속 출력의 부분집합입니다. -- Codex는 인덱싱 중에도 부드럽게 스크롤됩니다. 스크롤 상태는 동작이 끝난 뒤 한 번만 저장되고 실시간 인덱스 진행률은 페이지 전체를 다시 만들지 않고 텍스트만 갱신합니다. 모델별 녹색 요약은 Claude와 같은 의미로 정확히 일치하는 모델의 API 등가 비용과 가격 적용 범위를 표시합니다. 알 수 없는 모델은 `—`, effort는 중립색의 캐시되지 않은 Token 값으로 유지합니다. -- OpenAI 호환 Optimizer 요청은 `reasoning_effort`만 보내고 지원되지 않는 최상위 `thinking`을 보내지 않아 [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94)를 해결합니다. -- Claude/Codex는 밀도, 계층, 포커스, ARIA, 좁은 폭, 밝고 어두운 테마 token을 공유하되 지표 의미는 분리합니다. - -검토된 Release 초안은 `v2.3.1`이며, 수동 게시 워크플로가 해당 tag에서 패키지 버전을 설정합니다. - -## v2.3 Codex Beta +## v2.3 새로운 기능 +- **v2.3 계열 전반의 개선** — GPT-6 Astra와 Fable 5.1 모델 메타데이터, + 선택형 AWS Bedrock 가격, 고정 기준 환율 표시 통화 선택, Token 전용 30/90일 + 프로젝트 활동 매트릭스, 월/일/시간 전체 드릴다운, 상태를 보존하는 새로 고침, 접근성 차트, watcher 및 제목 인덱스 + 부하 절감을 추가했습니다. 패치별 상세 내용은 CHANGELOG와 GitHub Releases에만 + 유지합니다. - Codex 사용량 레코드는 `sessions/**/*.jsonl`과 `archived_sessions/**/*.jsonl`에서만 찾으며 자격 증명, DB, 알 수 없는 파일은 제외합니다. 별도로 `$CODEX_HOME/session_index.jsonl` 하나만 정확히 스트리밍해 실제 스레드 제목에 사용할 `id` → `thread_name` 매핑을 가져옵니다. 제목 속 절대 경로는 가리고 제목은 메모리에만 유지합니다. 사용량 JSONL 각 줄은 허용 목록의 사용량·구조 메타데이터를 추출하기 위해서만 스트리밍 방식으로 일시 파싱합니다. 프롬프트, 응답, 명령, 도구 인수 필드는 검사하거나 분석에 사용하지 않으며 보관·영구 저장하지 않습니다. - **처리된** 값 = 입력 + 출력, **캐시되지 않은 사용량** = 캐시되지 않은 입력 + 출력, **캐시 입력**은 입력의 부분집합, reasoning은 출력의 부분집합입니다. 개요에는 캐시 입력 / 입력으로 계산한 입력 캐시 적중률도 표시합니다. Codex 청구 비용은 표시하지 않습니다. 첫 요약 카드에는 선택 범위의 API 등가 비용 추정치를 명확히 표시하고, 전체 기간 보기에는 같은 가격 기준의 주간 추이를 표시합니다. Claude / Codex / Compare는 공급자별 집계 의미를 분리합니다. - Codex의 **오늘**은 설정한 시간대의 현재 달력 날짜를 뜻합니다. 정확한 시간별 API 등가 비용을 표시하고 Token 구성은 별도 보기로 유지합니다. 일별·월별 기본 차트도 API 등가 비용을 기본값으로 사용하며 Token 구성은 따로 볼 수 있습니다. 정확히 일치하는 알려진 모델만 가격 계산에 포함하므로 알 수 없는 모델은 가격을 매기지 않고 가격 적용 범위는 계속 표시합니다. schema 3과 호환되는 추가형 시간별 sidecar는 최근 30일의 희소한 날짜 / 시간 버킷을 보관하고 checkpoint 저장과 재개, 31일째 제거를 지원하며 날짜를 펼칠 때 JSONL을 읽지 않습니다. Codex 월별 차트와 표는 오래된 달부터 표시합니다. diff --git a/README-zh-CN.md b/README-zh-CN.md index c2884019..475e520d 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -35,7 +35,7 @@ *点击状态栏打开完整仪表板:堆叠 token 构成图、小时分布、缓存命中率、按 token 类型的成本构成,以及下方的按模型 / 按日表格。* -### v2.3.1 Codex 与对比 +### v2.3 Codex 与对比 ![Claude 今日,简体中文深色主题](images/v2.3.1/claude-today-zh-CN-dark.png) @@ -45,11 +45,14 @@ ![Claude 与 Codex 综合热力图,英文浅色主题](images/v2.3.1/compare-heatmap-en-light.png) -以上四张 v2.3.1 截图使用生产仪表板渲染器、合成夹具及 VS Code Light+/Dark+ 主题变量生成,不是个人用量或账单证据;原生 VSIX 安装单独验收。 +![项目活动矩阵,英文深色主题](images/v2.3.2/project-activity-matrix-en-dark.png) + +以上五张 v2.3 截图使用生产仪表板渲染器、合成夹具及 VS Code Light+/Dark+ 主题变量生成,不是个人用量或账单证据;原生 VSIX 安装单独验收。 - Codex 主状态项显示**今日已用 token**,周额度项显示**余量**:已用 36% 时为 `wk 64%`;悬浮卡保留已用进度条、重置时间和分行说明。额度来自本地最后观测,不是实时余额。 - 周期详情默认折叠;分享设置位于图下方。分享工作台默认开启、可关闭;色阶可选分位数、对数或线性。 - CLI 调用只有留下带 usage 的持久会话日志才能计入。未保存会话的历史调用不能回填;今日和最近 30 天都采用配置时区。 +- Projects 为两个供应商新增仅 Token 的 30/90 天“项目 × 日期”热力图与每日堆叠趋势,并保留精确提示、覆盖状态、有界行数和“其他项目”长尾。 ### Content 标签页:看清 token 究竟花在了哪里 @@ -73,33 +76,12 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 --- -## v2.3.1 新功能 - -- 精确定价与上下文元数据现已覆盖 **GPT-6 Astra**(`gpt-6-astra`,105 万 - token 上下文)以及 **Claude Fable 5.1 / Mythos 5.1** - (`claude-fable-5-1` / `claude-mythos-5-1`,100 万 token 上下文)。 - Fable 5.1 使用其专属的 `$0.25 / 百万 token` 缓存读取价,既有 Fable 5 - 历史数据不变。GPT-6 的 API 等效值采用官方 Standard 短上下文费率;由于 - 本地聚合日志无法判定单次请求是否超过 272K 输入,因此不计入该阈值以上的 - 整个请求附加价。 -- Claude 估算可选用 Anthropic 直连或 AWS Bedrock in-region 按需费率,覆盖当前 - Opus、Sonnet 与 Haiku 系列;切换后端会立即按新费率重算未变化的本地日志。 - Sonnet 5 使用 2026 年 8 月 31 日上线促销结束后的标准费率。 -- 最近 30 天严格定义为配置时区中的今天及此前 29 个自然日;满足「今天 ≤ 最近 30 天 ≤ 全部时间」,重复刷新/重建不累加,所有分组可核对,月份从旧到新显示。Claude 的工作流摘要使用同一滚动范围,并按配置时区中的开始日期归入周期。切换图表指标或渲染下钻标签时,日/月用量键不会再受宿主时区影响而回退一天或一个月;AI 建议和用量优化器的暂停截止日期也统一使用配置时区与界面语言。 -- 兼容当前和旧版结构化 reasoning effort,不根据模型名猜测;非零 `unknown` 会解释原因,零值不会渲染。 -- 有界匿名额度观测可保留不规则、连续乃至同日重置。每个同窗有效已用比例都参与总额度和未用耐用度估算(包括当前周期);近似或未归属证据标为低可信度。即使本地 Codex 额度系列重叠,当前周期仍采用最后一次真实观测给出低可信度混合估算;归属不清的已结束周期保持仅已用值。未用估算不会为负。 -- 「对比」默认展示 Claude + Codex 综合活动热力图,直接复用日聚合、支持任一单供应商,并在 tooltip 中分别显示两侧数据。 -- 预览优先的分享工作台包含确定性本地 SVG、综合分享卡、可复制 Markdown、自定义标题/范围、隐私预览和可调配色。工作台默认开启,可在设置中用一个开关整体隐藏。默认采用 Academic Violet;色阶映射可选分位数(默认)、对数或线性;可选的公开 GitHub 发布需要另行确认精确目标。 -- Codex Token 构成现在会在互不重叠的「未缓存输入 / 缓存输入 / 输出」堆叠上方明确显示 **未缓存用量**(未缓存输入 + 输出);推理仍是输出的子集。 -- Codex 在索引期间也能顺畅滚动:滚动状态改为操作结束后只保存一次,实时索引进度只更新文字,不再重建整页。分模型折叠项的绿色摘要现在与 Claude 一致,显示精确模型的 API 等效价格并附定价覆盖说明;未知模型显示 `—`,推理强度仍以中性色显示未缓存 Token。 -- OpenAI 兼容的优化器请求现在只发送 `reasoning_effort`,不再附带不受支持的顶层 `thinking` 参数,解决 [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94)。 -- Claude/Codex 共用卡片密度、层级、折叠、焦点、ARIA、窄宽度和深浅主题 token,同时保持各供应商指标含义独立。 -- AI 建议与用量优化器共用精确预览和明确发送边界;没有默认/后台 AI 请求,本地证据与反馈保持有界。 - -已审阅的 Release 草稿为 `v2.3.1`;人工发布工作流会从该 tag 写入包版本。 - -## 2.3 Codex Beta +## 2.3 新功能 +- **2.3 系列持续完善**——新增 GPT-6 Astra、Fable 5.1 模型元数据与可选 + AWS Bedrock 定价,提供固定参考汇率币种选择、仅 Token 的 30/90 天项目 + 活动矩阵、完整的月/日/小时下钻、保留界面状态的刷新、无障碍图表,以及更低的 watcher/标题索引开销。 + 逐补丁明细只保留在 CHANGELOG 与 GitHub Releases。 - Codex 用量记录只会从 `sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl` 中发现;凭据、数据库和未知文件仍明确排除。此外,插件会精确流式读取 `$CODEX_HOME/session_index.jsonl`,仅将 `id` 映射为 `thread_name`,用于真实线程标题。标题中的绝对路径会先脱敏,标题只保留在内存。用量 JSONL 会逐行流式、临时解析,以提取白名单内的用量与结构元数据;提示词、回复、命令和工具参数字段不会被检查或用于分析,也不会被保留或持久化。 - **已处理** = 输入 + 输出;**未缓存用量** = 未缓存输入 + 输出;**缓存输入**仍是输入的子集,reasoning 仍是输出的子集。概览还会按「缓存输入 / 输入」显示输入缓存命中率。Codex 不显示账单成本;首张汇总卡会针对当前范围显示明确标注的 API 等效成本估算,「全部时间」则按同一定价口径展示每周趋势。Claude / Codex / Compare 仍保持各供应商口径独立。 - Codex 的**今天**指配置时区中的当前自然日。页面会显示精确的小时 API 等效成本,并把 Token 构成作为独立视图;每日与每月主图也默认显示 API 等效成本,Token 构成仍单独保留。只有精确匹配的已知模型才参与计价,未知模型保持未定价,定价覆盖率始终可见。这个兼容 schema 3 的增量小时 sidecar 保存滚动最近 30 天的稀疏日期 / 小时桶,支持检查点与续传、淘汰第 31 天,并在展开日期时不读取 JSONL。Codex 按月图表和表格按月份从旧到新显示。 @@ -141,7 +123,7 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 实际返回的受限模型名称,例如 `fable 17%`;由 PR #38 ([@wheelbarrel00](https://github.com/wheelbarrel00))最初的 模型专属额度功能演进而来。 -- **AI 建议 2.0 的兼容基础**:继续支持自备 key 的 Anthropic(`/v1/messages`)与 OpenAI 兼容端点(`advice.apiFormat`)、超时、重试和 curl 兜底;v2.3.1 将旧入口收敛到统一的本地证据、完整请求预览、明确发送和反馈边界。订阅凭据从不用于模型请求。 +- **AI 建议 2.0 的兼容基础**:继续支持自备 key 的 Anthropic(`/v1/messages`)与 OpenAI 兼容端点(`advice.apiFormat`)、超时、重试和 curl 兜底;2.3 系列将旧入口收敛到统一的本地证据、完整请求预览、明确发送和反馈边界。订阅凭据从不用于模型请求。 - **用量优化器**(实验性,`advice.optimizer.enabled`,默认关闭):Content 标签页的一张卡,粘贴进粗略需求,返回一条精炼提示词,以**纯文本**形式(可直接粘贴、无 Markdown),并附推荐的 effort / thinking / 模型。三个可选微调项(标出含糊指代 · 压缩长粘贴内容 · 建议风格方向)。先生成包含所粘贴文字的精确请求预览;只有随后明确点击发送才会访问配置的 BYOK endpoint。 - **上下文窗口指示器**(实验性,默认关闭):在设置里开启后,状态栏显示当前 session 的上下文占用。`~` 表示窗口大小是猜测;代理 / 自定义模型可用 `contextWindowOverride` 手填真实窗口。 diff --git a/README-zh-TW.md b/README-zh-TW.md index 733caff0..22d52cad 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -26,7 +26,7 @@ ![儀表板](images/v2-dashboard-en.png) -### v2.3.1 Codex 與比較 +### v2.3 Codex 與比較 ![Claude 今日,簡體中文深色主題](images/v2.3.1/claude-today-zh-CN-dark.png) @@ -36,11 +36,14 @@ ![Claude 與 Codex 綜合熱力圖,英文淺色主題](images/v2.3.1/compare-heatmap-en-light.png) -以上四張 v2.3.1 影像使用正式儀表板渲染器、合成資料與 VS Code Light+/Dark+ 主題變數產生,不是個人用量或帳單證據;原生 VSIX 安裝另行驗證。 +![專案活動矩陣,英文深色主題](images/v2.3.2/project-activity-matrix-en-dark.png) + +以上五張 v2.3 影像使用正式儀表板渲染器、合成資料與 VS Code Light+/Dark+ 主題變數產生,不是個人用量或帳單證據;原生 VSIX 安裝另行驗證。 - Codex 狀態列分別顯示**今日已用 token**和**剩餘額度**:已用 36% 時顯示 `wk 64%`。提示卡保留使用進度條、重置時間與換行說明;額度是本地最後觀測,不是即時餘額。 - 週期明細預設收合,分享設定位於預覽下方。分享工作台預設開啟、可關閉;色階可選分位數、對數或線性。 - CLI 呼叫須留下帶 usage 的持久工作階段日誌才能計入。未保存的呼叫無法回填;今日與最近 30 天使用設定時區。 +- Projects 為兩個 provider 新增僅 Token 的 30/90 天「專案 × 日期」熱力圖與每日堆疊趨勢,並保留精確提示、涵蓋狀態、有界列數及「其他專案」長尾。 ## 功能特色 @@ -53,32 +56,12 @@ - **多廠商定價** — Opus 4.x / Sonnet 4.x / Haiku 4.5 對照 Anthropic 官方定價;OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 參考價,含家族感知回退。`Refresh Token Pricing` 可拉取 LiteLLM 即時價格。 - **個人化** — 語言、時區、小數位數、精簡數字、專案分組、儀表板自動刷新開關。 -## v2.3.1 新功能 - -- 精確定價與上下文中繼資料現已涵蓋 **GPT-6 Astra**(`gpt-6-astra`,105 萬 - token 上下文)以及 **Claude Fable 5.1 / Mythos 5.1** - (`claude-fable-5-1` / `claude-mythos-5-1`,100 萬 token 上下文)。 - Fable 5.1 採用專屬的 `$0.25 / 百萬 token` 快取讀取價,既有 Fable 5 - 歷史資料不變。GPT-6 的 API 等效值採用官方 Standard 短上下文費率;由於 - 本機彙總日誌無法判定單次請求是否超過 272K 輸入,因此不計入該門檻以上的 - 整個請求附加價。 -- Claude 估算可選用 Anthropic 直連或 AWS Bedrock in-region 隨選費率,涵蓋目前 - Opus、Sonnet 與 Haiku 系列;切換後端會立即按新費率重算未變更的本機日誌。 - Sonnet 5 使用 2026 年 8 月 31 日上市優惠結束後的標準費率。 -- 最近 30 天是設定時區中的今天與前 29 個日期;總計可核對、重複重新整理穩定,月份由舊到新。Claude 的工作流程摘要使用同一滾動範圍,並依設定時區中的開始日期歸入週期。切換圖表指標或呈現下鑽標籤時,日/月用量鍵不會再受宿主時區影響而倒退一天或一個月;AI 建議與用量最佳化器的暫停截止日期也統一使用設定時區與介面語言。 -- 結構化 reasoning effort 不依模型名稱猜測;非零 `unknown` 會解釋,零值不顯示。 -- 有界匿名額度觀測保留不規則及同日重設;有效同窗比例可估算總額度與未用耐用度(包括目前週期)。近似或未歸屬證據標為低可信度。即使本機 Codex 額度系列重疊,目前週期仍採用最後一次真實觀測提供低可信度混合估算;歸屬不清的已結束週期維持僅已用值。 -- 「比較」預設顯示 Claude + Codex 綜合活動熱力圖,沿用日聚合,也支援單一供應商。 -- 預覽優先的分享工作台可匯出確定性本機 SVG、分享卡與 Markdown,並自訂標題/範圍、預覽隱私及調整配色。工作台預設開啟,可在設定用一個開關整體隱藏。預設 Academic Violet;色階映射可選分位數(預設)、對數或線性;GitHub 公開發佈另行確認精確目標。 -- Codex Token 構成現在會在互不重疊的「未快取輸入 / 快取輸入 / 輸出」堆疊上方明確顯示 **未快取用量**(未快取輸入 + 輸出);推理仍是輸出的子集。 -- Codex 在索引期間也能順暢捲動:捲動狀態改為操作結束後只儲存一次,即時索引進度只更新文字,不再重建整頁。各模型折疊項的綠色摘要現在與 Claude 一致,顯示精確模型的 API 等效價格並附定價涵蓋說明;未知模型顯示 `—`,推理強度仍以中性色顯示未快取 Token。 -- OpenAI 相容的最佳化器請求現在只傳送 `reasoning_effort`,不再附帶不受支援的頂層 `thinking` 參數,解決 [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94)。 -- Claude/Codex 共用密度、層級、焦點、ARIA、窄幅與深淺色設計 token,但不混淆指標語義。 - -已審閱的 Release 草稿為 `v2.3.1`;人工發佈工作流程會從該 tag 寫入套件版本。 - -## v2.3 Codex Beta +## v2.3 新功能 +- **v2.3 系列持續完善**——新增 GPT-6 Astra、Fable 5.1 模型中繼資料與 + 可選 AWS Bedrock 定價,提供固定參考匯率幣別選擇、僅 Token 的 30/90 天 + 專案活動矩陣、完整的月/日/小時下鑽、保留介面狀態的重新整理、無障礙圖表,以及更低的 watcher/標題 + 索引開銷。逐修補版本明細只保留在 CHANGELOG 與 GitHub Releases。 - Codex 用量記錄只會從 `sessions/**/*.jsonl` 與 `archived_sessions/**/*.jsonl` 中發現;憑證、資料庫與未知檔案仍明確排除。此外,擴充功能會精確串流讀取 `$CODEX_HOME/session_index.jsonl`,僅將 `id` 對應到 `thread_name`,用於真實執行緒標題。標題中的絕對路徑會先去識別化,標題只保留在記憶體。用量 JSONL 會逐行串流並暫時解析,以擷取白名單內的用量與結構中繼資料;提示、回覆、命令和工具參數欄位不會被檢查或用於分析,也不會被保留或持久化。 - **已處理** = 輸入 + 輸出;**未快取用量** = 未快取輸入 + 輸出;**快取輸入**仍是輸入的子集,reasoning 仍是輸出的子集。概覽也會依「快取輸入 / 輸入」顯示輸入快取命中率。Codex 不顯示帳單成本;首張摘要卡會針對目前範圍顯示明確標示的 API 等效成本估算,「全部時間」則依同一定價口徑顯示每週趨勢。Claude / Codex / Compare 仍維持各供應商口徑獨立。 - Codex 的**今天**指設定時區中的目前自然日。頁面會顯示精確的每小時 API 等效成本,並將 Token 構成保留為獨立檢視;每日與每月主圖也預設顯示 API 等效成本,Token 構成仍分開呈現。只有精確符合的已知模型才參與計價,未知模型維持未定價,定價涵蓋率始終可見。這個相容 schema 3 的增量小時 sidecar 會保留最近 30 天的稀疏日期 / 小時分桶,支援檢查點與續傳、淘汰第 31 天,並在展開日期時不讀取 JSONL。Codex 按月圖表與表格會按月份由舊到新顯示。 diff --git a/README.md b/README.md index 5a888726..faf10925 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ the same data. ## Screenshots -### v2.3.1 Claude, Codex and Compare +### v2.3 Claude, Codex and Compare -The four v2.3.1 images below are reproducible captures of the production dashboard +The five v2.3 images below are reproducible captures of the production dashboard renderer with synthetic fixtures and VS Code Light+/Dark+ theme variables, not personal usage or billing evidence. Native VSIX installation is verified separately. @@ -76,6 +76,12 @@ Markdown. It is enabled by default and can be hidden with the single sharing workspace setting. Card settings sit below the preview; intensity can use quantile, logarithmic, or linear scaling. The metric is activity volume, not productivity or billing.* +![Project activity matrix in English, dark theme](images/v2.3.2/project-activity-matrix-en-dark.png) + +*Projects now adds a Token-only 30/90-day project × day heatmap and stacked +daily trend for both providers. Exact tooltips, explicit coverage, bounded rows, +and an Other-projects tail keep the view auditable without rereading source logs.* + ### Claude status bar Codex uses a compact **Today token usage** item and a separate **remaining quota** @@ -113,7 +119,7 @@ for optimising your usage. Scoped to the last 30 days ### AI advice — evidence first, sending optional -v2.3.1 keeps one readable path from a local observation to its +v2.3 keeps one readable path from a local observation to its evidence, recommendation, action, feedback, and guarded result. It is off by default. Local evidence appears before any model is involved; **Helpful**, **Not helpful**, and **Applied** stay on this device. Once enough reliable, similar @@ -154,91 +160,14 @@ same full-request preview and separate explicit Send action as AI advice. --- -## What's new in v2.3.1 - -- **GPT-6 Astra and Fable 5.1 support** — exact pricing and context metadata now - cover `gpt-6-astra` (1.05M context) and `claude-fable-5-1` / - `claude-mythos-5-1` (1M context). Fable 5.1 uses its model-specific - `$0.25 / MTok` cache-read rate without changing historical Fable 5 data. - GPT-6 API-equivalent values use official Standard short-context rates; the - request-wide surcharge above 272K input is excluded because aggregate local - logs cannot establish that per-request threshold. -- **Optional AWS Bedrock pricing** — Claude estimates can use direct Anthropic - or AWS Bedrock in-region on-demand rates for current Opus, Sonnet, and Haiku - families. Switching the backend reprices unchanged logs immediately; Sonnet - 5 uses the standard rate after its launch promotion ended on 31 August 2026. -- **Correct, stable date ranges** — Last 30 days means today plus the preceding - 29 calendar dates in the configured timezone. Today ≤ Last 30 days ≤ All time, - provider totals reconcile, repeated refresh/reindex is idempotent, and Codex - monthly charts/tables run oldest-first. Claude's Workflows summary uses that - same rolling range and assigns each run by its configured-zone start date. - Chart metric switches and drill-down labels render daily/monthly usage keys - without host-timezone rollback; Advice and Optimizer snooze dates also use - the configured timezone and UI locale. -- **Truthful effort attribution** — legacy and current structured effort fields - normalize without guessing from model names. A non-zero `unknown` bucket is - explained; a zero bucket is not rendered. -- **Reset-aware weekly estimates** — bounded, atomically written quota - observations preserve reset-time/window changes and significant usage drops, - including consecutive and same-day resets. Every valid same-window used - fraction contributes an observed full-allowance estimate. Coherent current and - completed windows show total and unused durability estimates; approximate or - unattributed evidence is labelled low confidence. If local Codex series - overlap, the current window uses the latest real observation for a labelled - low-confidence blended estimate; ambiguous completed windows remain - usage-only. Unused value never goes negative. -- **Combined heatmap in Compare** — Claude and Codex daily processed volume is - merged with provider components visible in every tooltip. It works with both - providers or either provider alone and reuses existing aggregates instead of - scanning logs again. -- **Private share loop** — use the preview-first studio to customize title, - 30/90-day or yearly range, and heatmap colors. Academic Violet is the default; - choose quantile (default), logarithmic, or linear intensity mapping to suit - the activity distribution. Inspect the privacy preview, export deterministic - SVG, and copy a Markdown snippet. Optional public-GitHub publication is a - separate exact-destination confirmation; local export needs no account - permission. -- **Explicit Codex uncached composition** — Token composition shows uncached - usage (uncached input + output) above the non-overlapping uncached-input / - cached-input / output stack; reasoning remains a subset of output. -- **Smooth live Codex dashboard** — scroll state is saved once after a gesture, - and active index progress updates its text in place instead of rebuilding the - page. Per-model disclosure headlines now use Claude's green-money convention: - exact-model API-equivalent price with coverage help; unknown models show `—`, - while effort headlines keep neutral uncached-token values. -- **OpenAI reasoning compatibility** — OpenAI-compatible optimizer requests - send `reasoning_effort` without the unsupported top-level `thinking` - parameter, resolving [#94](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/94). -- **Aligned dashboard interaction** — shared density, headings, disclosures, - charts, empty states, keyboard focus, ARIA behavior, narrow layout, and - light/dark design tokens keep Claude and Codex consistent without claiming - their metrics are semantically equivalent. -- **One advice loop** — local evidence, explainable recommendations, optional - exact BYOK preview/send, device-local feedback, and versioned comparable-task - results share one boundary. Get AI Advice opens this surface; the Optimizer - reuses its preview/send/cancel/strict-parse behavior instead of forming a - third network path. -- **No automatic AI traffic** — the feature is off by default, aggregate consent - and prompt-personalisation consent are separate, and a request crosses the - network only after the user previews it and clicks Send. - Withdrawing advice consent immediately invalidates previews and cancels active - advice requests; it cannot recall bytes already transmitted. -- **Resumable first-use work** — Codex history and the rolling 30-day hour - migration show why they are running, persist progress/failure/backoff state, - and do not restart equivalent work after completion or on every refresh. -- **Thirty-day hour drill-down** — any populated Codex day in the last 30 days - expands from the existing index with zero JSONL reads on click. Claude and - Codex use the configured timezone and the same `HH:00` labels. -- **Safe rolling totals and resumable indexing** — recent 7/30-day views reject - stale/inflated period projections and use verified daily aggregates until the - zone projection catches up. First-use work persists bounded progress and does - not hot-loop or repeat after completion. - -The reviewed draft is `v2.3.1`; the human-controlled publish workflow stamps -package metadata from that release tag. - ## What's new in 2.3 +- **Refined throughout the v2.3 line** — GPT-6 Astra and Fable 5.1 model + metadata, optional AWS Bedrock pricing, a fixed-reference display-currency + selector, a Token-only 30/90-day project activity matrix, complete + month/day/hour drill-downs, state-preserving refresh, accessible charts, and + lower watcher/title-index overhead. Patch-level + details stay in the changelog and GitHub Releases. - **Codex Beta, enabled by default** — usage records are discovered only from `sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl`; credential, database, and unknown files stay excluded. Separately, the extension streams @@ -426,7 +355,7 @@ package metadata from that release tag. original model-specific contribution in PR #38 by [@wheelbarrel00](https://github.com/wheelbarrel00). - **AI advice 2.0** — bring your own key for Anthropic or an OpenAI-compatible - endpoint (`advice.apiFormat`). v2.3.1 places local evidence and an exact full- + endpoint (`advice.apiFormat`). The v2.3 line places local evidence and an exact full- request preview before the separate Send action. Aggregate-only is the default; prompt samples and `advice.userContext` require independent prompt- personalisation consent and appear verbatim in the preview. The keyless diff --git a/docs/roadmap/provider-parity-matrix-v2.4.md b/docs/roadmap/provider-parity-matrix-v2.4.md new file mode 100644 index 00000000..041ce86b --- /dev/null +++ b/docs/roadmap/provider-parity-matrix-v2.4.md @@ -0,0 +1,65 @@ +# v2.4 provider parity matrix + +[简体中文](provider-parity-matrix-v2.4.zh-CN.md) · English + +Snapshot: 2026-09-11, local candidate branch `codex/v2.3.2-project-usage-matrix`. +Commit identifiers are development evidence, not published-release claims. + +This matrix records interaction parity without pretending Claude and Codex +expose equivalent data. “Aligned” means the user interaction and visual +hierarchy match. “Intentional difference” means the available source evidence +does not support the same behavior. “Partial” means a known contract remains to +be implemented or verified. “Candidate” means the behavior exists only on the +local branch and has passed its listed tests. + +## Component matrix + +| Area | Claude | Codex | Status | Evidence and boundary | +|:--|:--|:--|:--|:--| +| Provider navigation | Shared tablist, automatic activation, roving keyboard focus | Same | Aligned | `providerNavClient.ts`; keyboard and Axe coverage in `tests/ui/codex-accessibility.spec.mjs` | +| Dashboard navigation | Today / Last 30 days / All time plus Claude-supported destinations | Same shared tabs, with unsupported Branches and Workflows omitted | Aligned with intentional destination difference | `showTab` owns one state and ARIA path; `codex-interactions.spec.mjs` covers navigation and reload | +| Summary cards | Claude cost, messages, token/cache totals | Qualified API-equivalent value and Codex-native token totals | Aligned layout, intentional metrics | Shared production stylesheet and geometry contracts in `codex-visual.spec.mjs` | +| Model disclosures | Green displayed cost, token detail in disclosure | Green API-equivalent value, Codex-native token detail | Aligned | `codex-visual.spec.mjs` verifies headline meaning and shared disclosure geometry | +| Today hourly primary chart | Cost and token/message switches | API-equivalent value and Codex token/thread switches | Aligned | Both use the same chart controls, grid, stacked cost treatment, and configured-zone hour labels | +| Today hourly selection | Selectable hour, synchronized detail line and table row | Same | Candidate (`fbaa693`) | Mouse, Enter, Space, metric synchronization, no host message, reload restoration, and tab-reset behavior pass in `codex-interactions.spec.mjs` | +| Today token composition | Claude input, cache write, cache read, output | Fresh input, cached input, output; reasoning remains a subset note rather than a second output segment | Aligned with intentional token semantics | Shared composition renderer; Codex never double-counts reasoning | +| Last-30-days primary chart | Daily values and metric switches | Same interaction with Codex-native metrics | Aligned | Shared chart stack and `chart-date-labels.spec.mjs`; configured-zone daily keys remain stable | +| Last-30-days day → hour | Click/table disclosure uses a bounded rolling-hour DTO already embedded in the Webview | Click/table disclosure uses the persisted rolling-hour sidecar in the Webview | Candidate (`84c84d7`) | Neither path sends a host message or rereads JSONL on click. Claude transfers sparse active-hour rows and completes the selected day to an exact 24-hour presentation in the browser; days without materialized detail expose no dead control | +| All-time month → day | Click/table disclosure derives the selected month's configured-zone days from loaded records | Click/table disclosure requests only the selected month from a complete host-memory daily view derived from already-persisted period slices | Candidate (`f51f29c`) | No index-schema migration and no JSONL read on click. The share payload remains bounded to 370 days; month details are bounded to one month. Recent covered days can continue to the existing hourly sidecar; Playwright covers nested collapse and full-reload restoration | +| Chart expansion semantics | One expanded period, matching table button and chart state, `aria-expanded` / `aria-controls` | Same across month → day and supported day → hour rows | Aligned | Reload and keyboard contracts pass in `codex-interactions.spec.mjs` and `codex-accessibility.spec.mjs` | +| Empty / partial hourly detail | Claude no-data response inside the controlled row | Explicit no-data row plus hourly migration coverage | Aligned hierarchy, intentional provenance copy | Codex never falls back to unverified legacy totals | +| Missing-period zero fill | Today presents all 24 hours; Last 30 days presents exactly 30 configured-zone civil dates without widening source aggregates | Complete Today coverage presents all 24 hours; partial indexing stays sparse; Last 30 days presents exactly 30 dates | Candidate (`7b86a54`) | Synthetic zero rows render as real zero values rather than unpriced data. Claude zero days expose no dead drill-down control. Helper unit tests and `time-range-presentation.spec.mjs` cover the exact ranges | +| Metric persistence | Selected metric survives full Webview reload | Same | Aligned | `chartMetrics` state and browser coverage | +| Expansion / selection persistence | Expansion and Today-hour selection survive Webview reload and reset on a user-initiated top-tab switch | Same for supported interactions | Candidate (`3b46c09`) | Ordinary same-shell refresh replaces only the provider panel and preserves the host-selected tab, expanded chain, chart/hour selection, transient Optimizer input, keyboard focus, and nearest visible anchor. Same-turn updates coalesce; structural changes or failed delivery fall back to a complete document. Node and Playwright coverage exercise Claude and Codex | +| Page scroll | Debounced per-provider/tab position plus nearest-anchor restoration during live replacement | Same | Candidate (`3b46c09`, `7b86a54`) | Continuous scrolling writes once after the gesture; late Chromium scroll/pagehide events cannot synchronously rewrite an identical position. Live replacement restores the nearest visible stable element; complete reload restoration remains covered in `codex-interactions.spec.mjs` | +| Tables | Shared numeric alignment, sortable semantics where applicable, bounded horizontal scrollers | Same | Aligned | Keyboard sorting, 360 px, long-locale, and desktop overflow tests | +| Project activity matrix | Token-only 30/90-day project × day heatmap and stacked daily trend from the Claude incremental index | Same interaction from Codex period slices, with explicit indexed-subtotal coverage | Candidate (`eaf2eb9`) | Shared 0–4 intensity scale, exact tooltips, bounded rows/cells/series, **Other projects** tail, separate provider identities, local scrolling, and zero source-JSONL reads on interaction; `project-usage-matrix.spec.mjs` covers state, live replacement, setting, and 360 px | +| Weekly allowance-value detail | Provider-qualified Claude panel; details collapsed by default | Separate provider-qualified Codex panel; details collapsed by default | Aligned layout, intentional evidence authority | Claude uses official observations; Codex uses local last-observed evidence. Compare never sums them | +| Status-bar quota detail | Official `/usage`, shared progress table and thresholds | Last-observed local evidence, same progress table and thresholds | Aligned layout, intentional authority | Provider-specific provenance stays visible and must never be inferred from matching colors | +| Sharing | Legacy Claude export remains compatible during v2.3.x | Compare studio exports privacy-safe provider components | Partial by decision | New sharing work goes only to the Compare studio; legacy removal waits for documentation and compatibility migration | +| Settings | Concise provider-aware controls | Same | Aligned | Detailed local-data inventory and destructive controls remain repository documentation only | +| Accessible chart alternatives | Every chart scroller and heatmap has a localized provider/range/type/metric-qualified region name | Same; metric changes update the name in place | Candidate (`fc6f53b`) | Claude, Codex, and Compare names are non-empty, unique, stable across repeat initialization, and covered by the full Axe/keyboard suite. Duplicate-looking Compare allowance charts remain distinct because their section headings retain the provider | +| Installed VSIX evidence | Not yet captured for this branch | Not yet captured for this branch | Partial | Candidate packaging must capture privacy-safe real VS Code screenshots for status, quota, and both supported drill-down levels | + +## Read and persistence boundaries + +| Interaction | Source read allowed on activation | Persisted across Webview reload | Reset boundary | +|:--|:--|:--|:--| +| Today hour selection | None; DOM state only | Selected hour and selected metric | User switches the top dashboard tab | +| Last 30 days day → hour (Claude) | Existing materialized Webview DTO only; no host message | Expanded day and metric | User switches the top dashboard tab | +| Last 30 days day → hour (Codex) | Existing materialized Webview DTO only; no host message | Expanded day and metric | User switches the top dashboard tab | +| All time month → day (Claude) | Existing in-memory records only; no filesystem read | Expanded month and metric | User switches the top dashboard tab | +| All time month → day (Codex) | One provider-qualified selected-month response derived from host-memory index aggregates; no filesystem read | Expanded month, eligible nested hour, and metrics | User switches the top dashboard tab | +| Sort / filter | Existing rendered DTO only | Sort, session range, and model filter | Explicit user change or UI reset | +| Project matrix range / view / expansion | Existing bounded project-day DTO only; no host message or filesystem read | Provider-scoped 30/90-day range, heatmap/trend view, and expanded-row state | UI reset | +| Scroll | Browser scroll position only | Per provider and dashboard tab | UI reset | +| Live data replacement | Newly rendered provider-panel HTML, the bounded Claude rolling-hour DTO, and an on-demand selected-month Codex fragment; no source-log read in the client | Active tab, expansion chain, chart/hour selection, transient focused control, keyboard focus, and nearest visible anchor | Structural shell change, provider change, rejected delivery, or UI reset | + +No interaction in this table may initiate a JSONL rescan. A new aggregation +must be built during the existing index/update pass and transferred through a +bounded DTO before a new drill-down control is added. + +## Next implementation order + +1. Capture installed-VSIX status, quota, supported drill-down, and project-matrix + evidence before candidate packaging. diff --git a/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md b/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md new file mode 100644 index 00000000..2394ab48 --- /dev/null +++ b/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md @@ -0,0 +1,61 @@ +# v2.4 Provider 交互一致性矩阵 + +简体中文 · [English](provider-parity-matrix-v2.4.md) + +快照日期:2026-09-11;本地候选分支:`codex/v2.3.2-project-usage-matrix`。提交号只是开发 +证据,不代表已发布版本的声明。 + +本矩阵记录交互一致性,但不假装 Claude 与 Codex 提供了等价数据。“已对齐”表示用户 +交互与视觉层级一致;“刻意不同”表示现有来源证据无法支持同一行为;“部分完成”表示 +仍有已知契约需要实现或验证;“本地候选”表示行为只存在于本地分支,并已通过表中所列 +测试。 + +## 组件矩阵 + +| 区域 | Claude | Codex | 状态 | 证据与边界 | +|:--|:--|:--|:--|:--| +| Provider 导航 | 共用 tablist、自动激活和 roving 键盘焦点 | 相同 | 已对齐 | `providerNavClient.ts`;`tests/ui/codex-accessibility.spec.mjs` 覆盖键盘与 Axe | +| 仪表盘导航 | 今日 / 最近 30 天 / 全部时间,以及 Claude 可支持的目的地 | 共用同一组标签,但省略无法支持的分支和工作流 | 已对齐,目的地刻意不同 | `showTab` 统一状态与 ARIA;`codex-interactions.spec.mjs` 覆盖导航和重载 | +| 摘要卡 | Claude 成本、消息数、Token 与缓存总量 | 明确限定的 API 等价值和 Codex 原生 Token 总量 | 布局已对齐,指标刻意不同 | `codex-visual.spec.mjs` 验证共用生产样式与几何 | +| 模型折叠项 | 绿色显示成本,折叠后显示 Token 细节 | 绿色显示 API 等价值,折叠后显示 Codex 原生 Token 细节 | 已对齐 | `codex-visual.spec.mjs` 验证标题语义和共用折叠几何 | +| 今日小时主图 | 成本及 Token/消息指标切换 | API 等价值及 Codex Token/线程指标切换 | 已对齐 | 共用图表控件、网格、堆叠成本样式和配置时区小时标签 | +| 今日小时选择 | 可选小时、同步详情行与表格行 | 相同 | 本地候选(`fbaa693`) | `codex-interactions.spec.mjs` 覆盖鼠标、Enter、Space、指标同步、零主机消息、重载恢复和切换标签重置 | +| 今日 Token 构成 | Claude 输入、缓存写入、缓存读取、输出 | 未缓存输入、缓存输入、输出;推理继续作为输出子集说明,而不是第二次计入输出 | 已对齐,Token 语义刻意不同 | 共用构成图;Codex 不重复计算推理 | +| 最近 30 天主图 | 每日值与指标切换 | 采用 Codex 原生指标的相同交互 | 已对齐 | 共用图表栈和 `chart-date-labels.spec.mjs`;配置时区的日期键保持稳定 | +| 最近 30 天“日 → 小时” | 点击/表格折叠直接使用已嵌入 Webview 的有界滚动小时 DTO | 点击/表格折叠直接使用 Webview 中持久化的滚动小时侧车 | 本地候选(`84c84d7`) | 两条路径点击时都不发送主机消息,也不重读 JSONL。Claude 只传输有活动的稀疏小时行,并在浏览器中把所选日期补齐为精确 24 小时;没有已物化详情的日期不显示无响应控件 | +| 全部时间“月 → 日” | 点击/表格折叠从已加载记录中派生所选月份的配置时区日数据 | 点击/表格折叠只向主进程请求所选月份;完整每日视图由已持久化的 period 日切片派生并只驻留主进程内存 | 本地候选(`f51f29c`) | 不迁移索引 schema,点击时不读取 JSONL。分享载荷仍限制为 370 天,月份详情最多一个月;处于近期覆盖内的日期还能进入既有小时侧车。Playwright 覆盖嵌套收起与完整重载恢复 | +| 图表展开语义 | 同时只展开一个周期,表格按钮与图表状态同步,并使用 `aria-expanded` / `aria-controls` | “月 → 日”及可支持的“日 → 小时”均相同 | 已对齐 | `codex-interactions.spec.mjs` 和 `codex-accessibility.spec.mjs` 覆盖重载与键盘 | +| 空数据/部分小时详情 | 在受控详情行中显示 Claude 无数据响应 | 显式空数据行,并显示小时迁移覆盖情况 | 信息层级已对齐,来源说明刻意不同 | Codex 不回退到未经验证的旧总量 | +| 缺失周期补零 | 今日完整展示 24 小时;最近 30 天精确展示配置时区内连续 30 个自然日,同时不扩大来源聚合 | 今日覆盖完整时展示全部 24 小时;索引未完整时仍保持稀疏;最近 30 天精确展示 30 天 | 本地候选(`7b86a54`) | 合成的零值行显示为真实 0,而不是“未定价”;Claude 零值日期不显示无响应的下钻控件。辅助函数单测与 `time-range-presentation.spec.mjs` 覆盖精确范围 | +| 指标持久化 | 所选指标在完整 Webview 重载后保留 | 相同 | 已对齐 | `chartMetrics` 状态及浏览器测试 | +| 展开/选择持久化 | 展开项和今日小时选择在 Webview 重载后保留,用户主动切换顶层标签时重置 | 在受支持交互中相同 | 本地候选(`3b46c09`) | 普通且页面骨架未变的刷新只替换 provider 面板,并保留宿主指定标签、展开链、图表/小时选择、Optimizer 临时输入、键盘焦点和最近可见锚点。同一轮更新会合并;骨架变化或投递失败时安全回退到完整文档。Node 与 Playwright 均覆盖 Claude 和 Codex | +| 页面滚动 | 按 provider/标签防抖保存位置;实时替换时恢复最近锚点 | 相同 | 本地候选(`3b46c09`、`7b86a54`) | 连续滚动只在手势结束后写一次;Chromium 延迟触发的 scroll/pagehide 事件不会同步重复写入相同位置。实时替换恢复最近可见的稳定元素;`codex-interactions.spec.mjs` 继续覆盖完整重载恢复 | +| 表格 | 共用数值对齐、可排序语义和有界横向滚动容器 | 相同 | 已对齐 | 覆盖键盘排序、360 px、最长语言和桌面溢出 | +| 项目活动矩阵 | 从 Claude 增量索引生成仅 Token 的 30/90 天“项目 × 日期”热力图和每日堆叠趋势 | 采用相同交互,由 Codex period 切片生成并明确标注已索引小计覆盖状态 | 本地候选(`eaf2eb9`) | 共用 0–4 色阶、精确 tooltip、有界行/单元格/系列、“其他项目”长尾、provider 分离身份和局部滚动;交互读取零条来源 JSONL。`project-usage-matrix.spec.mjs` 覆盖状态、实时替换、设置和 360 px | +| 每周额度等价值详情 | Provider 专属 Claude 面板,详情默认折叠 | 独立的 Provider 专属 Codex 面板,详情默认折叠 | 布局已对齐,证据权威性刻意不同 | Claude 使用官方观测;Codex 使用本地末次观测;Compare 从不相加两者 | +| 状态栏额度详情 | 官方 `/usage`,共用进度表和阈值 | 本地末次观测,使用同一进度表和阈值 | 布局已对齐,权威性刻意不同 | Provider 来源说明必须保留,不能因颜色相同而暗示证据等价 | +| 分享 | v2.3.x 暂时保留旧 Claude 导出兼容性 | Compare 工作台导出隐私安全的 provider 分量 | 按决策部分完成 | 新分享能力只进入 Compare 工作台;旧入口需等文档和兼容迁移后再移除 | +| 设置 | 简洁、按 provider 生效的控件 | 相同 | 已对齐 | 详细本地数据清单与破坏性清理控制只保留在 repo 文档 | +| 图表可访问替代 | 每个图表滚动区和热力图都有由本地化 provider、范围、图表类型与指标组成的区域名称 | 相同;切换指标时名称同步更新 | 本地候选(`fc6f53b`) | Claude、Codex、Compare 的名称均非空、唯一,重复初始化后保持稳定,并通过完整 Axe/键盘测试。Compare 中外观相似的两张额度图仍因各自标题中的 provider 而明确区分 | +| 已安装 VSIX 证据 | 本分支尚未截取 | 本分支尚未截取 | 部分完成 | 候选打包必须在真实 VS Code 中截取状态栏、额度 tooltip 和两级下钻的隐私安全图片 | + +## 读取与持久化边界 + +| 交互 | 激活时允许读取的来源 | 跨 Webview 重载保留 | 重置边界 | +|:--|:--|:--|:--| +| 今日小时选择 | 无;只操作 DOM 状态 | 所选小时与所选指标 | 用户切换顶层仪表盘标签 | +| 最近 30 天“日 → 小时”(Claude) | 只使用已物化的 Webview DTO;不发送主机消息 | 展开日期与指标 | 用户切换顶层仪表盘标签 | +| 最近 30 天“日 → 小时”(Codex) | 只使用已物化的 Webview DTO;不发送主机消息 | 展开日期与指标 | 用户切换顶层仪表盘标签 | +| 全部时间“月 → 日”(Claude) | 只使用已存在的内存记录;不读取文件系统 | 展开月份与指标 | 用户切换顶层仪表盘标签 | +| 全部时间“月 → 日”(Codex) | 主进程用内存索引聚合返回一个带 provider 标识的所选月份片段;不读取文件系统 | 展开月份、可用的嵌套小时及指标 | 用户切换顶层仪表盘标签 | +| 排序/筛选 | 只使用已渲染 DTO | 排序、会话范围和模型筛选 | 用户显式修改或重置 UI | +| 项目矩阵范围/视图/展开 | 只使用有界项目日期 DTO;不发送主机消息,不读取文件系统 | 按 provider 保留 30/90 天范围、热力图/趋势视图和展开行状态 | 重置 UI | +| 滚动 | 只记录浏览器滚动位置 | 按 provider 和仪表盘标签保存 | 重置 UI | +| 实时数据替换 | 新渲染的 provider 面板 HTML、有界 Claude 滚动小时 DTO,以及按需返回的 Codex 单月片段;客户端不读取来源日志 | 当前标签、展开链、图表/小时选择、聚焦控件的临时值、键盘焦点和最近可见锚点 | 页面骨架变化、provider 切换、投递被拒或重置 UI | + +本表中的任何交互都不得触发 JSONL 重扫。新增聚合必须在既有索引/更新流程中构建,并先 +通过有界 DTO 传入 Webview,才能增加新的下钻控件。 + +## 下一步实现顺序 + +1. 候选打包前,在真实安装的 VSIX 中截取状态栏、额度、已支持下钻层级和项目矩阵的验收证据。 diff --git a/docs/roadmap/provider-parity-v2.4.md b/docs/roadmap/provider-parity-v2.4.md index 602de5c5..fe7d7b57 100644 --- a/docs/roadmap/provider-parity-v2.4.md +++ b/docs/roadmap/provider-parity-v2.4.md @@ -2,6 +2,9 @@ This roadmap follows the v2.3.1 regression fixes. Its goal is to give equivalent information the same interaction, visual hierarchy, keyboard behavior, and feedback without pretending that Claude and Codex expose equivalent metrics. It is outside the v2.3.1 release scope; every item starts with a minimal failing fixture. +Current audit ledger: [简体中文](provider-parity-matrix-v2.4.zh-CN.md) · +[English](provider-parity-matrix-v2.4.md). + ## P0: inventory and independent audit - Build a component-by-component Claude/Codex matrix covering status items, quota tooltips, summary cards, charts, tables, disclosures, empty/loading/error states, filters, sorting, drill-down, return paths, narrow layouts, and light/dark themes. @@ -11,38 +14,85 @@ This roadmap follows the v2.3.1 regression fixes. Its goal is to give equivalent ## P0.5: accepted post-v2.3.1 audit follow-ups -- Rearm failed Claude/Codex file watchers with bounded exponential backoff while - polling remains the safe fallback; test repeated failure, recovery, and - disposal without a hot loop. -- Cache `session_index.jsonl` by verified size/mtime and keep title recovery - streaming and memory-bounded. Preserve the rule that titles stay memory-only - and every non-title field remains ignored. -- Rename rolling-30-day variables that still say `month`, remove dead hourly - caches/branches, and escape every dynamic error-page slot before insertion. -- Localize the non-zero `component-delta-clamped` quality explanation and add a - migration fixture proving quota compaction retains the oldest/newest boundary - evidence needed for later confidence calculations. -- Add explicit browser contracts for Advice snooze persistence and for Compare - never summing cross-provider cost or quota. Decide whether the legacy Claude - Share Card is retired into the Compare studio or receives full timezone and - eight-locale parity before adding more sharing surfaces. -- Implement [#91](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/91) - as a local display preference only: user-entered rate, currency code/symbol, - deterministic formatting, no exchange-rate network dependency, and USD kept - as the auditable base value. -- Keep the detailed local-data inventory and destructive clear-path reference in - `LOCAL-DATA.md` / `LOCAL-DATA.zh-CN.md`; do not reintroduce the verbose panel - into the normal plugin Settings surface. +At the start of the v2.3.2 stabilization branch, the accepted items have the +following status. Commit identifiers refer to the local candidate branch and +become release evidence only after maintainer review and merge. + +- **Done — X-06 brought forward from v2.3.3:** the existing Projects page now + provides a shared, Token-only 30/90-day project × day heatmap and stacked + daily trend for Claude and Codex. Exact tooltips, explicit coverage, bounded + rows/cells/series, and an **Other projects** tail preserve truth and scale. + Both providers reuse already-materialized indexes; opening or switching the + matrix performs zero source-JSONL reads and adds no scanner, cache, timer, + watcher, worker, dependency, or network path (`eaf2eb9`; model/index/UI tests). + +- **Done — watcher recovery:** failed Claude/Codex file watchers rearm with + bounded exponential backoff while polling remains the safe fallback. Tests + cover repeated failure, recovery, and disposal without a hot loop + (`cd7a100`). +- **Done — title-index cache:** `session_index.jsonl` is cached by verified + size/mtime/device/inode with before/after race detection. Titles remain + memory-only and every non-title field remains ignored (`e89784d`). +- **Done — time semantics and error boundary:** Webview rolling-30-day data no + longer masquerades as a calendar month, and the unused hourly cache is gone. + The stable `month` DOM tab id remains temporarily as a persistence-compatibility + contract. Dynamic share errors now use DOM `textContent`, not HTML insertion + (`d840976`, `e96c45a`). +- **Done — quality and quota migration:** all eight locales explain a non-zero + `component-delta-clamped` flag. Quota compaction preserves the oldest and + newest series endpoints before other window boundaries, with a small-retention + migration fixture (`d840976`). +- **Done — browser boundaries:** an Advice snooze stays closed and resumable + after a full Webview reload. Compare summaries remain provider-native token + cards while cost and allowance stay in two provider-qualified panels + (`cd482a5`). +- **Decided — legacy Claude Share Card:** do not expand a second sharing-settings + surface. v2.3.x retains the current command, renderer, and export compatibility; + v2.4 converges provider-specific export into the Compare sharing studio, then + removes the old panel only after documentation migration and compatibility + tests. New sharing capabilities go only into the unified studio. +- **Done — [#91](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/91):** + one compact dropdown selects a bundled currency preset and defaults to USD. + The reference-rate snapshot is fixed and not user-editable; formatting is + deterministic and carries `≈`, while underlying prices, aggregates, sorting, + and persistence remain USD. No exchange-rate transport exists, provider-native + usage credits bypass conversion, and both static and client-rendered drill-downs + share the same formatter (`12bd21b`, refined in `c991d4f`). +- **Preserved — concise Settings:** keep the detailed local-data inventory and + destructive clear-path reference in `LOCAL-DATA.md` / + `LOCAL-DATA.zh-CN.md`; do not reintroduce the verbose panel into the normal + plugin Settings surface. +- **Done — state-preserving live refresh:** ordinary same-shell data updates + coalesce and replace only the active provider panel. The host-selected tab, + drill-down chain, chart/hour selection, transient Optimizer input, keyboard + focus, and nearest visible scroll anchor survive; a shell change or failed + delivery falls back to a complete document (`3b46c09`). +- **Done — provider-qualified chart regions:** every chart scroller and heatmap + receives a stable localized name composed from its provider, dashboard range, + section, chart type, and current metric. Names update with metric changes and + stay unique across the two Compare allowance charts (`fc6f53b`). +- **Done — Codex all-time month drill-down:** the host retains a complete daily + view derived from the period slices already present in the index, while the + share heatmap keeps its existing 370-day bound. Clicking a month transfers + only that month, never rereads JSONL, and continues to the materialized hourly + detail where rolling-hour coverage exists. Mouse, keyboard, nested state, and + full-reload restoration are covered without an index-schema migration + (`f51f29c`). ## P1: time-hierarchy chart drill-down -- All-time month bar: click or Enter/Space opens that month's daily data. -- Last-30-days day bar: click or Enter/Space opens that day's hourly data. +- All-time month bar: click or Enter/Space opens that month's daily data. The + v2.3.2 candidate implements this for both providers (`f51f29c`). +- Last-30-days day bar: click or Enter/Space opens that day's hourly data. The + v2.3.2 candidate materializes both provider paths (`84c84d7`). - Today hour bar: use the same selected/highlight/detail feedback without inventing unsupported granularity. - Claude and Codex share selection, `aria-expanded`, `aria-controls`, focus, empty-state, back/collapse, and responsive-layout behavior. - Drill-down must use materialized month/day/hour aggregates and perform zero JSONL reads on click. An incomplete hourly migration shows explicit subtotal/coverage state rather than unverified legacy totals. -- Preserve the expanded chain, selected period, keyboard focus, and scroll anchor through live refresh. Define which of those states survives a webview reload and test that contract explicitly. +- Preserve the expanded chain, selected period, keyboard focus, and scroll anchor through live refresh. The v2.3.2 candidate now implements and tests this contract for ordinary Claude/Codex same-shell updates (`3b46c09`); structural changes retain the complete-reload path. - Zero-fill missing calendar dates/hours inside a selected range so sparse activity does not distort axes or make the hierarchy appear shorter than the requested range. +- Hourly charts retain all zero-filled slots, axis labels, and table rows, but + omit repeated zero value labels above the bars; genuine unpriced usage still + shows `—` instead of being mistaken for no activity (`c991d4f`). ## P2: status bar and quota detail @@ -57,13 +107,38 @@ This roadmap follows the v2.3.1 regression fixes. Its goal is to give equivalent - Align table sorting, numeric alignment, row expansion, scrollers, empty rows, and narrow layouts. - Align disclosure defaults, hit areas, focus treatment, and persistence rules. - Align loading, no-data, partial-index, stale-observation, and error hierarchy while retaining provider-specific causes. -- Give every chart a provider-qualified accessible text alternative. In Compare, controls and regions must have unique accessible names even when the visible label is shared. +- Give every chart a provider-qualified accessible text alternative. The v2.3.2 candidate now labels all chart scrollers and heatmaps and verifies unique Compare names with the full Axe/keyboard suite (`fc6f53b`). - Compare continues to combine only comparable activity; it never adds bills, allowances, or capability. ## P4: exit gate -- A tracked parity matrix marks every difference aligned or intentionally different with a semantic rationale. +- Maintain the tracked [provider parity matrix](provider-parity-matrix-v2.4.md), where every difference is aligned or intentionally different with a semantic rationale. - Node fixtures cover non-UTC and DST conservation (`sum(hours) = day`, `sum(days) = month`, and Today uses the same day map), zero JSONL reads on click, quota thresholds, and Today status scope. - Playwright covers mouse, Enter/Space, ARIA, focus, Light+/Dark+, 360 px, the longest locale, and drill-down/collapse flows. - An isolated VS Code profile installs the VSIX and captures privacy-safe Claude/Codex Today status, quota tooltip, month-to-day, and day-to-hour evidence. - After one read-only Claude CLI audit, allow one revision review for selected findings; all blockers close before candidate packaging. + +## Later candidate (v2.6+): Remote-SSH and multi-host usage aggregation + +This item is explicitly outside v2.4 and v2.5 and does not block either release. +For now, each Extension Host continues to read exactly one local `CODEX_HOME`: +Remote-SSH usage is visible separately only when the extension actually runs on +that remote Extension Host, and local plus remote hosts are not merged automatically. + +- Any future implementation remains off by default and requires explicit host + selection and pairing. SSH access, background synchronization, or remote + scanning must never enter the ordinary refresh path implicitly. +- Hosts may exchange only versioned, bounded, privacy-safe numeric aggregates + and coverage metadata. Raw JSONL, prompts/responses, thread titles, absolute + paths, usernames, credentials, and full account identifiers never cross hosts. +- Token activity may be deduplicated and summed within stable pseudonymous + host/source boundaries. Account allowance snapshots are never added across + hosts: each allowance series uses its newest valid observation and displays + the source host, observation time, and staleness explicitly. +- The dashboard must expose both combined values and per-host coverage. Offline + hosts, disconnects, timezone differences, cross-host replay, and multiple + sign-ins in one home remain visible uncertainty instead of invented history. +- Implementation requires a separate design and privacy gate covering Remote + Extension Host lifecycle, resumable transfer, atomic checkpoints, cross-host + deduplication, resource ceilings, and reversible clearing. No exact release + is promised until those gates pass. diff --git a/docs/roadmap/provider-parity-v2.4.zh-CN.md b/docs/roadmap/provider-parity-v2.4.zh-CN.md index d88fae16..c0a48378 100644 --- a/docs/roadmap/provider-parity-v2.4.zh-CN.md +++ b/docs/roadmap/provider-parity-v2.4.zh-CN.md @@ -2,6 +2,9 @@ 本路线图承接 v2.3.1 的回归修复,目标是在不混淆两家 provider 指标语义的前提下,让相同类型的信息采用相同的交互、视觉层级、键盘行为和反馈方式。它不是 v2.3.1 发布范围;每项进入实现前都要先补最小失败夹具。 +当前审计台账:[简体中文](provider-parity-matrix-v2.4.zh-CN.md) · +[English](provider-parity-matrix-v2.4.md)。 + ## P0:交互清单与独立审计 - 建立 Claude / Codex 逐组件对照表:状态栏、额度 tooltip、摘要卡、图表、表格、折叠项、空状态、加载/失败状态、筛选、排序、下钻、返回路径、窄屏和深浅主题。 @@ -11,32 +14,67 @@ ## P0.5:v2.3.1 独立审阅后采纳的后续项 -- Claude/Codex 文件 watcher 失败后采用有界指数退避重建,轮询继续作为安全兜底; - 测试连续失败、恢复与 dispose,确保不会热循环。 -- 按校验后的 size/mtime 缓存 `session_index.jsonl`,线程标题恢复继续保持流式与 - 内存有界;标题仍只驻留内存,其他字段继续全部忽略。 -- 清理仍以 `month` 命名的滚动 30 天变量、无效小时缓存与死分支,并在插入动态 - 错误页内容前统一转义。 -- 为非零 `component-delta-clamped` 质量标记补齐本地化解释,并增加迁移夹具,证明 - quota 压缩会保留后续置信度计算所需的最早/最新边界证据。 -- 增加 Advice 暂停状态跨重载持久化,以及 Compare 永不跨 provider 相加成本或额度 - 的浏览器契约。继续扩展分享功能前,先决定旧 Claude Share Card 是收敛到 Compare - 工作台,还是补齐时区与八语言一致性。 -- 将 [#91](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/91) 实现为纯本地 - 显示偏好:用户填写汇率、币种代码/符号,格式确定可复现,不增加联网汇率源,并 - 保留 USD 作为可审计基准值。 -- 详细本地数据清单与破坏性清理路径只保留在 `LOCAL-DATA.md` / - `LOCAL-DATA.zh-CN.md`;不把冗长面板重新放回插件的常规设置界面。 +截至 v2.3.2 稳定分支启动时,本组采用项的状态如下。提交号属于本地候选分支,只有 +经维护者审阅并合入后才成为发布证据。 + +- **已完成 — 原定 v2.3.3 的 X-06 前移:** 现有 Projects 页面为 Claude 与 Codex + 提供共用的、仅 Token 的 30/90 天「项目 × 日期」热力图与每日堆叠趋势。精确 + tooltip、明确覆盖状态、有界行数/单元格/系列及“其他项目”长尾同时保证真实性与 + 可扩展性。两个 provider 都复用已物化索引;打开或切换矩阵时读取零条来源 JSONL, + 也不新增 scanner、cache、timer、watcher、worker、依赖或网络路径 + (`eaf2eb9`;模型/索引/UI 测试)。 + +- **已完成 — watcher 恢复:** Claude/Codex 文件 watcher 失败后采用有界指数退避 + 重建,轮询继续作为安全兜底;连续失败、恢复和 dispose 均有防热循环测试 + (`cd7a100`)。 +- **已完成 — 标题索引缓存:** 按校验后的 size/mtime/device/inode 缓存 + `session_index.jsonl`,并检测读取前后的文件竞争;线程标题仍只驻留内存,其他字段 + 继续全部忽略(`e89784d`)。 +- **已完成 — 时间语义与错误边界:** Webview 内滚动 30 天数据不再误叫自然月,删除 + 从未使用的小时缓存;稳定 DOM tab id `month` 暂时保留为持久化兼容契约。动态分享 + 错误改用 DOM `textContent`,不再插入 HTML(`d840976`、`e96c45a`)。 +- **已完成 — 质量与额度迁移:** 八语言均解释非零 `component-delta-clamped`;quota + 压缩先保留最早和最新系列端点,再保留窗口边界,迁移夹具覆盖小容量历史 + (`d840976`)。 +- **已完成 — 浏览器边界:** Advice 暂停状态在完整 Webview 重载后仍折叠且可恢复; + Compare 摘要只呈现两家各自 Token,成本与额度始终由两个 provider 专属面板分别 + 展示(`cd482a5`)。 +- **已决策 — 旧 Claude Share Card:** 不再扩展第二套分享设置。v2.3.x 保留现有 + 命令、渲染器与导出兼容性;v2.4 将 provider 专属导出收敛到 Compare 分享工作台, + 完成文档迁移和兼容测试后再移除旧面板。新增分享能力只进入统一工作台。 +- **已完成 — [#91](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues/91):** + 一个紧凑下拉框选择内置币种预设,默认 USD;参考汇率快照固定且不允许用户调整。 + 换算格式确定可复现并统一标记 `≈`,底层定价、聚合、排序与持久化仍为 USD。 + 不存在联网汇率通道;供应商原生 usage credits 不参与换算,静态页面与客户端下钻 + 共用同一格式边界(`12bd21b`,并在 `c991d4f` 中收敛设置)。 +- **已保持 — 设置简洁性:** 详细本地数据清单与破坏性清理路径只保留在 + `LOCAL-DATA.md` / `LOCAL-DATA.zh-CN.md`;不把冗长面板重新放回插件的常规设置 + 界面。 +- **已完成 — 保持状态的实时刷新:** 普通且页面骨架未变的数据更新会合并,只替换 + 当前 provider 面板;宿主指定标签、下钻链、图表/小时选择、Optimizer 临时输入、 + 键盘焦点和最近可见滚动锚点都会保留。骨架变化或投递失败时回退到完整文档 + (`3b46c09`)。 +- **已完成 — 带 provider 语义的图表区域:** 每个图表滚动区和热力图都会用现有 + 本地化文字组合出稳定的 provider、仪表盘范围、区段、图表类型和当前指标名称; + 指标切换时同步更新,Compare 的两张额度图也保持唯一名称(`fc6f53b`)。 +- **已完成 — Codex 全部时间月度下钻:** 主进程保留一份由现有索引 period 日切片 + 派生的完整每日视图,分享热力图仍维持原有 370 天上限。点击月份时只传该月数据, + 不重读 JSONL;若日期处于滚动小时覆盖范围,还可继续展开已物化小时详情。鼠标、 + 键盘、嵌套状态与完整重载恢复均有测试,且不需要迁移索引 schema(`f51f29c`)。 ## P1:图表按时间层级下钻 -- 全部时间的月柱:点击或按 Enter/Space 后展开该月的每日数据。 -- 最近 30 天的日柱:点击或按 Enter/Space 后展开该日的小时数据。 +- 全部时间的月柱:点击或按 Enter/Space 后展开该月的每日数据;v2.3.2 候选已为 + 两个 provider 实现(`f51f29c`)。 +- 最近 30 天的日柱:点击或按 Enter/Space 后展开该日的小时数据;v2.3.2 候选已 + 物化两个 provider 的对应路径(`84c84d7`)。 - 今日的小时柱:保持同一选中/高亮/详情反馈,不制造无法从本地索引支持的更细粒度。 - Claude 与 Codex 共用同一套选中态、`aria-expanded`、`aria-controls`、焦点、空数据提示、返回/收起入口和响应式布局。 - 下钻只能使用已物化的日/月/小时聚合;点击时不得重新扫描 JSONL。若小时侧车仍在迁移,显示明确的小计/覆盖率,不回退到未经验证的旧总量。 -- 实时刷新后保留展开链、所选周期、键盘焦点和滚动锚点;同时明确哪些状态应跨 webview 重载保存,并对该契约单独测试。 +- 实时刷新后保留展开链、所选周期、键盘焦点和滚动锚点。v2.3.2 候选已为普通 Claude/Codex 同骨架更新实现并测试该契约(`3b46c09`);结构变化仍走完整重载路径。 - 在选定范围内补齐无活动的日期/小时为零,避免稀疏记录扭曲坐标轴或让层级看起来短于请求范围。 +- 小时图继续保留全部补零时段、坐标轴标签和表格行,但不再在柱顶重复显示 0; + 真实存在但无法定价的活动仍显示 `—`,不会被误认为没有活动(`c991d4f`)。 ## P2:状态栏与额度详情 @@ -51,13 +89,30 @@ - 表格:排序、数值对齐、行展开、滚动容器、空行和窄屏行为一致。 - 折叠项:默认开合状态、summary 命中区、焦点样式和状态持久化规则一致。 - 加载、空数据、部分索引、过期观测和错误状态使用同一信息层级,但保留 provider 专属原因。 -- 每张图都提供带 provider 名称的可访问文本替代;Compare 中即使可见标签相同,控件与区域也必须具有唯一的可访问名称。 +- 每张图都提供带 provider 名称的可访问文本替代。v2.3.2 候选已为全部图表滚动区和热力图补齐名称,并用完整 Axe/键盘测试验证 Compare 名称唯一(`fc6f53b`)。 - Compare 只比较可比较的活动量,不相加账单、额度或能力。 ## P4:验收门 -- 一份可追踪的 provider parity matrix,每个差异标记为“已对齐”或“刻意不同”,后者必须写明语义理由。 +- 持续维护可追踪的 [provider 一致性矩阵](provider-parity-matrix-v2.4.zh-CN.md);每个差异标记为“已对齐”或“刻意不同”,后者必须写明语义理由。 - Node 夹具覆盖非 UTC 与 DST 守恒(`小时之和 = 日`、`日之和 = 月`,且 Today 使用同一日表)、点击零额外 JSONL 读取、额度阈值和状态栏范围。 - Playwright 覆盖鼠标、Enter/Space、ARIA、焦点、Light+/Dark+、360 px、最长语言和逐级下钻/收起。 - 在隔离 VS Code profile 安装 VSIX,至少截取 Claude/Codex 今日状态栏、额度 tooltip、月→日和日→小时四组隐私安全实图。 - Claude CLI 首轮只读审计后只允许一次针对已选问题的修订复核;阻塞问题关闭后才进入候选打包。 + +## 远期候选(v2.6+):Remote-SSH/多主机用量聚合 + +本项明确不进入 v2.4 或 v2.5,也不阻塞其发布。近期继续保持“每个 Extension Host +只读取一个本机 `CODEX_HOME`”的边界:Remote-SSH 用量只有在插件实际运行于对应 +远端 Extension Host 时才能单独查看;本机与多个远端不会自动合并。 + +- 若未来实施,默认仍关闭,并要求用户显式选择和配对主机;不得把 SSH 连接能力、 + 后台同步或远端扫描悄悄加入普通刷新路径。 +- 主机之间只允许交换带版本、受大小限制的脱敏数值聚合与覆盖元数据。不得传输原始 + JSONL、prompt/response、线程标题、绝对路径、用户名、凭证或完整账号标识。 +- Token 活动可在稳定的匿名主机/来源边界内去重后求和;账号额度快照绝不能按主机 + 相加,只能按额度系列采用最新有效观测,并明确显示来源主机、观测时间和过期状态。 +- 仪表盘必须同时显示综合值与逐主机覆盖率;离线、断连、时区差异、同一会话跨主机 + 重放和一个 home 多登录都要降级为可见的不确定性,不能伪造完整历史。 +- 进入实现前须另立设计与隐私安全门,验证 Remote Extension Host 生命周期、断线续传、 + 原子检查点、跨主机去重、资源上限和可逆清除;通过前不承诺具体发布版本。 diff --git a/images/v2.3.2/project-activity-matrix-en-dark.png b/images/v2.3.2/project-activity-matrix-en-dark.png new file mode 100644 index 00000000..4db5ef89 Binary files /dev/null and b/images/v2.3.2/project-activity-matrix-en-dark.png differ diff --git a/src/chartAccessibilityClient.ts b/src/chartAccessibilityClient.ts new file mode 100644 index 00000000..0b9ec52f --- /dev/null +++ b/src/chartAccessibilityClient.ts @@ -0,0 +1,102 @@ +/** + * Adds stable, provider-qualified accessible names to every dashboard chart + * without changing its visual presentation. Labels are derived from the + * existing localized provider, tab, section, chart, and metric text. + */ +export function getChartAccessibilityClientScript(): string { + return ` +function ccuChartText(element) { + return element && element.textContent + ? element.textContent.replace(/\\s+/g, ' ').trim() + : ''; +} + +function ccuPushChartLabelPart(parts, value) { + var normalized = String(value || '').replace(/\\s+/g, ' ').trim(); + if (normalized && parts.indexOf(normalized) === -1) { parts.push(normalized); } +} + +function ccuChartProviderLabel() { + return ccuChartText(document.getElementById('provider-tab-' + ccuProviderName())); +} + +function ccuChartTabLabel(element) { + var panel = element && element.closest + ? element.closest('.tab-content[role="tabpanel"]') + : null; + if (!panel) { return ''; } + var labelledBy = panel.getAttribute('aria-labelledby'); + return labelledBy ? ccuChartText(document.getElementById(labelledBy)) : ''; +} + +function ccuChartSectionHeading(element) { + var section = element && element.closest + ? element.closest('.hourly-breakdown, .daily-breakdown, .heatmap-panel') + : null; + if (!section) { return ''; } + var heading = section.querySelector(':scope > h2, :scope > h3, :scope > h4, :scope > .section-header h3'); + return ccuChartText(heading); +} + +function ccuChartRegionLabel(scroller) { + var parts = []; + ccuPushChartLabelPart(parts, ccuChartProviderLabel()); + ccuPushChartLabelPart(parts, ccuChartTabLabel(scroller)); + ccuPushChartLabelPart(parts, ccuChartSectionHeading(scroller)); + + var chart = scroller.closest('.composition-chart, .chart-content'); + var ownHeading = chart && chart.classList.contains('composition-chart') + ? chart.querySelector(':scope > h3, :scope > h4') + : null; + ccuPushChartLabelPart(parts, ccuChartText(ownHeading)); + + if (!ownHeading) { + var section = scroller.closest('.hourly-breakdown, .daily-breakdown'); + var activeMetric = section + ? section.querySelector(':scope > .chart-tabs .chart-tab.active[data-metric]') + : null; + ccuPushChartLabelPart(parts, ccuChartText(activeMetric)); + } + return parts.join(' · '); +} + +function ccuHeatmapRegionLabel(heatmap) { + var parts = []; + var baseLabel = heatmap.getAttribute('data-chart-base-label'); + if (baseLabel === null) { + baseLabel = heatmap.getAttribute('aria-label') || ''; + heatmap.setAttribute('data-chart-base-label', baseLabel); + } + ccuPushChartLabelPart(parts, ccuChartProviderLabel()); + ccuPushChartLabelPart(parts, ccuChartTabLabel(heatmap)); + ccuPushChartLabelPart(parts, ccuChartSectionHeading(heatmap)); + ccuPushChartLabelPart(parts, baseLabel); + return parts.join(' · '); +} + +function initializeChartRegions(root) { + var scope = root || document; + var scrollers = []; + var heatmaps = []; + if (scope.matches && scope.matches('.hc-scroll')) { scrollers.push(scope); } + if (scope.matches && scope.matches('.heatmap-svg')) { heatmaps.push(scope); } + scope.querySelectorAll('.hc-scroll').forEach(function(scroller) { scrollers.push(scroller); }); + scope.querySelectorAll('.heatmap-svg').forEach(function(heatmap) { heatmaps.push(heatmap); }); + + scrollers.forEach(function(scroller) { + var label = ccuChartRegionLabel(scroller); + if (!label) { return; } + scroller.setAttribute('role', 'region'); + scroller.setAttribute('aria-label', label); + scroller.setAttribute('data-chart-region', 'true'); + }); + heatmaps.forEach(function(heatmap) { + var label = ccuHeatmapRegionLabel(heatmap); + if (!label) { return; } + heatmap.setAttribute('role', 'region'); + heatmap.setAttribute('aria-label', label); + heatmap.setAttribute('data-chart-region', 'true'); + }); +} +`; +} diff --git a/src/claudeIncrementalIndex.ts b/src/claudeIncrementalIndex.ts index 1e2baf70..c55ca16d 100644 --- a/src/claudeIncrementalIndex.ts +++ b/src/claudeIncrementalIndex.ts @@ -25,6 +25,10 @@ import { } from './dateKeys'; import { I18n } from './i18n'; import { isRetryDuplicatePrompt } from './promptDedup'; +import { + buildProjectUsageMatrixSnapshot, + ProjectUsageMatrixSnapshot, +} from './projectUsageMatrix'; import { defaultCodexJsonlReader, scanCodexJsonlLines, @@ -84,6 +88,8 @@ interface UsageAggregates { byMonth: Map; byLocalDay: Map; byLocalHour: Map; + /** Bounded configured-day project buckets used by the Projects matrix. */ + byProjectDay: Map; bySession: Map; byProject: Map; byBranch: Map; @@ -110,6 +116,7 @@ interface CopyOnWriteKeys { interface ConfiguredTimeKeyers { formatter: Intl.DateTimeFormat; hourWindowStartDay: string; + projectWindowStartDay: string; } export interface ClaudeUsageIndex { @@ -184,6 +191,10 @@ export interface ClaudeUsageAggregateSnapshot { dailyForMonth: { date: string; data: UsageData }[]; monthlyForAllTime: { date: string; data: UsageData }[]; hourlyForToday: { hour: string; data: UsageData }[]; + /** Sparse, already-materialized hours for active days in the rolling + * 30-day dashboard range. The Webview may complete the 24-hour presentation + * without asking the host to regroup records when a day is expanded. */ + hourlyForLast30DaysByDay: Record; } export interface ClaudeUsageDashboardSnapshot extends ClaudeUsageAggregateSnapshot { @@ -195,6 +206,7 @@ export interface ClaudeUsageDashboardSnapshot extends ClaudeUsageAggregateSnapsh workflows: WorkflowUsage[]; costliestMessages: CostlyMessage[]; context: ContextWindowInfo | null; + projectUsageMatrix: ProjectUsageMatrixSnapshot; /** Present only for the default-off advice capability; built from materialized aggregates. */ adviceWindow?: { windowDays: number; @@ -239,6 +251,7 @@ function emptyAggregates(): UsageAggregates { byMonth: new Map(), byLocalDay: new Map(), byLocalHour: new Map(), + byProjectDay: new Map(), bySession: new Map(), byProject: new Map(), byBranch: new Map(), @@ -279,6 +292,7 @@ function createConfiguredTimeKeyers(timeZone: string, now = Date.now()): Configu timeZone: resolved, }), hourWindowStartDay: rollingDayKeys(now, resolved, 30)[0] ?? '', + projectWindowStartDay: rollingDayKeys(now, resolved, 90)[0] ?? '', }; } @@ -740,6 +754,11 @@ function applyConfiguredTimeAggregate( if (day >= index.timeKeyers.hourWindowStartDay) { applyBucket(index.aggregates.byLocalHour, dayHour, contribution, sign); } + if (day >= index.timeKeyers.projectWindowStartDay) { + const rawProject = record._projectPath || record._projectName || 'unknown'; + const project = ClaudeDataLoader.normalizePath(rawProject) || 'unknown'; + applyBucket(index.aggregates.byProjectDay, `${project}\0${day}`, contribution, sign); + } } function applyAggregate(index: ClaudeUsageIndex, record: ClaudeUsageRecord, sign: 1 | -1): void { @@ -872,6 +891,7 @@ function rebuildConfiguredTimeAggregates(index: ClaudeUsageIndex): void { index.aggregates.byMonth = new Map(); index.aggregates.byLocalDay = new Map(); index.aggregates.byLocalHour = new Map(); + index.aggregates.byProjectDay = new Map(); index.visibleKeysByLocalDay = new Map(); index.copyOnWrite.visibleLocalDays = new Set(); for (const [visibleKey, record] of index.visibleRecords) { @@ -1077,6 +1097,7 @@ function cloneIndexForCommit(previous: ClaudeUsageIndex): ClaudeUsageIndex { byMonth: new Map(previous.aggregates.byMonth), byLocalDay: new Map(previous.aggregates.byLocalDay), byLocalHour: new Map(previous.aggregates.byLocalHour), + byProjectDay: new Map(previous.aggregates.byProjectDay), bySession: new Map(previous.aggregates.bySession), byProject: new Map(previous.aggregates.byProject), byBranch: new Map(previous.aggregates.byBranch), @@ -1093,6 +1114,17 @@ function pruneHourlyBuckets(index: ClaudeUsageIndex): void { } } +function pruneProjectDayBuckets(index: ClaudeUsageIndex): void { + const cutoff = index.timeKeyers.projectWindowStartDay; + if (!cutoff) return; + for (const key of index.aggregates.byProjectDay.keys()) { + const separator = key.lastIndexOf('\0'); + if (separator >= 0 && key.slice(separator + 1) < cutoff) { + index.aggregates.byProjectDay.delete(key); + } + } +} + function recordsOf(index: ClaudeUsageIndex): ClaudeUsageRecord[] { return [...index.visibleRecords.values()]; } @@ -1282,6 +1314,65 @@ function sessionMatchesWorkspace(index: ClaudeUsageIndex, sessionId: string, wor return false; } +function rollingHourlyRowsByDay( + index: ClaudeUsageIndex, + dayKeys: readonly string[], +): Record { + const requestedDays = new Set(dayKeys); + const activeDays = new Set( + dayKeys.filter((day) => index.aggregates.byLocalDay.has(day)), + ); + const buckets = new Map>(); + + for (const [key, data] of index.aggregates.byLocalHour) { + const separator = key.indexOf('\0'); + if (separator < 0) continue; + const day = key.slice(0, separator); + const hour = key.slice(separator + 1); + if (!requestedDays.has(day) || !hour) continue; + let dayBuckets = buckets.get(day); + if (!dayBuckets) { + dayBuckets = new Map(); + buckets.set(day, dayBuckets); + } + dayBuckets.set(hour, cloneUsageData(data)); + } + + // Deterministic snapshots in tests/replay may request a historical rolling + // window after the index has already pruned that window's hourly buckets. + // Rebuild only those missing active days from visible in-memory records once + // during snapshot creation. Production snapshots use the materialized map. + const missingActiveDays = new Set( + [...activeDays].filter((day) => !buckets.has(day)), + ); + if (missingActiveDays.size > 0) { + for (const record of index.visibleRecords.values()) { + const keys = configuredTimeKeys(index, new Date(record.timestamp)); + if (!keys.day || !keys.hour || !missingActiveDays.has(keys.day)) continue; + let dayBuckets = buckets.get(keys.day); + if (!dayBuckets) { + dayBuckets = new Map(); + buckets.set(keys.day, dayBuckets); + } + applyBucket( + dayBuckets, + keys.hour, + ClaudeDataLoader.calculateUsageData([record]), + 1, + ); + } + } + + return Object.fromEntries( + dayKeys.flatMap((day) => { + const rows = [...(buckets.get(day)?.entries() ?? [])] + .map(([hour, data]) => ({ hour, data })) + .sort((left, right) => left.hour.localeCompare(right.hour)); + return rows.length > 0 ? [[day, rows]] : []; + }), + ); +} + export function claudeUsageAggregateSnapshot( index: ClaudeUsageIndex, now: Date = new Date(), @@ -1303,25 +1394,8 @@ export function claudeUsageAggregateSnapshot( const monthlyForAllTime = [...index.aggregates.byMonth.entries()] .map(([month, data]) => ({ date: `${month}-01`, data: cloneUsageData(data) })) .sort((left, right) => right.date.localeCompare(left.date)); - let hourlyForToday = [...index.aggregates.byLocalHour.entries()] - .filter(([key]) => key.startsWith(`${localToday}\0`)) - .map(([key, data]) => ({ hour: key.slice(localToday.length + 1), data: cloneUsageData(data) })) - .sort((left, right) => left.hour.localeCompare(right.hour)); - // Historical callers may ask for a deterministic snapshot date (tests, - // replay, or an already-rendered day). If that day is outside the rolling - // materialized hour window, derive its hours from the in-memory visible - // records only; never reopen JSONL. - if (hourlyForToday.length === 0 && index.aggregates.byLocalDay.has(localToday)) { - const fallback = new Map(); - for (const record of index.visibleRecords.values()) { - const keys = configuredTimeKeys(index, new Date(record.timestamp)); - if (keys.day !== localToday || !keys.hour) continue; - applyBucket(fallback, keys.hour, ClaudeDataLoader.calculateUsageData([record]), 1); - } - hourlyForToday = [...fallback.entries()] - .map(([hour, data]) => ({ hour, data })) - .sort((left, right) => left.hour.localeCompare(right.hour)); - } + const hourlyForLast30DaysByDay = rollingHourlyRowsByDay(index, last30DayKeys); + const hourlyForToday = hourlyForLast30DaysByDay[localToday] ?? []; return { today: cloneUsageData(index.aggregates.byLocalDay.get(localToday) ?? emptyUsageData()), last30Days, @@ -1331,9 +1405,40 @@ export function claudeUsageAggregateSnapshot( dailyForMonth, monthlyForAllTime, hourlyForToday, + hourlyForLast30DaysByDay, }; } +function claudeProjectUsageMatrixSnapshot( + index: ClaudeUsageIndex, + now: Date, +): ProjectUsageMatrixSnapshot { + const asOfDay = dayKeyInZone(now, index.timeZone); + const points = [...index.aggregates.byProjectDay.entries()].flatMap(([key, data]) => { + const separator = key.lastIndexOf('\0'); + if (separator < 0) return []; + const projectKey = key.slice(0, separator); + const day = key.slice(separator + 1); + const project = index.projectRows.get(projectKey); + const projectName = project?.projectName || + projectKey.split('/').filter(Boolean).pop() || + ''; + return [{ + projectKey: `claude:${projectKey}`, + projectName, + day, + tokens: data.totalInputTokens + data.totalOutputTokens + + data.totalCacheCreationTokens + data.totalCacheReadTokens, + coverage: 'complete' as const, + }]; + }); + return buildProjectUsageMatrixSnapshot('claude', points, { + asOfDay, + timeZone: index.timeZone, + coverage: 'complete', + }); +} + export function claudeUsageDashboardSnapshot( index: ClaudeUsageIndex, options: { @@ -1432,6 +1537,7 @@ export function claudeUsageDashboardSnapshot( .slice(0, 50), costliestMessages, context, + projectUsageMatrix: claudeProjectUsageMatrixSnapshot(index, now), ...(adviceWindow ? { adviceWindow } : {}), }; } @@ -1585,6 +1691,7 @@ export async function updateClaudeUsageIndex( next.timeZone = configuredTimeZone; next.timeKeyers = timeKeyers; pruneHourlyBuckets(next); + pruneProjectDayBuckets(next); if (timeZoneChanged) { rebuildConfiguredTimeAggregates(next); } diff --git a/src/codexViewComponents.ts b/src/codexViewComponents.ts index 7703b2c1..d1d56238 100644 --- a/src/codexViewComponents.ts +++ b/src/codexViewComponents.ts @@ -12,6 +12,7 @@ export const CODEX_QUALITY_FLAGS = [ 'missing-token-info', 'invalid-token-count', 'counter-regression', + 'component-delta-clamped', 'missing-parent', 'index-backfill-incomplete', 'ambiguous-session-identity', @@ -235,6 +236,7 @@ export const CODEX_COPY_EN: CodexViewCopy = { 'missing-token-info': 'Missing token information', 'invalid-token-count': 'Invalid token count', 'counter-regression': 'Usage counter moved backwards', + 'component-delta-clamped': 'Cached or reasoning token delta exceeded its parent total and was capped', 'missing-parent': 'Parent session log missing; conservative usage retained', 'index-backfill-incomplete': 'Usage index is still being built; current totals are incomplete and indexing will continue automatically', 'ambiguous-session-identity': 'Duplicate session identity is ambiguous; both local copies are retained', diff --git a/src/currencyDisplay.ts b/src/currencyDisplay.ts new file mode 100644 index 00000000..3cc0acc9 --- /dev/null +++ b/src/currencyDisplay.ts @@ -0,0 +1,134 @@ +/** + * Local-only display conversion for USD-denominated estimates. + * + * Pricing, aggregation, sorting, and persistence continue to use USD. The + * selected currency resolves to one bundled reference-rate snapshot: there is + * no exchange-rate transport and no user-editable multiplier. + */ + +export type DisplayCurrencyCode = + | 'USD' + | 'EUR' + | 'GBP' + | 'JPY' + | 'CNY' + | 'HKD' + | 'KRW' + | 'IDR' + | 'BRL' + | 'AUD' + | 'CAD' + | 'CHF' + | 'INR' + | 'SGD'; + +export interface CurrencyDisplayPreference { + code: DisplayCurrencyCode; + label: string; + unitsPerUsd: number; + converted: boolean; + referenceDate: string; +} + +interface CurrencyPreset { + code: DisplayCurrencyCode; + symbol: string; + unitsPerUsd: number; +} + +// ECB euro reference rates published for 2026-09-09, converted from currency +// units per EUR to currency units per USD. USD stays the invariant source unit. +export const CURRENCY_REFERENCE_DATE = '2026-09-09'; + +const PRESETS: readonly CurrencyPreset[] = [ + { code: 'USD', symbol: '$', unitsPerUsd: 1 }, + { code: 'EUR', symbol: '€', unitsPerUsd: 0.85822176 }, + { code: 'GBP', symbol: '£', unitsPerUsd: 0.73719533 }, + { code: 'JPY', symbol: '¥', unitsPerUsd: 153.26982492 }, + { code: 'CNY', symbol: '¥', unitsPerUsd: 6.70777549 }, + { code: 'HKD', symbol: 'HK$', unitsPerUsd: 7.84225884 }, + { code: 'KRW', symbol: '₩', unitsPerUsd: 1336.19979403 }, + { code: 'IDR', symbol: 'Rp', unitsPerUsd: 17475.79814624 }, + { code: 'BRL', symbol: 'R$', unitsPerUsd: 5.08891177 }, + { code: 'AUD', symbol: 'A$', unitsPerUsd: 1.38414006 }, + { code: 'CAD', symbol: 'C$', unitsPerUsd: 1.37684518 }, + { code: 'CHF', symbol: 'CHF', unitsPerUsd: 0.80707175 }, + { code: 'INR', symbol: '₹', unitsPerUsd: 95.1102815 }, + { code: 'SGD', symbol: 'S$', unitsPerUsd: 1.26338826 }, +] as const; + +export const DISPLAY_CURRENCY_CODES: DisplayCurrencyCode[] = PRESETS.map( + (preset) => preset.code, +); + +export const DISPLAY_CURRENCY_LABELS: string[] = PRESETS.map( + (preset) => `${preset.code} (${preset.symbol})`, +); + +const PRESETS_BY_CODE = new Map( + PRESETS.map((preset) => [preset.code, preset]), +); + +// Compatibility with values stored by the early 2.3.2 test build. Ambiguous +// yen/yuan symbols are deliberately omitted; their ISO codes remain accepted. +const LEGACY_SYMBOL_CODES = new Map([ + ['$', 'USD'], + ['US$', 'USD'], + ['€', 'EUR'], + ['£', 'GBP'], + ['HK$', 'HKD'], + ['₩', 'KRW'], + ['RP', 'IDR'], + ['R$', 'BRL'], + ['A$', 'AUD'], + ['C$', 'CAD'], + ['₹', 'INR'], + ['S$', 'SGD'], +]); + +export function normalizeDisplayCurrencyCode(raw: unknown): DisplayCurrencyCode { + if (typeof raw !== 'string') return 'USD'; + const normalized = raw.trim().toUpperCase(); + if (PRESETS_BY_CODE.has(normalized as DisplayCurrencyCode)) { + return normalized as DisplayCurrencyCode; + } + return LEGACY_SYMBOL_CODES.get(normalized) ?? 'USD'; +} + +export function resolveCurrencyDisplay(raw: unknown): CurrencyDisplayPreference { + const code = normalizeDisplayCurrencyCode(raw); + const preset = PRESETS_BY_CODE.get(code) ?? PRESETS[0]; + return { + code, + label: code === 'USD' ? '$' : code, + unitsPerUsd: preset.unitsPerUsd, + converted: code !== 'USD', + referenceDate: CURRENCY_REFERENCE_DATE, + }; +} + +function normalizedDecimalPlaces(value: number): number { + return Number.isFinite(value) + ? Math.max(0, Math.min(4, Math.floor(value))) + : 2; +} + +/** The invariant source representation, unaffected by display preferences. */ +export function formatUsdBaseline(amountUsd: number, decimalPlaces = 2): string { + if (!Number.isFinite(amountUsd)) return '—'; + return `$${amountUsd.toFixed(normalizedDecimalPlaces(decimalPlaces))}`; +} + +/** Format one USD estimate using the selected bundled display preset. */ +export function formatUsdForDisplay( + amountUsd: number, + currency: unknown, + decimalPlaces = 2, +): string { + if (!Number.isFinite(amountUsd)) return '—'; + const preference = resolveCurrencyDisplay(currency); + const amount = (amountUsd * preference.unitsPerUsd) + .toFixed(normalizedDecimalPlaces(decimalPlaces)); + if (!preference.converted) return `$${amount}`; + return `≈${preference.label} ${amount}`; +} diff --git a/src/dashboardRefreshClient.ts b/src/dashboardRefreshClient.ts new file mode 100644 index 00000000..b71f0fca --- /dev/null +++ b/src/dashboardRefreshClient.ts @@ -0,0 +1,268 @@ +/** + * Root-independent client controller for replacing provider dashboard data + * without reloading the complete Webview document. The surrounding Webview + * script owns the render-specific restore functions referenced here. + */ +export function getDashboardRefreshClientScript(): string { + return ` +var __ccuLastDashboardPatchRevision = 0; +var __ccuRefreshIdentityAttributes = [ + 'aria-controls', + 'data-persist', + 'data-session-id', + 'data-dashboard-tab', + 'data-date', + 'data-hour', + 'data-group', + 'data-sortkey', + 'data-metric', + 'data-project-matrix-range', + 'data-project-matrix-view', + 'name' +]; +var __ccuFocusableSelector = 'a[href],button,input,select,textarea,summary,[tabindex]'; + +function ccuRefreshScope(element, panel) { + var tab = element && element.closest ? element.closest('.tab-content[id]') : null; + return tab || panel; +} + +function ccuRefreshDescriptor(element, panel, allowOwner) { + if (!element || !panel || !panel.contains(element) || !element.tagName) { return null; } + if (element.id && element.id.length <= 256) { + return { kind: 'id', value: element.id }; + } + var scope = ccuRefreshScope(element, panel); + var tag = element.tagName.toLowerCase(); + for (var i = 0; i < __ccuRefreshIdentityAttributes.length; i += 1) { + var attribute = __ccuRefreshIdentityAttributes[i]; + var value = element.getAttribute(attribute); + if (value === null || value.length > 512) { continue; } + var matches = Array.prototype.filter.call(scope.getElementsByTagName(tag), function(candidate) { + return candidate.getAttribute(attribute) === value; + }); + return { + kind: 'attribute', + scopeId: scope.id || 'provider-panel', + tag: tag, + attribute: attribute, + value: value, + occurrence: Math.max(0, matches.indexOf(element)) + }; + } + if (allowOwner !== false) { + var owner = element.parentElement; + while (owner && owner !== panel) { + var ownerDescriptor = ccuRefreshDescriptor(owner, panel, false); + if (ownerDescriptor) { + var descendants = Array.prototype.slice.call(owner.querySelectorAll(__ccuFocusableSelector)); + var descendantIndex = descendants.indexOf(element); + if (descendantIndex >= 0) { + return { kind: 'descendant', owner: ownerDescriptor, focusIndex: descendantIndex }; + } + } + owner = owner.parentElement; + } + } + var focusables = Array.prototype.slice.call(scope.querySelectorAll(__ccuFocusableSelector)); + var focusIndex = focusables.indexOf(element); + return focusIndex < 0 ? null : { + kind: 'focus-index', + scopeId: scope.id || 'provider-panel', + focusIndex: focusIndex + }; +} + +function ccuResolveRefreshDescriptor(descriptor, panel) { + if (!descriptor || !panel) { return null; } + if (descriptor.kind === 'id') { + var byId = document.getElementById(descriptor.value); + return byId && panel.contains(byId) ? byId : null; + } + if (descriptor.kind === 'descendant') { + var owner = ccuResolveRefreshDescriptor(descriptor.owner, panel); + if (!owner) { return null; } + return owner.querySelectorAll(__ccuFocusableSelector)[descriptor.focusIndex] || null; + } + var scope = descriptor.scopeId === 'provider-panel' + ? panel + : document.getElementById(descriptor.scopeId); + if (!scope || !panel.contains(scope)) { return null; } + if (descriptor.kind === 'focus-index') { + return scope.querySelectorAll(__ccuFocusableSelector)[descriptor.focusIndex] || null; + } + if (descriptor.kind !== 'attribute') { return null; } + var matches = Array.prototype.filter.call(scope.getElementsByTagName(descriptor.tag), function(candidate) { + return candidate.getAttribute(descriptor.attribute) === descriptor.value; + }); + return matches[descriptor.occurrence] || null; +} + +function ccuCaptureRefreshAnchor(panel, focusedDescriptor) { + var focused = focusedDescriptor ? ccuResolveRefreshDescriptor(focusedDescriptor, panel) : null; + if (focused) { + var focusedRect = focused.getBoundingClientRect(); + if (focusedRect.height > 0 && focusedRect.bottom > 0 && focusedRect.top < window.innerHeight) { + return { descriptor: focusedDescriptor, top: focusedRect.top }; + } + } + var activePanel = panel.querySelector('.tab-content.active') || panel; + var selector = '[id],[aria-controls],[data-persist],[data-date],[data-hour],[data-group]'; + var best = null; + Array.prototype.forEach.call(activePanel.querySelectorAll(selector), function(candidate) { + var descriptor = ccuRefreshDescriptor(candidate, panel, false); + if (!descriptor) { return; } + var rect = candidate.getBoundingClientRect(); + if (rect.height <= 0 || rect.bottom <= 0 || rect.top >= window.innerHeight) { return; } + var score = Math.abs(rect.top); + if (!best || score < best.score) { + best = { descriptor: descriptor, top: rect.top, score: score }; + } + }); + return best ? { descriptor: best.descriptor, top: best.top } : null; +} + +function ccuCaptureTransientControl(element, panel) { + if (!element || !element.matches || !element.matches('input,select,textarea')) { return null; } + var descriptor = ccuRefreshDescriptor(element, panel, true); + if (!descriptor) { return null; } + var state = { descriptor: descriptor, value: element.value }; + if (element.matches('input[type="checkbox"],input[type="radio"]')) { + state.checked = element.checked; + } + if (typeof element.selectionStart === 'number' && typeof element.selectionEnd === 'number') { + state.selectionStart = element.selectionStart; + state.selectionEnd = element.selectionEnd; + } + return state; +} + +function ccuCaptureRefreshContext(panel) { + var focused = document.activeElement && panel.contains(document.activeElement) + ? document.activeElement + : null; + var focus = ccuRefreshDescriptor(focused, panel, true); + var controls = []; + var seen = []; + [focused, document.getElementById('optDraft'), document.getElementById('optResolve'), + document.getElementById('optDistil'), document.getElementById('optAesthetic')] + .forEach(function(element) { + if (!element || seen.indexOf(element) !== -1 || !panel.contains(element)) { return; } + seen.push(element); + var captured = ccuCaptureTransientControl(element, panel); + if (captured) { controls.push(captured); } + }); + return { + focus: focus, + anchor: ccuCaptureRefreshAnchor(panel, focus), + controls: controls, + scrollY: window.scrollY + }; +} + +function ccuRestoreTransientControls(context, panel) { + (context.controls || []).forEach(function(saved) { + var control = ccuResolveRefreshDescriptor(saved.descriptor, panel); + if (!control || !control.matches || !control.matches('input,select,textarea')) { return; } + control.value = saved.value; + if (typeof saved.checked === 'boolean') { control.checked = saved.checked; } + if ( + typeof saved.selectionStart === 'number' && + typeof saved.selectionEnd === 'number' && + typeof control.setSelectionRange === 'function' + ) { + try { control.setSelectionRange(saved.selectionStart, saved.selectionEnd); } catch (e) {} + } + }); +} + +function ccuRestoreRefreshPosition(context, panel) { + requestAnimationFrame(function() { + requestAnimationFrame(function() { + var anchor = context.anchor + ? ccuResolveRefreshDescriptor(context.anchor.descriptor, panel) + : null; + if (anchor) { + window.scrollBy(0, anchor.getBoundingClientRect().top - context.anchor.top); + } else { + window.scrollTo(0, context.scrollY); + } + var focused = ccuResolveRefreshDescriptor(context.focus, panel); + if (focused && typeof focused.focus === 'function') { + try { focused.focus({ preventScroll: true }); } catch (e) { focused.focus(); } + } + if (anchor) { + window.scrollBy(0, anchor.getBoundingClientRect().top - context.anchor.top); + } + __ccuUiReady = true; + }); + }); +} + +function ccuRestoreDashboardUiAfterPatch(context, panel, tab) { + try { localStorage.setItem('ccu.activeTab', tab); } catch (e) {} + showTab(tab, true); + restoreSessionFilter(); + restorePersistedDetails(); + restoreClaudeDrilldownDetails(); + restoreCodexHourlyDetails(); + restoreAdviceEffectivenessState(); + restoreSessionDetails(); + restoreTableSorts(panel); + restoreChartMetrics(panel); + initializeChartDrilldowns(panel); + initializeHourlyOverviewSelections(panel); + restoreHourlyOverviewSelections(panel); + initializeStatusRegions(panel); + restoreCombinedHeatmapConfig(); + restoreProjectMatrixState(panel); + ccuRestoreTransientControls(context, panel); + formatOptSettings(); + requestLocalDataInventoryForVisibleSettings(); + ccuRestoreRefreshPosition(context, panel); +} + +function ccuApplyDashboardDataPatch(message) { + var revision = message && message.revision; + if (!Number.isSafeInteger(revision) || revision <= 0) { return false; } + if (revision <= __ccuLastDashboardPatchRevision) { + vscode.postMessage({ command: 'dashboardDataPatchAck', revision: revision, ok: true }); + return true; + } + var panel = document.getElementById('provider-panel'); + var valid = panel && + (message.provider === 'claude' || message.provider === 'codex') && + message.provider === ccuProviderName() && + ['today', 'month', 'all', 'sessions', 'projects', 'content', 'branches', 'workflows', 'settings'] + .indexOf(message.tab) !== -1 && + typeof message.html === 'string' && + message.html.length <= 32 * 1024 * 1024 && + !/)/i.test(message.html) && + message.claudeLast30HoursByDay && + Object.prototype.toString.call(message.claudeLast30HoursByDay) === '[object Object]'; + if (!valid) { + vscode.postMessage({ command: 'dashboardDataPatchAck', revision: revision, ok: false }); + return false; + } + try { + var context = ccuCaptureRefreshContext(panel); + if (__ccuScrollSaveTimer) { + clearTimeout(__ccuScrollSaveTimer); + __ccuScrollSaveTimer = 0; + } + __ccuScrollDirty = false; + __ccuUiReady = false; + panel.innerHTML = message.html; + __claudeLast30HoursByDay = message.claudeLast30HoursByDay; + __ccuLastDashboardPatchRevision = revision; + ccuRestoreDashboardUiAfterPatch(context, panel, message.tab); + vscode.postMessage({ command: 'dashboardDataPatchAck', revision: revision, ok: true }); + return true; + } catch (e) { + __ccuUiReady = true; + vscode.postMessage({ command: 'dashboardDataPatchAck', revision: revision, ok: false }); + return false; + } +} +`; +} diff --git a/src/displayRange.ts b/src/displayRange.ts new file mode 100644 index 00000000..d5c47225 --- /dev/null +++ b/src/displayRange.ts @@ -0,0 +1,40 @@ +/** A presentation row completed against an exact, ordered range. */ +export interface CompletedDisplayRow { + key: string; + value: T; + /** True when the row came from the source rather than the zero-row factory. */ + observed: boolean; +} + +/** Canonical 24-hour clock keys, ordered from 00 through 23. */ +export function clockHourKeys(): string[] { + return Array.from({ length: 24 }, (_, hour) => String(hour).padStart(2, '0')); +} + +/** + * Complete sparse source rows against an exact presentation range without + * mutating or widening the underlying aggregate. Invalid, duplicate, and + * out-of-range source keys are ignored; the first valid row wins so output is + * deterministic even when a caller supplies malformed data. + */ +export function completeDisplayRange( + keys: readonly string[], + rows: readonly T[], + keyOf: (row: T) => string, + makeEmpty: (key: string) => T, +): CompletedDisplayRow[] { + const expected = new Set(keys); + const observed = new Map(); + for (const row of rows) { + const key = keyOf(row); + if (expected.has(key) && !observed.has(key)) { + observed.set(key, row); + } + } + return keys.map((key) => { + const value = observed.get(key); + return value === undefined + ? { key, value: makeEmpty(key), observed: false } + : { key, value, observed: true }; + }); +} diff --git a/src/extension.ts b/src/extension.ts index 103043dc..5f4c3bc9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -74,6 +74,7 @@ import { reportColdRefreshFailure, shouldCommitUsageLoad, shouldReloadUsage, + watcherFailureBackoffMs, WindowActivityGate, } from './refreshPolicy'; import { @@ -172,11 +173,40 @@ interface QuotaObservationRuntime { salt: string; } +interface WatcherRecoveryState { + timer: NodeJS.Timeout | undefined; + timerLease: ResourceLease | undefined; + failureStreak: number; + lastFailureAt: number; +} + +type WatcherProvider = 'claude' | 'codex'; +type WatcherStopCondition = Extract< + ResourceStopCondition, + | 'settled' + | 'cancelled' + | 'window-blur' + | 'feature-disabled' + | 'extension-dispose' + | 'settings-change' + | 'profile-change' +>; + +function createWatcherRecoveryState(): WatcherRecoveryState { + return { + timer: undefined, + timerLease: undefined, + failureStreak: 0, + lastFailureAt: 0, + }; +} + const QUOTA_FINGERPRINT_SALT_KEY = 'ccu.quota.fingerprintSalt.v1'; const QUOTA_P5_MIGRATION_KEY = 'ccu.quota.migratedCodexIndex.v2'; const LEGACY_QUOTA_PREFIX = 'ccu.usageLimits.'; const LEGACY_WEEKLY_PREFIX = 'ccu.weeklyQuotaHistory.v1.'; const LOCAL_DATA_QUOTA_SCOPE_TTL_MS = 5 * 60_000; +const WATCHER_FAILURE_STREAK_RESET_MS = 5 * 60_000; const LOCAL_DATA_EXACT_GLOBAL_STATE_KEYS = [ ...new Set([ ...LOCAL_DATA_GLOBAL_STATE_KEYS, @@ -356,6 +386,10 @@ const WHATS_NEW: ReleaseAnnouncementCatalog = { version: '2.3.1', body: () => I18n.t.releaseAnnouncement.v231, }, + '2.3.2': { + version: '2.3.2', + body: () => I18n.t.releaseAnnouncement.v232, + }, }; export class ClaudeCodeUsageExtension { @@ -371,8 +405,10 @@ export class ClaudeCodeUsageExtension { private refreshTimerLease: ResourceLease | undefined; private fileWatcher: fs.FSWatcher | undefined; private fileWatcherLease: ResourceLease | undefined; + private claudeWatcherRecovery = createWatcherRecoveryState(); private codexWatchers: fs.FSWatcher[] = []; private readonly codexWatcherLeases = new Map(); + private codexWatcherRecovery = createWatcherRecoveryState(); private readonly debounceTimerLeases = new Map(); private readonly codexWatchDebounce = this.createOwnedRefreshDebounce('codex'); private codexWatchedHome: string | null = null; @@ -566,6 +602,8 @@ export class ClaudeCodeUsageExtension { // Rename showOpusWeekly -> showScopedWeekly (the API stopped naming Opus). // Runs once. this.queueInitializationWrite(() => this.settings.migrateScopedWeekly()); + // Collapse the early 2.3.2 free-form currency/rate pair into one preset. + this.queueInitializationWrite(() => this.settings.migrateCurrencyPreset()); // Usage Optimizer (Phase 9c): the webview posts a draft prompt; we run it // through the same model backend as the advice feature and post back a // tightened prompt + a settings recommendation. Consent gate lives here. @@ -1905,7 +1943,12 @@ export class ClaudeCodeUsageExtension { const data = buildShareCardData(input, DEFAULT_SECTIONS); const kind = vscode.window.activeColorTheme?.kind; const isDark = kind === vscode.ColorThemeKind.Dark || kind === vscode.ColorThemeKind.HighContrast; - const svg = renderShareCardSvg(data, { theme: 'claudeClassic', isDark, lang: I18n.getLocale() }); + const svg = renderShareCardSvg(data, { + theme: 'claudeClassic', + isDark, + lang: I18n.getLocale(), + formatCurrency: (amountUsd) => I18n.formatCurrency(amountUsd), + }); const defaultName = shareCardFilename(picked.range).replace(/\.png$/, '.svg'); const uri = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), @@ -2031,11 +2074,7 @@ export class ClaudeCodeUsageExtension { const config = this.getConfiguration(); this.activePricingBackend = config.pricingBackend; setPricingBackend(config.pricingBackend); - I18n.setLanguage(config.language as any); - I18n.setDecimalPlaces(config.decimalPlaces); - I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); - I18n.setCompactNumbers(config.compactNumbers); - I18n.setTimezone(config.timezone); + this.applyFormattingConfiguration(config); this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); // Listen for configuration changes @@ -2058,6 +2097,15 @@ export class ClaudeCodeUsageExtension { }); } + private applyFormattingConfiguration(config: ExtensionConfig): void { + I18n.setLanguage(config.language as any); + I18n.setDecimalPlaces(config.decimalPlaces); + I18n.setCurrencyDisplay(config.displayCurrency); + I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); + I18n.setCompactNumbers(config.compactNumbers); + I18n.setTimezone(config.timezone); + } + private getConfiguration(): ExtensionConfig { // All settings flow through SettingsStore: language and dataDirectory live // in VS Code config, BYOK secrets live in SecretStorage, and the rest use @@ -2078,6 +2126,7 @@ export class ClaudeCodeUsageExtension { ), language: s.get('language'), decimalPlaces: s.get('decimalPlaces'), + displayCurrency: s.get('displayCurrency'), tokenDecimalPlaces: s.get('tokenDecimalPlaces'), compactNumbers: s.get('compactNumbers'), releaseAnnouncements: s.get('releaseAnnouncements'), @@ -3044,11 +3093,25 @@ export class ClaudeCodeUsageExtension { // providers, or trigger a corpus reindex. private static readonly DASHBOARD_ONLY_SETTINGS = new Set([ 'showWeeklyEquivalentValue', + 'showProjectUsageMatrix', + ]); + + // These values only reformat already-materialized USD estimates. They must + // not restart file watchers, recreate providers, or rescan either corpus. + private static readonly COST_DISPLAY_SETTINGS = new Set([ + 'decimalPlaces', 'displayCurrency', ]); /** Dashboard Settings change — status-bar-only toggles apply in place, others reload. */ private onSettingsChangedFromPanel(key?: string): void { if (this.disposed) return; + if (key && ClaudeCodeUsageExtension.COST_DISPLAY_SETTINGS.has(key)) { + const config = this.getConfiguration(); + this.applyFormattingConfiguration(config); + this.webviewProvider.invalidateShareCardPreview(); + this.syncProviderUi(); + return; + } if (key && ClaudeCodeUsageExtension.DASHBOARD_ONLY_SETTINGS.has(key)) { this.syncProviderUi(); return; @@ -3122,11 +3185,7 @@ export class ClaudeCodeUsageExtension { if (pricingBackendChanged) { this.invalidateClaudeUsagePricingCache(); } - I18n.setLanguage(config.language as any); - I18n.setDecimalPlaces(config.decimalPlaces); - I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); - I18n.setCompactNumbers(config.compactNumbers); - I18n.setTimezone(config.timezone); + this.applyFormattingConfiguration(config); this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); // Restart auto-refresh with new interval @@ -3214,11 +3273,111 @@ export class ClaudeCodeUsageExtension { /** * Watch the Claude projects directory for new/changed jsonl lines so the * status bar reflects new usage within ~1.5 seconds instead of waiting for - * the polling timer. Falls back silently if fs.watch fails (some platforms / - * filesystems do not support recursive watching). + * the polling timer. Polling remains active while a failed watcher is + * rearmed with bounded exponential backoff. */ - private async startFileWatching(): Promise { + private watcherRecoveryState(provider: WatcherProvider): WatcherRecoveryState { + if (provider === 'claude') { + this.claudeWatcherRecovery ??= createWatcherRecoveryState(); + return this.claudeWatcherRecovery; + } + this.codexWatcherRecovery ??= createWatcherRecoveryState(); + return this.codexWatcherRecovery; + } + + private markWatcherHealthy(provider: WatcherProvider): void { + const state = this.watcherRecoveryState(provider); + if (state.timer) return; + state.failureStreak = 0; + state.lastFailureAt = 0; + } + + private resetWatcherRecovery( + provider: WatcherProvider, + condition: Exclude, + ): void { + const state = this.watcherRecoveryState(provider); + const timer = state.timer; + const lease = state.timerLease; + state.timer = undefined; + state.timerLease = undefined; + state.failureStreak = 0; + state.lastFailureAt = 0; + if (!timer) return; + if (lease?.active) { + this.trackResourceStop(lease.stop(condition, () => clearTimeout(timer))); + } else { + clearTimeout(timer); + } + } + + private scheduleWatcherRecovery( + provider: WatcherProvider, + error: unknown, + ): void { + if (this.disposed || !this.windowActivity.focused) return; + const state = this.watcherRecoveryState(provider); + if (state.timer) return; + const now = Date.now(); + if ( + state.lastFailureAt <= 0 || + now < state.lastFailureAt || + now - state.lastFailureAt > WATCHER_FAILURE_STREAK_RESET_MS + ) { + state.failureStreak = 0; + } + state.failureStreak += 1; + state.lastFailureAt = now; + const delayMs = watcherFailureBackoffMs(state.failureStreak); + const rawCode = (error as NodeJS.ErrnoException | undefined)?.code; + const code = typeof rawCode === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(rawCode) + ? rawCode + : 'watch-error'; + const label = provider === 'claude' ? 'Claude' : 'Codex'; + this.outputChannel.appendLine( + `${label} file watcher stopped (${code}); retrying in ${delayMs / 1000}s; polling remains active.`, + ); + + const lease = this.resourceOwnership.register({ + kind: 'timer', + capability: provider === 'claude' ? 'refresh' : 'codex-index', + scope: provider, + creator: 'refresh-coordinator', + stopConditions: [ + 'settled', + 'cancelled', + 'window-blur', + 'feature-disabled', + 'extension-dispose', + 'settings-change', + 'profile-change', + ], + boundedException: 'none', + }); + let timer!: NodeJS.Timeout; + timer = setTimeout(() => { + if (state.timer !== timer) return; + state.timer = undefined; + state.timerLease = undefined; + if (lease.active) { + this.trackResourceStop(lease.stop('settled', () => undefined)); + } + if (this.disposed || !this.windowActivity.focused) return; + if (provider === 'claude') { + void this.startFileWatching(true); + } else { + this.startCodexWatching(true); + } + }, delayMs); + state.timer = timer; + state.timerLease = lease; + } + + private async startFileWatching(recoveryAttempt = false): Promise { if (this.disposed || this.localDataClearedRequiresReload) return; + if (!recoveryAttempt) { + this.resetWatcherRecovery('claude', 'cancelled'); + } const requestGeneration = ++this.fileWatcherGeneration; if (!this.windowActivity.focused) { this.stopFileWatching('window-blur'); @@ -3229,7 +3388,15 @@ export class ClaudeCodeUsageExtension { this.stopFileWatching('feature-disabled'); // "Off" return; } - const dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory(config.dataDirectory || undefined); + let dataDirectory: string | null; + try { + dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory(config.dataDirectory || undefined); + } catch (error) { + if (requestGeneration === this.fileWatcherGeneration) { + this.scheduleWatcherRecovery('claude', error); + } + return; + } if ( this.disposed || requestGeneration !== this.fileWatcherGeneration @@ -3254,11 +3421,12 @@ export class ClaudeCodeUsageExtension { if (!fs.existsSync(projectsDir) || this.watchedDir === projectsDir) { return; } - this.stopFileWatching(); + this.closeFileWatcher('settings-change'); const activeGeneration = this.fileWatcherGeneration; try { const watcher = fs.watch(projectsDir, { recursive: true }, (_event, filename) => { if (this.disposed || activeGeneration !== this.fileWatcherGeneration) return; + this.markWatcherHealthy('claude'); if (!filename || !String(filename).endsWith('.jsonl')) { return; } @@ -3294,18 +3462,16 @@ export class ClaudeCodeUsageExtension { }); watcher.on('error', (error) => { if (this.fileWatcher !== watcher || activeGeneration !== this.fileWatcherGeneration) return; - const code = (error as NodeJS.ErrnoException).code || 'watch-error'; - this.outputChannel.appendLine( - `Claude file watcher stopped (${code}); polling remains active.`, - ); - this.stopFileWatching('cancelled'); + this.closeFileWatcher('cancelled'); + this.scheduleWatcherRecovery('claude', error); }); - } catch { - // Recursive watching unsupported — the polling timer is enough. + } catch (error) { + this.closeFileWatcher('cancelled'); + this.scheduleWatcherRecovery('claude', error); } } - private stopFileWatching( + private closeFileWatcher( condition: Extract< ResourceStopCondition, 'cancelled' | 'window-blur' | 'feature-disabled' | 'extension-dispose' | 'settings-change' | 'profile-change' @@ -3331,8 +3497,21 @@ export class ClaudeCodeUsageExtension { this.watchedDir = null; } - private startCodexWatching(): void { + private stopFileWatching( + condition: Extract< + ResourceStopCondition, + 'cancelled' | 'window-blur' | 'feature-disabled' | 'extension-dispose' | 'settings-change' | 'profile-change' + > = 'settings-change', + ): void { + this.resetWatcherRecovery('claude', condition); + this.closeFileWatcher(condition); + } + + private startCodexWatching(recoveryAttempt = false): void { if (this.disposed || this.localDataClearedRequiresReload) return; + if (!recoveryAttempt) { + this.resetWatcherRecovery('codex', 'cancelled'); + } if (!this.windowActivity.focused) { this.stopCodexWatching('window-blur'); return; @@ -3343,11 +3522,16 @@ export class ClaudeCodeUsageExtension { return; } const codexHome = this.codexHome(config); - if (this.codexWatchedHome === codexHome && this.codexWatchers.length > 0) { + if ( + !recoveryAttempt && + this.codexWatchedHome === codexHome && + this.codexWatchers.length > 0 + ) { return; } - this.stopCodexWatching(); + this.closeCodexWatchers('settings-change'); const activeGeneration = this.codexWatcherGeneration; + let watchFailure: unknown; for (const child of ['sessions', 'archived_sessions']) { const directory = path.join(codexHome, child); if (!fs.existsSync(directory)) { @@ -3359,6 +3543,7 @@ export class ClaudeCodeUsageExtension { { recursive: true }, (_event, filename) => { if (this.disposed || activeGeneration !== this.codexWatcherGeneration) return; + this.markWatcherHealthy('codex'); if (!filename || !String(filename).endsWith('.jsonl')) { return; } @@ -3373,6 +3558,14 @@ export class ClaudeCodeUsageExtension { }, ); this.codexWatchers.push(watcher); + watcher.on('error', (error) => { + if ( + activeGeneration !== this.codexWatcherGeneration || + !this.codexWatchers.includes(watcher) + ) return; + this.closeCodexWatchers('cancelled'); + this.scheduleWatcherRecovery('codex', error); + }); this.codexWatcherLeases.set(watcher, this.resourceOwnership.register({ kind: 'watcher', capability: 'codex-index', @@ -3383,21 +3576,25 @@ export class ClaudeCodeUsageExtension { 'feature-disabled', 'extension-dispose', 'settings-change', + 'cancelled', ], boundedException: 'none', })); - } catch { - // Polling remains available when recursive watches are unsupported. + } catch (error) { + watchFailure ??= error; } } this.codexWatchedHome = this.codexWatchers.length > 0 ? codexHome : null; + if (watchFailure) { + this.scheduleWatcherRecovery('codex', watchFailure); + } } - private stopCodexWatching( + private closeCodexWatchers( condition: Extract< ResourceStopCondition, - 'window-blur' | 'feature-disabled' | 'extension-dispose' | 'settings-change' + 'cancelled' | 'window-blur' | 'feature-disabled' | 'extension-dispose' | 'settings-change' > = 'settings-change', ): void { this.codexWatcherGeneration += 1; @@ -3419,6 +3616,16 @@ export class ClaudeCodeUsageExtension { this.codexWatchedHome = null; } + private stopCodexWatching( + condition: Extract< + ResourceStopCondition, + 'cancelled' | 'window-blur' | 'feature-disabled' | 'extension-dispose' | 'settings-change' + > = 'settings-change', + ): void { + this.resetWatcherRecovery('codex', condition); + this.closeCodexWatchers(condition); + } + /** * Watch the OAuth credentials file so switching Claude accounts updates the * quota promptly. Without this, the quota stays on the previous account's @@ -4093,6 +4300,7 @@ export class ClaudeCodeUsageExtension { const dailyDataForRolling30 = materialized.dailyForLast30Days; const dailyDataForAllTime = materialized.monthlyForAllTime; const hourlyDataForToday = materialized.hourlyForToday; + const hourlyDataForRolling30DaysByDay = materialized.hourlyForLast30DaysByDay; const sessionBreakdown = materialized.sessions; const projectBreakdown = materialized.projects; const branchBreakdown = materialized.branches; @@ -4105,7 +4313,7 @@ export class ClaudeCodeUsageExtension { this.statusBar.updateUsageData(todayData, workspaceTodayData, undefined, undefined, calendarMonthData); this.statusBar.updateContext(materialized.context); if (updateWebview) { - this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages); + this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay, materialized.projectUsageMatrix); } } diff --git a/src/i18n.ts b/src/i18n.ts index 4c9d4e00..dc877fa8 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,4 +1,9 @@ import { SupportedLanguage } from './types'; +import { + formatUsdBaseline as formatUsdBaselineValue, + formatUsdForDisplay, + resolveCurrencyDisplay, +} from './currencyDisplay'; import { CODEX_COPY_EN, CodexViewCopy } from './codexView'; export interface ProviderTranslations { @@ -110,6 +115,7 @@ export interface Translations { releaseAnnouncement: { v230: string; v231: string; + v232: string; }; providers: ProviderTranslations; weeklyValue: WeeklyValueCopy; @@ -601,6 +607,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': 'Fehlende Token-Informationen', 'invalid-token-count': 'Ungültige Token-Anzahl', 'counter-regression': 'Rückläufiger Nutzungszähler', + 'component-delta-clamped': 'Delta für Cache- oder Reasoning-Tokens überschritt die übergeordnete Summe und wurde begrenzt', 'missing-parent': 'Protokoll der übergeordneten Sitzung fehlt; konservative Nutzung beibehalten', 'index-backfill-incomplete': 'Nutzungsindex wird noch aufgebaut; aktuelle Werte sind unvollständig und die Indexierung wird automatisch fortgesetzt', 'ambiguous-session-identity': 'Doppelte Sitzungsidentität ist mehrdeutig; beide lokalen Kopien werden beibehalten', @@ -644,6 +651,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': '缺少 Token 資訊', 'invalid-token-count': '無效的 Token 數量', 'counter-regression': '用量計數器回退', + 'component-delta-clamped': '快取或推理 Token 增量超過其上層總量,已限制於總量內', 'missing-parent': '缺少父工作階段記錄;已保留保守用量', 'index-backfill-incomplete': '用量索引仍在建立;目前數字不完整,索引會自動繼續', 'ambiguous-session-identity': '重複工作階段身分無法安全判定;兩份本機副本均已保留', @@ -687,6 +695,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': '缺少 Token 信息', 'invalid-token-count': '无效的 Token 数量', 'counter-regression': '用量计数器回退', + 'component-delta-clamped': '缓存或推理 Token 增量超过其上层总量,已限制在总量内', 'missing-parent': '缺少父会话日志;已保留保守用量', 'index-backfill-incomplete': '用量索引仍在建立;当前数字不完整,索引会自动继续', 'ambiguous-session-identity': '重复会话身份无法安全判定;两个本地副本均已保留', @@ -730,6 +739,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': 'トークン情報がありません', 'invalid-token-count': '無効なトークン数', 'counter-regression': '使用量カウンターの後退', + 'component-delta-clamped': 'キャッシュまたは推論トークンの差分が親の合計を超えたため上限を適用', 'missing-parent': '親セッションのログがないため、保守的な使用量を保持', 'index-backfill-incomplete': '使用量インデックスを作成中です。現在の数値は不完全で、インデックス作成は自動的に続行されます', 'ambiguous-session-identity': '重複セッションの同一性を確定できないため、両方のローカルコピーを保持しています', @@ -773,6 +783,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': '토큰 정보 누락', 'invalid-token-count': '잘못된 토큰 수', 'counter-regression': '사용량 카운터 역행', + 'component-delta-clamped': '캐시 또는 추론 토큰 증가분이 상위 합계를 초과하여 상한을 적용함', 'missing-parent': '부모 세션 로그 누락; 보수적 사용량 유지', 'index-backfill-incomplete': '사용량 인덱스를 만드는 중입니다. 현재 수치는 불완전하며 인덱싱은 자동으로 계속됩니다', 'ambiguous-session-identity': '중복 세션의 동일성을 확정할 수 없어 두 로컬 사본을 모두 유지합니다', @@ -816,6 +827,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': 'Informações de token ausentes', 'invalid-token-count': 'Contagem de tokens inválida', 'counter-regression': 'Regressão do contador de uso', + 'component-delta-clamped': 'O delta de tokens em cache ou de raciocínio excedeu o total pai e foi limitado', 'missing-parent': 'Log da sessão pai ausente; uso conservador mantido', 'index-backfill-incomplete': 'O índice de uso ainda está sendo criado; os números atuais estão incompletos e a indexação continuará automaticamente', 'ambiguous-session-identity': 'A identidade da sessão duplicada é ambígua; ambas as cópias locais foram mantidas', @@ -859,6 +871,7 @@ const TASK8_CODEX_COPY: Record< 'missing-token-info': 'Informasi token tidak ada', 'invalid-token-count': 'Jumlah token tidak valid', 'counter-regression': 'Penghitung penggunaan mundur', + 'component-delta-clamped': 'Delta token cache atau penalaran melebihi total induknya dan telah dibatasi', 'missing-parent': 'Log sesi induk tidak ada; penggunaan konservatif dipertahankan', 'index-backfill-incomplete': 'Indeks penggunaan masih dibuat; angka saat ini belum lengkap dan pengindeksan akan berlanjut otomatis', 'ambiguous-session-identity': 'Identitas sesi duplikat ambigu; kedua salinan lokal dipertahankan', @@ -1176,6 +1189,7 @@ const translations: Record = { releaseAnnouncement: { v230: "What's new — Codex Beta usage and local optimization guidance, exact-version release notes, and removal of the obsolete model-specific weekly Opus option.", v231: 'Accurate 30-day Codex totals, reset-aware weekly allowance estimates, and a private combined activity heatmap with local SVG and Markdown sharing.', + v232: 'New in 2.3.2: a 30/90-day project activity matrix for Claude and Codex, complete chart drill-downs, state-preserving refresh, and a compact fixed-rate currency selector.', }, providers: PROVIDERS.en, weeklyValue: WEEKLY_VALUE_COPY.en, @@ -1476,6 +1490,7 @@ const translations: Record = { releaseAnnouncement: { v230: 'Neu: Codex-Beta-Nutzung und lokale Optimierungshinweise, versionsgenaue Release-Hinweise und Entfernung der veralteten modellspezifischen wöchentlichen Opus-Option.', v231: 'Neu: korrekte 30-Tage-Codex-Werte, reset-bewusste Wochenschätzungen und eine private kombinierte Aktivitäts-Heatmap mit lokalem SVG- und Markdown-Export.', + v232: 'Neu in 2.3.2: eine 30-/90-Tage-Projektaktivitätsmatrix für Claude und Codex, vollständige Diagramm-Drilldowns, zustandserhaltende Aktualisierung und eine kompakte Währungsauswahl mit festen Referenzkursen.', }, providers: PROVIDERS['de-DE'], weeklyValue: WEEKLY_VALUE_COPY['de-DE'], @@ -1779,6 +1794,7 @@ const translations: Record = { releaseAnnouncement: { v230: '新功能:Codex Beta 用量與本機優化建議、與安裝版本精確對應的更新說明,並移除已過時的特定模型每週 Opus 選項。', v231: '新功能:正確的 Codex 最近 30 天統計、可識別重置的每週額度估算,以及可匯出本機 SVG/Markdown 的隱私安全綜合活動熱力圖。', + v232: '2.3.2 新功能:Claude 與 Codex 的 30/90 天專案活動矩陣、完整圖表下鑽、保留介面狀態的重新整理,以及採用固定參考匯率的精簡幣別下拉選單。', }, providers: PROVIDERS['zh-TW'], weeklyValue: WEEKLY_VALUE_COPY['zh-TW'], @@ -2075,6 +2091,7 @@ const translations: Record = { releaseAnnouncement: { v230: '新功能:Codex Beta 用量与本地优化建议、与安装版本精确对应的更新说明,并移除已过时的特定模型每周 Opus 选项。', v231: '新功能:准确的 Codex 最近 30 天统计、可识别重置的每周额度估算,以及可导出本地 SVG/Markdown 的隐私安全综合活动热力图。', + v232: '2.3.2 新功能:Claude 与 Codex 的 30/90 天项目活动矩阵、完整图表下钻、保留界面状态的刷新,以及采用固定参考汇率的紧凑币种下拉栏。', }, providers: PROVIDERS['zh-CN'], weeklyValue: WEEKLY_VALUE_COPY['zh-CN'], @@ -2371,6 +2388,7 @@ const translations: Record = { releaseAnnouncement: { v230: '新機能:Codex Beta の使用量とローカル最適化ガイド、完全なバージョンに対応するリリース通知、および古いモデル別の週間 Opus オプションの削除。', v231: '新機能:正確な Codex の直近 30 日集計、リセットを考慮した週間枠の推定、ローカル SVG/Markdown 共有に対応したプライバシー保護の統合アクティビティヒートマップ。', + v232: '2.3.2 の新機能:Claude と Codex の30/90日プロジェクト活動マトリクス、完全なチャートドリルダウン、状態を保つ更新、固定参照レートのコンパクトな通貨選択。', }, providers: PROVIDERS.ja, weeklyValue: WEEKLY_VALUE_COPY.ja, @@ -2672,6 +2690,7 @@ const translations: Record = { releaseAnnouncement: { v230: '새 기능: Codex Beta 사용량과 로컬 최적화 안내, 설치된 전체 버전에 맞는 릴리스 알림, 그리고 오래된 모델별 주간 Opus 옵션 제거.', v231: '새 기능: 정확한 Codex 최근 30일 통계, 재설정을 인식하는 주간 한도 추정, 로컬 SVG/Markdown 공유를 지원하는 개인정보 보호 통합 활동 히트맵.', + v232: '2.3.2 새 기능: Claude와 Codex의 30/90일 프로젝트 활동 매트릭스, 완전한 차트 드릴다운, 상태를 보존하는 새로 고침, 고정 기준 환율의 간결한 통화 선택.', }, providers: PROVIDERS.ko, weeklyValue: WEEKLY_VALUE_COPY.ko, @@ -2973,6 +2992,7 @@ const translations: Record = { releaseAnnouncement: { v230: 'Novidades: uso do Codex Beta e orientações locais de otimização, avisos da versão exata instalada e remoção da opção semanal obsoleta do Opus por modelo.', v231: 'Novidades: totais corretos dos últimos 30 dias do Codex, estimativas semanais cientes de redefinições e um mapa de calor combinado e privado com exportação local em SVG e Markdown.', + v232: 'Novidades da 2.3.2: matriz de atividade por projeto em 30/90 dias para Claude e Codex, detalhamento completo dos gráficos, atualização que preserva o estado e seletor compacto de moeda com taxas de referência fixas.', }, providers: PROVIDERS['pt-BR'], weeklyValue: WEEKLY_VALUE_COPY['pt-BR'], @@ -3273,6 +3293,7 @@ const translations: Record = { releaseAnnouncement: { v230: 'Yang baru: penggunaan Codex Beta dan panduan optimasi lokal, catatan rilis yang sesuai dengan versi lengkap terpasang, serta penghapusan opsi Opus mingguan khusus model yang sudah usang.', v231: 'Yang baru: statistik 30 hari Codex yang akurat, estimasi batas mingguan yang mengenali reset, dan heatmap aktivitas gabungan privat dengan ekspor SVG serta Markdown lokal.', + v232: 'Baru di 2.3.2: matriks aktivitas proyek 30/90 hari untuk Claude dan Codex, drill-down grafik lengkap, refresh yang mempertahankan state, serta pemilih mata uang ringkas dengan kurs referensi tetap.', }, providers: PROVIDERS.id, weeklyValue: WEEKLY_VALUE_COPY.id, @@ -3573,6 +3594,7 @@ const SETTINGS_I18N: Partial { + return { ...this.currencyDisplay }; + } + /** Decimals for compact token display, 0–2 (claudeCodeUsage.tokenDecimalPlaces). */ static setTokenDecimalPlaces(places: number): void { if (typeof places === 'number' && isFinite(places) && places >= 0 && places <= 2) { @@ -4045,7 +4100,13 @@ export class I18n { static formatCurrency(amount: number, decimalPlaces?: number): string { const places = decimalPlaces != null ? decimalPlaces : this.currentDecimalPlaces; - return `$${amount.toFixed(places)}`; + return formatUsdForDisplay(amount, this.currencyDisplay.code, places); + } + + /** Provider-native USD money (for example actual usage credits), never the + * user's display conversion. */ + static formatUsdBaseline(amount: number, decimalPlaces = 2): string { + return formatUsdBaselineValue(amount, decimalPlaces); } /** Always-compact token count (k / M / B) honouring the user's decimal diff --git a/src/projectUsageMatrix.ts b/src/projectUsageMatrix.ts new file mode 100644 index 00000000..e76bdb54 --- /dev/null +++ b/src/projectUsageMatrix.ts @@ -0,0 +1,265 @@ +import { rollingDayKeysFromDayKey } from './dateKeys'; + +export type ProjectMatrixCoverage = 'complete' | 'partial' | 'none'; + +export interface ProjectDayPoint { + projectKey: string; + projectName: string; + day: string; + tokens: number; + coverage: ProjectMatrixCoverage; +} + +export interface ProjectUsageMatrixSnapshot { + provider: 'claude' | 'codex'; + timeZone: string; + asOfDay: string; + /** One bounded sparse source supports both the 30- and 90-day projections. */ + days: string[]; + points: ProjectDayPoint[]; + coverage: ProjectMatrixCoverage; +} + +export interface ProjectHeatmapCell extends ProjectDayPoint { + bucket: 0 | 1 | 2 | 3 | 4; +} + +export interface ProjectHeatmapRow { + projectKey: string; + projectName: string; + totalTokens: number; + activeDays: number; + cells: ProjectHeatmapCell[]; +} + +export interface ProjectTrendSeries { + projectKey: string; + projectName: string; + totalTokens: number; + other: boolean; + values: Array<{ + day: string; + tokens: number; + coverage: ProjectMatrixCoverage; + }>; +} + +function safeTokenCount(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function safeProjectName(value: string): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function mergeCoverage( + left: ProjectMatrixCoverage, + right: ProjectMatrixCoverage, +): ProjectMatrixCoverage { + if (left === right) return left; + if (left === 'partial' || right === 'partial') return 'partial'; + return 'partial'; +} + +function normalizedRangeDays(value: number): 30 | 90 { + return value === 90 ? 90 : 30; +} + +function rangeDays( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number, +): string[] { + return snapshot.days.slice(-normalizedRangeDays(requestedDays)); +} + +/** Build the single bounded sparse source consumed by both visual projections. */ +export function buildProjectUsageMatrixSnapshot( + provider: 'claude' | 'codex', + points: Iterable, + options: { + asOfDay: string; + timeZone: string; + coverage?: ProjectMatrixCoverage; + }, +): ProjectUsageMatrixSnapshot { + const days = rollingDayKeysFromDayKey(options.asOfDay, 90); + const allowedDays = new Set(days); + const combined = new Map(); + + for (const point of points) { + const projectKey = typeof point.projectKey === 'string' + ? point.projectKey.trim() + : ''; + if (!projectKey || !allowedDays.has(point.day)) continue; + const key = `${projectKey}\0${point.day}`; + const previous = combined.get(key); + const projectName = safeProjectName(point.projectName) || previous?.projectName || ''; + combined.set(key, { + projectKey, + projectName, + day: point.day, + tokens: safeTokenCount(point.tokens) + (previous?.tokens ?? 0), + coverage: previous + ? mergeCoverage(previous.coverage, point.coverage) + : point.coverage, + }); + } + + return { + provider, + timeZone: options.timeZone, + asOfDay: options.asOfDay, + days, + points: [...combined.values()].sort( + (left, right) => + left.day.localeCompare(right.day) || + left.projectName.localeCompare(right.projectName) || + left.projectKey.localeCompare(right.projectKey), + ), + coverage: options.coverage ?? 'complete', + }; +} + +function bucketScale(positiveValues: readonly number[]): (value: number) => 0 | 1 | 2 | 3 | 4 { + const unique = [...new Set(positiveValues)].sort((left, right) => left - right); + return (value: number): 0 | 1 | 2 | 3 | 4 => { + if (!(value > 0) || unique.length === 0) return 0; + if (unique.length === 1) return 4; + const rank = unique.findIndex((candidate) => candidate >= value); + const position = rank >= 0 ? rank + 1 : unique.length; + return (1 + Math.floor(((position - 1) * 3) / (unique.length - 1))) as 1 | 2 | 3 | 4; + }; +} + +function groupedRows( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number, +): Array<{ + projectKey: string; + projectName: string; + totalTokens: number; + activeDays: number; + byDay: Map; +}> { + const days = new Set(rangeDays(snapshot, requestedDays)); + const projects = new Map; + }>(); + for (const point of snapshot.points) { + if (!days.has(point.day)) continue; + const row = projects.get(point.projectKey) ?? { + projectName: point.projectName, + totalTokens: 0, + activeDays: 0, + byDay: new Map(), + }; + row.projectName ||= point.projectName; + row.totalTokens += point.tokens; + if (point.tokens > 0) row.activeDays += 1; + row.byDay.set(point.day, point); + projects.set(point.projectKey, row); + } + return [...projects.entries()] + .map(([projectKey, row]) => ({ projectKey, ...row })) + .filter((row) => row.totalTokens > 0) + .sort( + (left, right) => + right.totalTokens - left.totalTokens || + right.activeDays - left.activeDays || + left.projectName.localeCompare(right.projectName) || + left.projectKey.localeCompare(right.projectKey), + ); +} + +/** Project rows with one shared quantile scale across every visible cell. */ +export function projectHeatmap( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number = 30, + maxRows: number = 40, +): ProjectHeatmapRow[] { + const days = rangeDays(snapshot, requestedDays); + const daySet = new Set(days); + const rows = groupedRows(snapshot, requestedDays) + .slice(0, Math.max(1, Math.min(40, Math.floor(maxRows)))); + const bucketForValue = bucketScale(snapshot.points + .filter((point) => daySet.has(point.day) && point.tokens > 0) + .map((point) => point.tokens)); + return rows.map((row) => ({ + projectKey: row.projectKey, + projectName: row.projectName, + totalTokens: row.totalTokens, + activeDays: row.activeDays, + cells: days.map((day): ProjectHeatmapCell => { + const point = row.byDay.get(day) ?? { + projectKey: row.projectKey, + projectName: row.projectName, + day, + tokens: 0, + coverage: snapshot.coverage, + }; + return { ...point, bucket: bucketForValue(point.tokens) }; + }), + })); +} + +/** Daily stacked series. The final series is a lossless Other tail when needed. */ +export function projectTrend( + snapshot: ProjectUsageMatrixSnapshot, + requestedDays: number = 30, + maxSeries: number = 6, +): ProjectTrendSeries[] { + const days = rangeDays(snapshot, requestedDays); + const daySet = new Set(days); + const rows = groupedRows(snapshot, requestedDays); + const seriesLimit = Math.max(2, Math.min(12, Math.floor(maxSeries))); + const hasTail = rows.length > seriesLimit; + const visibleCount = hasTail ? seriesLimit - 1 : rows.length; + const visibleRows = rows.slice(0, visibleCount); + const visibleKeys = new Set(visibleRows.map((row) => row.projectKey)); + const result = visibleRows.map((row): ProjectTrendSeries => ({ + projectKey: row.projectKey, + projectName: row.projectName, + totalTokens: row.totalTokens, + other: false, + values: days.map((day) => { + const point = row.byDay.get(day); + return { + day, + tokens: point?.tokens ?? 0, + coverage: point?.coverage ?? snapshot.coverage, + }; + }), + })); + + if (hasTail) { + const tailByDay = new Map(); + let tailTotal = 0; + for (const point of snapshot.points) { + if (!daySet.has(point.day) || visibleKeys.has(point.projectKey)) continue; + const previous = tailByDay.get(point.day); + tailByDay.set(point.day, { + tokens: (previous?.tokens ?? 0) + point.tokens, + coverage: previous + ? mergeCoverage(previous.coverage, point.coverage) + : point.coverage, + }); + tailTotal += point.tokens; + } + result.push({ + projectKey: '__other__', + projectName: '', + totalTokens: tailTotal, + other: true, + values: days.map((day) => ({ + day, + tokens: tailByDay.get(day)?.tokens ?? 0, + coverage: tailByDay.get(day)?.coverage ?? snapshot.coverage, + })), + }); + } + + return result; +} diff --git a/src/providers/codex/codexIdentity.ts b/src/providers/codex/codexIdentity.ts index 3013bd55..6578c985 100644 --- a/src/providers/codex/codexIdentity.ts +++ b/src/providers/codex/codexIdentity.ts @@ -7,6 +7,30 @@ import { createInterface } from 'node:readline'; const MAX_TITLE_LENGTH = 200; const MAX_PROJECT_LABEL_LENGTH = 120; +interface SessionIndexFileStamp { + size: number; + mtimeMs: number; + dev: number; + ino: number; +} + +interface SessionTitleCacheIo { + stat(indexPath: string): Promise; + read(indexPath: string, salt: string): Promise | undefined>; +} + +interface SessionTitleCacheEntry extends SessionIndexFileStamp { + indexPath: string; + saltFingerprint: string; + titles: Map; +} + +interface SessionTitleCachePending extends SessionIndexFileStamp { + indexPath: string; + saltFingerprint: string; + promise: Promise>; +} + export interface CodexProjectIdentity { keySource?: string; name?: string; @@ -245,20 +269,46 @@ function sessionTitleRecord( } } -export async function loadCodexSessionTitles( - codexHome: string, - salt: string, -): Promise> { - const indexPath = path.join(codexHome, 'session_index.jsonl'); +async function sessionIndexFileStamp( + indexPath: string, +): Promise { try { const info = await lstat(indexPath); if (!info.isFile() || info.isSymbolicLink()) { - return new Map(); + return undefined; + } + if ( + !Number.isFinite(info.size) || + info.size < 0 || + !Number.isFinite(info.mtimeMs) + ) { + return undefined; } + return { + size: info.size, + mtimeMs: info.mtimeMs, + dev: Number.isFinite(info.dev) ? info.dev : 0, + ino: Number.isFinite(info.ino) ? info.ino : 0, + }; } catch { - return new Map(); + return undefined; } +} + +function sameSessionIndexFile( + left: SessionIndexFileStamp, + right: SessionIndexFileStamp, +): boolean { + return left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.dev === right.dev && + left.ino === right.ino; +} +async function readCodexSessionTitles( + indexPath: string, + salt: string, +): Promise | undefined> { const titleRecords = new Map< string, { title: string; updatedAt?: number; ordinal: number } @@ -295,8 +345,111 @@ export async function loadCodexSessionTitles( [...titleRecords.entries()].map(([key, record]) => [key, record.title]), ); } catch { - return new Map(); + return undefined; } finally { lines.close(); } } + +const SESSION_TITLE_CACHE_IO: SessionTitleCacheIo = { + stat: sessionIndexFileStamp, + read: readCodexSessionTitles, +}; + +function sessionTitleSaltFingerprint(salt: string): string { + return createHash('sha256') + .update('codex-session-title-cache\0') + .update(salt) + .digest('hex'); +} + +function sameSessionTitleCacheInput( + left: SessionTitleCacheEntry | SessionTitleCachePending, + indexPath: string, + saltFingerprint: string, + stamp: SessionIndexFileStamp, +): boolean { + return left.indexPath === indexPath && + left.saltFingerprint === saltFingerprint && + sameSessionIndexFile(left, stamp); +} + +/** Runtime-only cache for the allowlisted Codex title index. The cache key is + * accepted only after a regular-file lstat before and after the streaming read; + * titles and the derived salt fingerprint are never persisted. */ +export class CodexSessionTitleCache { + private cached: SessionTitleCacheEntry | undefined; + private pending: SessionTitleCachePending | undefined; + + constructor(private readonly io: SessionTitleCacheIo = SESSION_TITLE_CACHE_IO) {} + + async load(codexHome: string, salt: string): Promise> { + const indexPath = path.join(codexHome, 'session_index.jsonl'); + const saltFingerprint = sessionTitleSaltFingerprint(salt); + const before = await this.io.stat(indexPath); + if (!before) { + this.clear(); + return new Map(); + } + if ( + this.cached && + sameSessionTitleCacheInput(this.cached, indexPath, saltFingerprint, before) + ) { + return this.cached.titles; + } + if ( + this.pending && + sameSessionTitleCacheInput(this.pending, indexPath, saltFingerprint, before) + ) { + return this.pending.promise; + } + + let promise!: Promise>; + promise = (async () => { + const titles = await this.io.read(indexPath, salt); + if (!titles) { + this.cached = undefined; + return new Map(); + } + const after = await this.io.stat(indexPath); + if (after && sameSessionIndexFile(before, after)) { + this.cached = { + indexPath, + saltFingerprint, + ...after, + titles, + }; + return titles; + } + // A writer or path replacement raced this stream. Fail closed for this + // snapshot and require the next refresh to read one stable regular file. + this.cached = undefined; + return new Map(); + })(); + this.pending = { + indexPath, + saltFingerprint, + ...before, + promise, + }; + try { + return await promise; + } finally { + if (this.pending?.promise === promise) { + this.pending = undefined; + } + } + } + + clear(): void { + this.cached = undefined; + this.pending = undefined; + } +} + +export async function loadCodexSessionTitles( + codexHome: string, + salt: string, +): Promise> { + return new CodexSessionTitleCache().load(codexHome, salt); +} diff --git a/src/providers/codex/codexProvider.ts b/src/providers/codex/codexProvider.ts index 32dcbb90..248b0180 100644 --- a/src/providers/codex/codexProvider.ts +++ b/src/providers/codex/codexProvider.ts @@ -38,7 +38,7 @@ import { CodexWorkerRefreshInput, CodexWorkerResult, } from './codexWorkerProtocol'; -import { loadCodexSessionTitles } from './codexIdentity'; +import { CodexSessionTitleCache } from './codexIdentity'; import { classifyCodexSessionDuplicates } from './codexDedup'; import { equivalentUsageFromProviderTokens, @@ -346,6 +346,7 @@ export class CodexProvider { private lastProgress: CodexIndexProgress | undefined; private snapshotGeneration = 0; private persistedSnapshotLoad: Promise | null = null; + private readonly sessionTitleCache = new CodexSessionTitleCache(); private disposed = false; private disposal: Promise | null = null; @@ -389,7 +390,7 @@ export class CodexProvider { try { const [index, sessionTitles] = await Promise.all([ loadCodexIndex(this.options.indexPath, this.options.timeZone), - loadCodexSessionTitles(this.options.codexHome, this.options.salt), + this.sessionTitleCache.load(this.options.codexHome, this.options.salt), ]); if ( index.coverage.totalFiles === 0 && @@ -452,7 +453,7 @@ export class CodexProvider { onProgress?.(progress); }, ); - const sessionTitles = await loadCodexSessionTitles( + const sessionTitles = await this.sessionTitleCache.load( this.options.codexHome, this.options.salt, ); @@ -516,6 +517,7 @@ export class CodexProvider { if (this.disposal) return this.disposal; this.disposed = true; this.snapshotGeneration += 1; + this.sessionTitleCache.clear(); const client = this.client; this.client = null; this.disposal = Promise.resolve() diff --git a/src/providers/codex/codexUsage.ts b/src/providers/codex/codexUsage.ts index 42fb64a3..41886280 100644 --- a/src/providers/codex/codexUsage.ts +++ b/src/providers/codex/codexUsage.ts @@ -36,6 +36,11 @@ import { summarizeEquivalentCostBreakdowns, WeeklyValueInputs, } from '../../weeklyValue'; +import { + buildProjectUsageMatrixSnapshot, + ProjectMatrixCoverage, + ProjectUsageMatrixSnapshot, +} from '../../projectUsageMatrix'; export interface CodexMetricTotals { processed: number; @@ -189,6 +194,10 @@ export interface CodexUsageView { last30Days: CodexUsageScopeView; allTime: CodexUsageScopeView; projects: CodexProjectUsageView[]; + projectUsageMatrix: ProjectUsageMatrixSnapshot; + /** Complete derived day series retained in host memory for month drill-downs. */ + allTimeDaily: CodexDailyUsageView[]; + /** Bounded recent day series used by the share heatmap payload. */ daily: CodexDailyUsageView[]; last7DaysDaily: CodexDailyUsageView[]; last30DaysDaily: CodexDailyUsageView[]; @@ -615,7 +624,6 @@ function dailyRows( } return [...days.entries()] .sort(([left], [right]) => right.localeCompare(left)) - .slice(0, MAX_DAILY_ROWS) .map(([day, row]) => { const total = metrics(row.tokens); return { @@ -1055,6 +1063,39 @@ export function buildCodexUsageView( group.push(file); projects.set(key, group); } + const exactMatrixPeriods = snapshot.files.every((file) => + file.period?.timeZone === periodCoverage.timeZone && + (file.period.lineageVersion === undefined || + file.period.lineageVersion === CODEX_PERIOD_LINEAGE_VERSION) && + codexPeriodFitsAggregate(file.period, file.total), + ); + const matrixCoverage: ProjectMatrixCoverage = + snapshot.coverage.complete && periodCoverage.allTime.complete && exactMatrixPeriods + ? 'complete' + : 'partial'; + const projectUsageMatrix = buildProjectUsageMatrixSnapshot( + 'codex', + [...projects.entries()].flatMap(([projectKey, files]) => { + const projectName = identityValue(files, 'projectName') ?? + identityValue(files, 'projectDirectoryName') ?? + ''; + const viewKey = `codex:${stableCodexViewKey(projectKey)}`; + return files.flatMap((file) => + periodSlicesForFile(file, periodCoverage.timeZone).map(([day, slice]) => ({ + projectKey: viewKey, + projectName, + day, + tokens: processedTokens(slice.total), + coverage: matrixCoverage, + })), + ); + }), + { + asOfDay: periodCoverage.asOfDay, + timeZone: periodCoverage.timeZone, + coverage: matrixCoverage, + }, + ); const aggregateIndexIncomplete = !snapshot.coverage.complete || !periodCoverage.last7Days.complete || @@ -1085,7 +1126,8 @@ export function buildCodexUsageView( aggregateIndexIncomplete, ); const allTime = scope(snapshot.files, aggregateIndexIncomplete); - const daily = dailyRows(snapshot.files, periodCoverage.timeZone); + const allTimeDaily = dailyRows(snapshot.files, periodCoverage.timeZone); + const daily = allTimeDaily.slice(0, MAX_DAILY_ROWS); const last30DaysHourlyByDay = Object.fromEntries( last30DayKeys.flatMap((day) => { const rows = hourlyRows(snapshot.files, day, periodCoverage.timeZone); @@ -1178,6 +1220,8 @@ export function buildCodexUsageView( right.lastActiveAt - left.lastActiveAt || left.projectKey.localeCompare(right.projectKey), ), + projectUsageMatrix, + allTimeDaily, daily, last7DaysDaily: rollingDailyRows(daily, last7DayKeys), last30DaysDaily: rollingDailyRows(daily, last30DayKeys), diff --git a/src/quotaObservationStore.ts b/src/quotaObservationStore.ts index 2dd758db..83ec8693 100644 --- a/src/quotaObservationStore.ts +++ b/src/quotaObservationStore.ts @@ -434,17 +434,35 @@ function compactBySeries( mandatory.set(observationIdentity(window[0]), window[0]); mandatory.set(observationIdentity(window[window.length - 1]), window[window.length - 1]); } - const selected = [...mandatory.values()] - .sort((left, right) => right.observedAt - left.observedAt) - .slice(0, limit); - const selectedIds = new Set(selected.map(observationIdentity)); + const selected: QuotaObservationV2[] = []; + const selectedIds = new Set(); + const retain = (item: QuotaObservationV2): void => { + if (selected.length >= limit) return; + const identity = observationIdentity(item); + if (selectedIds.has(identity)) return; + selected.push(item); + selectedIds.add(identity); + }; + // A one-row budget must retain the freshest fact. With any larger budget, + // keep both ends of the series before prioritising flagged/migration and + // per-window boundary observations. Otherwise a migration in which every + // imported row is mandatory can evict the oldest baseline and make later + // confidence calculations depend on an already-compacted middle slice. + if (limit === 1) { + retain(ordered[ordered.length - 1]); + } else { + retain(ordered[0]); + retain(ordered[ordered.length - 1]); + } + for (const item of [...mandatory.values()].sort( + (left, right) => right.observedAt - left.observedAt, + )) { + if (selected.length >= limit) break; + retain(item); + } for (const item of [...ordered].reverse()) { if (selected.length >= limit) break; - const identity = observationIdentity(item); - if (!selectedIds.has(identity)) { - selected.push(item); - selectedIds.add(identity); - } + retain(item); } retained.push(...selected); } diff --git a/src/refreshPolicy.ts b/src/refreshPolicy.ts index e41997bf..5537f748 100644 --- a/src/refreshPolicy.ts +++ b/src/refreshPolicy.ts @@ -47,6 +47,15 @@ export function quotaFailureBackoffMs(failStreak: number): number { return Math.min(3_600_000, 60_000 * Math.pow(2, exponent)); } +/** Filesystem watchers are only an acceleration path; polling remains active. + * Retry quickly enough to recover from a transient OS handle failure, but cap + * the retry cadence so an unsupported or exhausted filesystem cannot hot-loop. */ +export function watcherFailureBackoffMs(failStreak: number): number { + if (!Number.isFinite(failStreak) || failStreak <= 0) return 0; + const exponent = Math.max(0, Math.floor(failStreak) - 1); + return Math.min(60_000, 1_000 * Math.pow(2, exponent)); +} + const TRIGGER_PRIORITY: Record = { startup: 0, poll: 1, diff --git a/src/settings.ts b/src/settings.ts index 27a0b05b..4877c734 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -3,6 +3,11 @@ import { CODEX_LIVE_REFRESH_SECONDS, LIVE_REFRESH_SECONDS, } from './refreshPolicy'; +import { + DISPLAY_CURRENCY_CODES, + DISPLAY_CURRENCY_LABELS, + normalizeDisplayCurrencyCode, +} from './currencyDisplay'; // Single source of truth for every user setting (V2.1: "settings in the // dashboard"). Most settings moved OUT of VS Code's Settings UI to keep it @@ -43,6 +48,8 @@ export interface SettingDef { enumGroups?: { label: string; values: string[]; labels: string[] }[]; min?: number; max?: number; + step?: number; + maxLength?: number; secret?: boolean; // mask the input (apiKey) multiline?: boolean; // render a textarea // Dashboard visibility. Omitted settings are Claude-only; explicitly list @@ -71,6 +78,9 @@ const AUTOREFRESH_MIGRATION_FLAG = 'ccu.migrated.dashboardAutoRefresh'; // caps itself, so a setting naming one model could no longer describe the thing // it controls. Own flag so it runs regardless of the earlier migrations. const SCOPED_WEEKLY_MIGRATION_FLAG = 'ccu.migrated.showScopedWeekly'; +// The early 2.3.2 test build exposed a free-form label and manual USD +// multiplier. The release candidate replaces both with one fixed preset. +const CURRENCY_PRESET_MIGRATION_FLAG = 'ccu.migrated.currencyPreset.v2.3.2'; // Exact configuration/globalState names used by released predecessors but no // longer present in SETTINGS. They are migration inputs, never a prefix-based @@ -81,6 +91,7 @@ const RETIRED_SETTING_KEYS = [ 'showOpusWeekly', 'advice.backend', 'advice.subscriptionModel', + 'usdConversionRate', ] as const; export type SettingsSecretMigrationErrorCode = @@ -228,6 +239,19 @@ export const SETTINGS: SettingDef[] = [ label: 'Cost decimal places', min: 0, max: 4, + providers: ['claude', 'codex'], + }, + { + key: 'displayCurrency', + type: 'enum', + default: 'USD', + storage: 'state', + group: 'general', + label: 'Cost display currency', + help: 'Display only. Uses bundled reference rates dated 2026-09-09; rates are not editable or fetched, and underlying estimates remain USD.', + enumValues: DISPLAY_CURRENCY_CODES, + enumLabels: DISPLAY_CURRENCY_LABELS, + providers: ['claude', 'codex'], }, { key: 'tokenDecimalPlaces', @@ -327,6 +351,16 @@ export const SETTINGS: SettingDef[] = [ help: 'On by default. Show the historical weekly API-equivalent value panel in All-time and Compare. This is an estimate, not a bill or subscription allowance.', providers: ['claude', 'codex'], }, + { + key: 'showProjectUsageMatrix', + type: 'boolean', + default: true, + storage: 'state', + group: 'features', + label: 'Show project usage matrix', + help: 'On by default. Add a local 30/90-day Token heatmap and stacked trend to Projects. Reuses indexed aggregates and never allocates subscription quota by project.', + providers: ['claude', 'codex'], + }, { key: 'showHeatmap', type: 'boolean', @@ -873,7 +907,13 @@ export class SettingsStore { if (def.storage === 'secret') { return (this.secretValues.get(def.key) ?? def.default) as unknown as T; } - return this.context.globalState.get(STATE_PREFIX + def.key, def.default as unknown as T); + const value = this.context.globalState.get( + STATE_PREFIX + def.key, + def.default as unknown as T, + ); + return (def.key === 'displayCurrency' + ? normalizeDisplayCurrencyCode(value) + : value) as T; } /** Persist a value to whichever store owns the key. */ @@ -1059,7 +1099,11 @@ export class SettingsStore { return !!value; } if (def.type === 'number') { - let n = typeof value === 'number' ? value : Number(value); + let n = typeof value === 'number' + ? value + : String(value).trim() === '' + ? Number.NaN + : Number(value); if (!Number.isFinite(n)) { n = def.default as number; } @@ -1072,10 +1116,16 @@ export class SettingsStore { return n; } if (def.type === 'enum') { + if (def.key === 'displayCurrency') { + return normalizeDisplayCurrencyCode(value); + } const allowed = def.enumValues || []; return allowed.includes(String(value)) ? String(value) : (def.default as string); } - return String(value); + const stringValue = String(value); + return def.maxLength === undefined + ? stringValue + : Array.from(stringValue).slice(0, def.maxLength).join(''); } /** Catalog + current values, for rendering the dashboard settings panel. */ @@ -1181,4 +1231,25 @@ export class SettingsStore { } await this.context.globalState.update(SCOPED_WEEKLY_MIGRATION_FLAG, true); } + + /** + * Replace the early 2.3.2 free-form currency pair with one preset code. + * Existing supported codes and unambiguous symbols survive; the obsolete + * manual multiplier is removed because bundled rates are now authoritative. + */ + async migrateCurrencyPreset(): Promise { + if (this.context.globalState.get(CURRENCY_PRESET_MIGRATION_FLAG, false)) { + return; + } + const currencyKey = STATE_PREFIX + 'displayCurrency'; + const storedCurrency = this.context.globalState.get(currencyKey); + if (storedCurrency !== undefined) { + await this.context.globalState.update( + currencyKey, + normalizeDisplayCurrencyCode(storedCurrency), + ); + } + await this.context.globalState.update(STATE_PREFIX + 'usdConversionRate', undefined); + await this.context.globalState.update(CURRENCY_PRESET_MIGRATION_FLAG, true); + } } diff --git a/src/shareCardSvg.ts b/src/shareCardSvg.ts index 3b29cf40..5928e410 100644 --- a/src/shareCardSvg.ts +++ b/src/shareCardSvg.ts @@ -248,6 +248,8 @@ export interface ShareCardSvgOptions { username?: string; fullNumbers?: boolean; lang?: string; // UI language; the card renders in it (en fallback) + /** Optional USD display formatter. Source data remains USD. */ + formatCurrency?: (amountUsd: number) => string; } export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOptions = {}): string { @@ -257,6 +259,7 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption const T = SHARE_CARD_THEMES[resolveShareCardTheme(opts.theme, opts.isDark)]; const L = cardLang(opts.lang); const S = CARD_STRINGS[L]; + const formatMoney = opts.formatCurrency ?? money; const p: string[] = []; p.push( @@ -338,7 +341,7 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption heroValue = opts.fullNumbers ? data.totalTokens.toLocaleString('en-US') : compact(data.totalTokens, L, true); heroUnit = S.totalTokens; } else if (data.estimatedCost != null) { - heroValue = money(data.estimatedCost); + heroValue = formatMoney(data.estimatedCost); heroUnit = S.spent; } else if (data.sessions != null) { heroValue = String(data.sessions); @@ -354,7 +357,7 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption // Stat tiles — 4 by default (cost / cache / model / sessions), glass panels. const tiles: { label: string; value: string; accent?: string }[] = []; - if (data.totalTokens != null && data.estimatedCost != null) tiles.push({ label: S.estCost, value: money(data.estimatedCost) }); + if (data.totalTokens != null && data.estimatedCost != null) tiles.push({ label: S.estCost, value: formatMoney(data.estimatedCost) }); if (data.cacheSharePct != null) tiles.push({ label: S.cacheHit, value: data.cacheSharePct + '%', accent: T.cacheAccent }); const modelLabel = data.topModelName || data.topModelFamily; if (modelLabel) tiles.push({ label: S.topModel, value: truncate(modelLabel, 12), accent: T.modelAccent }); diff --git a/src/statusBar.ts b/src/statusBar.ts index adb51429..4c33747a 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -602,12 +602,11 @@ export class StatusBarManager { .replace(/'/g, '''); } - /** Credit amounts always carry two decimals (they are real money, unlike the - * estimated token costs elsewhere, which follow the user's decimalPlaces). - * I18n.formatCurrency is USD-only, so any other currency prints its code - * rather than a wrong "$". */ + /** Credit amounts always carry two decimals and remain in the provider's + * actual currency. Unlike estimated token costs, they never use the user's + * manual display conversion. */ private formatCreditAmount(amount: number, currency: string): string { - return currency === 'USD' ? I18n.formatCurrency(amount, 2) : `${amount.toFixed(2)} ${currency}`; + return currency === 'USD' ? I18n.formatUsdBaseline(amount, 2) : `${amount.toFixed(2)} ${currency}`; } /** Tooltip label for a window. Scoped rows are named by the API ("Fable"), so diff --git a/src/test/claudeIncrementalIndex.test.ts b/src/test/claudeIncrementalIndex.test.ts index 98f0f270..2ce8952c 100644 --- a/src/test/claudeIncrementalIndex.test.ts +++ b/src/test/claudeIncrementalIndex.test.ts @@ -335,6 +335,62 @@ test('Today and rolling 30 days share the configured local-day map across DST', } }); +test('Claude project-day aggregates stay incremental and feed the 90-day matrix without body rereads', async () => { + const previousTimeZone = I18n.getTimezone(); + I18n.setTimezone('UTC'); + try { + const { root, first } = await fixture(); + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { + analyzeContent: false, + }); + const initial = claudeUsageDashboardSnapshot(cold.index, { + now: new Date('2026-08-21T12:00:00.000Z'), + }); + assert.deepEqual( + initial.projectUsageMatrix.points.map((point) => ({ + name: point.projectName, + day: point.day, + tokens: point.tokens, + })), + [ + { name: 'project-a', day: '2026-08-21', tokens: 19 }, + { name: 'project-b', day: '2026-08-21', tokens: 33 }, + ], + ); + + const unchanged = await updateClaudeUsageIndex(cold.index, root, { + analyzeContent: false, + }); + const warmSnapshot = claudeUsageDashboardSnapshot(unchanged.index, { + now: new Date('2026-08-21T12:00:00.000Z'), + }); + assert.equal(unchanged.diagnostics.bodyReads, 0); + assert.deepEqual(warmSnapshot.projectUsageMatrix, initial.projectUsageMatrix); + + await appendFile(first, `${usageLine('matrix-append', 7, 1, { + timestamp: '2026-08-22T09:00:00.000Z', + })}\n`, 'utf8'); + const appended = await updateClaudeUsageIndex(unchanged.index, root, { + analyzeContent: false, + }); + const appendedSnapshot = claudeUsageDashboardSnapshot(appended.index, { + now: new Date('2026-08-22T12:00:00.000Z'), + }); + assert.equal(appended.diagnostics.bodyReads, 1); + assert.deepEqual( + appendedSnapshot.projectUsageMatrix.points + .filter((point) => point.projectName === 'project-a') + .map((point) => ({ day: point.day, tokens: point.tokens })), + [ + { day: '2026-08-21', tokens: 19 }, + { day: '2026-08-22', tokens: 13 }, + ], + ); + } finally { + I18n.setTimezone(previousTimeZone); + } +}); + test('advice window is opt-in and comes only from materialized day and session aggregates', async () => { const { root, first } = await fixture(); await writeFile( @@ -394,7 +450,15 @@ test('configured timezone rebuckets Claude Today and hours from the in-memory in assert.equal(shifted.diagnostics.bodyReads, 0); assert.equal(hongKong.today.totalInputTokens, 10); + assert.deepEqual( + hongKong.projectUsageMatrix.points.map((point) => point.day), + ['2026-07-21'], + ); assert.deepEqual(hongKong.hourlyForToday.map(({ hour }) => hour), ['07:00']); + assert.deepEqual( + hongKong.hourlyForLast30DaysByDay['2026-07-21'].map(({ hour }) => hour), + ['07:00'], + ); assert.deepEqual( ClaudeDataLoader.getHourlyDataForDate(shifted.records, '2026-07-21') .map(({ hour }) => hour), diff --git a/src/test/codexIdentity.test.ts b/src/test/codexIdentity.test.ts index 7ac106a0..e83a7af5 100644 --- a/src/test/codexIdentity.test.ts +++ b/src/test/codexIdentity.test.ts @@ -5,6 +5,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { + CodexSessionTitleCache, loadCodexSessionTitles, normalizeRepositoryIdentity, parsePseudonymousIdentityKey, @@ -16,6 +17,64 @@ import { const SALT = 'identity-test-salt'; +test('session title cache reuses only an identical verified file stamp', async () => { + let stamp = { size: 100, mtimeMs: 1_000, dev: 2, ino: 3 }; + let reads = 0; + const cache = new CodexSessionTitleCache({ + stat: async () => ({ ...stamp }), + read: async (_indexPath: string, salt: string) => { + reads += 1; + return new Map([ + [pseudonymousIdentityKey(salt, 'raw-session'), `title-${reads}`], + ]); + }, + }); + + const first = await cache.load('/virtual-codex-home', SALT); + const second = await cache.load('/virtual-codex-home', SALT); + assert.strictEqual(second, first); + assert.equal(reads, 1, 'unchanged size/mtime/dev/ino avoids another stream'); + + stamp = { ...stamp, mtimeMs: 2_000 }; + const changed = await cache.load('/virtual-codex-home', SALT); + assert.equal(reads, 2); + assert.equal( + changed.get(pseudonymousIdentityKey(SALT, 'raw-session')), + 'title-2', + ); + + const otherSalt = await cache.load('/virtual-codex-home', 'other-salt'); + assert.equal(reads, 3, 'a different machine salt cannot reuse pseudonymous keys'); + assert.equal( + otherSalt.get(pseudonymousIdentityKey('other-salt', 'raw-session')), + 'title-3', + ); +}); + +test('session title cache never retains a map read across a metadata race', async () => { + let stamp = { size: 100, mtimeMs: 1_000, dev: 2, ino: 3 }; + let reads = 0; + const cache = new CodexSessionTitleCache({ + stat: async () => ({ ...stamp }), + read: async (_indexPath: string, salt: string) => { + reads += 1; + if (reads === 1) { + stamp = { ...stamp, size: 120, mtimeMs: 2_000 }; + } + return new Map([ + [pseudonymousIdentityKey(salt, 'raw-session'), `title-${reads}`], + ]); + }, + }); + + const raced = await cache.load('/virtual-codex-home', SALT); + assert.equal(raced.size, 0, 'an unstable title stream fails closed for this snapshot'); + const stable = await cache.load('/virtual-codex-home', SALT); + const reused = await cache.load('/virtual-codex-home', SALT); + assert.equal(reads, 2, 'the raced stream is retried once and the stable result is cached'); + assert.strictEqual(reused, stable); +}); + test('session index keeps the latest clean title behind an anonymous key', async () => { const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-codex-title-')); try { diff --git a/src/test/codexQualityI18n.test.ts b/src/test/codexQualityI18n.test.ts new file mode 100644 index 00000000..98936fc8 --- /dev/null +++ b/src/test/codexQualityI18n.test.ts @@ -0,0 +1,42 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { CODEX_QUALITY_FLAGS } from '../codexViewComponents'; +import { I18n } from '../i18n'; +import type { SupportedLanguage } from '../types'; + +const LANGUAGES: SupportedLanguage[] = [ + 'en', + 'de-DE', + 'zh-TW', + 'zh-CN', + 'ja', + 'ko', + 'pt-BR', + 'id', +]; + +test('every Codex quality flag has a specific label in every supported locale', () => { + const previous = I18n.getCurrentLanguage(); + try { + for (const language of LANGUAGES) { + I18n.setLanguage(language); + const copy = I18n.t.providers.codex; + assert.deepEqual( + Object.keys(copy.qualityFlagLabels).sort(), + [...CODEX_QUALITY_FLAGS].sort(), + language, + ); + for (const flag of CODEX_QUALITY_FLAGS) { + assert.ok(copy.qualityFlagLabels[flag].trim().length > 0, `${language}: ${flag}`); + assert.notEqual( + copy.qualityFlagLabels[flag], + copy.qualityFlagUnknown, + `${language}: ${flag}`, + ); + } + } + } finally { + I18n.setLanguage(previous); + } +}); diff --git a/src/test/codexUsage.test.ts b/src/test/codexUsage.test.ts index f57859f0..0914dfd8 100644 --- a/src/test/codexUsage.test.ts +++ b/src/test/codexUsage.test.ts @@ -927,6 +927,9 @@ test('daily activity view retains a complete leap-year share window plus boundar const view = buildCodexUsageView(snapshot, NOW); + assert.equal(view.allTimeDaily.length, 371); + assert.equal(view.allTimeDaily[0].day, '2026-07-20'); + assert.equal(view.allTimeDaily[view.allTimeDaily.length - 1]?.day, '2025-07-15'); assert.equal(view.daily.length, 370); assert.equal(view.daily[0].day, '2026-07-20'); assert.equal(view.daily[view.daily.length - 1]?.day, '2025-07-16'); @@ -1050,9 +1053,36 @@ test('an empty snapshot has no recent task and safe zero scopes', () => { assert.equal(view.allTime.total.processed, 0); assert.equal(view.behavior.processedToFreshRatio, 0); assert.equal(view.behaviorScopes.recent, null); + assert.deepEqual(view.projectUsageMatrix.points, []); assert.equal(view.behaviorScopes.last7Days.processedToFreshRatio, 0); }); +test('Codex project matrix reuses verified daily slices and exposes only safe display identities', () => { + const snapshot = snapshotFixture(); + const rawProjectKeys = new Set(snapshot.files.map((file) => file.session.projectKey)); + const view = buildCodexUsageView(snapshot, NOW); + + assert.equal(view.projectUsageMatrix.days.length, 90); + assert.equal(view.projectUsageMatrix.asOfDay, '2026-07-20'); + assert.equal(view.projectUsageMatrix.coverage, 'partial'); + assert.equal( + view.projectUsageMatrix.points.some((point) => rawProjectKeys.has(point.projectName)), + false, + ); + assert.deepEqual( + view.projectUsageMatrix.points.map((point) => ({ + name: point.projectName, + day: point.day, + tokens: point.tokens, + })), + [ + { name: 'TianGong', day: '2026-06-01', tokens: 120 }, + { name: 'ClaudeCodeUsage', day: '2026-07-10', tokens: 240 }, + { name: 'ClaudeCodeUsage', day: '2026-07-20', tokens: 1_200 }, + ], + ); +}); + test('missing model and effort values are grouped as unknown', () => { const snapshot = snapshotFixture(); snapshot.files[0].byModel = {}; diff --git a/src/test/currencyDisplay.test.ts b/src/test/currencyDisplay.test.ts new file mode 100644 index 00000000..dde82681 --- /dev/null +++ b/src/test/currencyDisplay.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + CURRENCY_REFERENCE_DATE, + DISPLAY_CURRENCY_CODES, + DISPLAY_CURRENCY_LABELS, + formatUsdBaseline, + formatUsdForDisplay, + normalizeDisplayCurrencyCode, + resolveCurrencyDisplay, +} from '../currencyDisplay'; + +test('USD is the default and preserves the existing format', () => { + assert.equal(normalizeDisplayCurrencyCode(undefined), 'USD'); + assert.equal(formatUsdForDisplay(12.345, 'USD', 2), '$12.35'); + assert.equal(DISPLAY_CURRENCY_CODES[0], 'USD'); + assert.equal(DISPLAY_CURRENCY_LABELS[0], 'USD ($)'); +}); + +test('curated dropdown currencies resolve to fixed bundled reference rates', () => { + assert.equal(CURRENCY_REFERENCE_DATE, '2026-09-09'); + assert.deepEqual(resolveCurrencyDisplay('EUR'), { + code: 'EUR', + label: 'EUR', + unitsPerUsd: 0.85822176, + converted: true, + referenceDate: '2026-09-09', + }); + assert.equal(formatUsdForDisplay(10, 'EUR', 2), '≈EUR 8.58'); + assert.equal(formatUsdForDisplay(123.456, 'CNY', 2), '≈CNY 828.12'); +}); + +test('legacy test-build codes and unambiguous symbols migrate into presets', () => { + assert.equal(normalizeDisplayCurrencyCode(' eur '), 'EUR'); + assert.equal(normalizeDisplayCurrencyCode('$'), 'USD'); + assert.equal(normalizeDisplayCurrencyCode('€'), 'EUR'); + assert.equal(normalizeDisplayCurrencyCode('HK$'), 'HKD'); +}); + +test('unsupported or markup-shaped values fail closed to USD', () => { + assert.equal(normalizeDisplayCurrencyCode(''), 'USD'); + assert.equal(normalizeDisplayCurrencyCode('XYZ'), 'USD'); + assert.equal(formatUsdForDisplay(10, 'XYZ', 2), '$10.00'); +}); + +test('conversion is display-only and the auditable USD baseline stays available', () => { + const usd = 123.456; + assert.equal(formatUsdForDisplay(usd, 'CNY', 2), '≈CNY 828.12'); + assert.equal(usd, 123.456); + assert.equal(formatUsdBaseline(usd, 2), '$123.46'); +}); + +test('the display converter has no exchange-rate transport dependency', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'currencyDisplay.ts'), + 'utf8', + ); + assert.doesNotMatch(source, /^\s*import\s/m); + assert.doesNotMatch(source, /\bfetch\s*\(|\bhttps?:\/\//); +}); diff --git a/src/test/dashboardOnlySettings.test.ts b/src/test/dashboardOnlySettings.test.ts index 0658c093..9b3d7f70 100644 --- a/src/test/dashboardOnlySettings.test.ts +++ b/src/test/dashboardOnlySettings.test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { test } from 'node:test'; import * as assert from 'node:assert/strict'; -test('weekly equivalent panel toggle refreshes dashboard UI without rebuilding providers', () => { +test('presentation toggles refresh dashboard UI without rebuilding providers', () => { const source = fs.readFileSync( path.join(__dirname, '..', '..', 'src', 'extension.ts'), 'utf8', @@ -11,10 +11,34 @@ test('weekly equivalent panel toggle refreshes dashboard UI without rebuilding p assert.match( source, - /DASHBOARD_ONLY_SETTINGS\s*=\s*new Set\(\[[\s\S]*?'showWeeklyEquivalentValue'[\s\S]*?\]\)/, + /DASHBOARD_ONLY_SETTINGS\s*=\s*new Set\(\[[\s\S]*?'showWeeklyEquivalentValue'[\s\S]*?'showProjectUsageMatrix'[\s\S]*?\]\)/, ); assert.match( source, /DASHBOARD_ONLY_SETTINGS\.has\(key\)[\s\S]*?this\.syncProviderUi\(\);[\s\S]*?return;/, ); }); + +test('currency display settings reformat materialized data without rebuilding providers', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'extension.ts'), + 'utf8', + ); + + assert.match( + source, + /COST_DISPLAY_SETTINGS\s*=\s*new Set\(\[[\s\S]*?'decimalPlaces'[\s\S]*?'displayCurrency'[\s\S]*?\]\)/, + ); + assert.doesNotMatch( + source.match(/COST_DISPLAY_SETTINGS\s*=\s*new Set\(\[([\s\S]*?)\]\)/)?.[1] ?? '', + /usdConversionRate/, + ); + const branch = source.match( + /if \(key && ClaudeCodeUsageExtension\.COST_DISPLAY_SETTINGS\.has\(key\)\) \{([\s\S]*?)\n \}/, + )?.[1] ?? ''; + assert.match(branch, /this\.applyFormattingConfiguration\(config\)/); + assert.match(branch, /this\.webviewProvider\.invalidateShareCardPreview\(\)/); + assert.match(branch, /this\.syncProviderUi\(\)/); + assert.match(branch, /return;/); + assert.doesNotMatch(branch, /restart|createCodexProvider|startWatcher|scan/i); +}); diff --git a/src/test/displayRange.test.ts b/src/test/displayRange.test.ts new file mode 100644 index 00000000..c5de6260 --- /dev/null +++ b/src/test/displayRange.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { clockHourKeys, completeDisplayRange } from '../displayRange'; + +test('clockHourKeys returns one canonical key for every hour', () => { + const keys = clockHourKeys(); + assert.equal(keys.length, 24); + assert.equal(keys[0], '00'); + assert.equal(keys[23], '23'); + assert.equal(new Set(keys).size, 24); +}); + +test('completeDisplayRange fills exact keys without mutating sparse source rows', () => { + const source = [ + { key: 'b', amount: 2 }, + { key: 'outside', amount: 99 }, + ]; + const before = structuredClone(source); + const completed = completeDisplayRange( + ['a', 'b', 'c'], + source, + (row) => row.key, + (key) => ({ key, amount: 0 }), + ); + + assert.deepEqual(completed, [ + { key: 'a', value: { key: 'a', amount: 0 }, observed: false }, + { key: 'b', value: source[0], observed: true }, + { key: 'c', value: { key: 'c', amount: 0 }, observed: false }, + ]); + assert.equal(completed[1].value, source[0]); + assert.deepEqual(source, before); +}); + +test('completeDisplayRange uses the first valid duplicate deterministically', () => { + const first = { key: 'a', amount: 1 }; + const completed = completeDisplayRange( + ['a'], + [first, { key: 'a', amount: 2 }], + (row) => row.key, + (key) => ({ key, amount: 0 }), + ); + + assert.equal(completed[0].value, first); + assert.equal(completed[0].observed, true); +}); diff --git a/src/test/extensionRefreshRouting.test.ts b/src/test/extensionRefreshRouting.test.ts index d7299725..aee4eaa3 100644 --- a/src/test/extensionRefreshRouting.test.ts +++ b/src/test/extensionRefreshRouting.test.ts @@ -97,7 +97,10 @@ test('Codex watcher still schedules its own Codex-only refresh', () => { callback: (event: string, filename: string) => void, ) => { callbacks.push(callback); - return { close: () => undefined }; + return { + close: () => undefined, + on: () => undefined, + }; }; try { extension.startCodexWatching(); diff --git a/src/test/extensionWindowActivity.test.ts b/src/test/extensionWindowActivity.test.ts index de3d9f0a..bf394bc3 100644 --- a/src/test/extensionWindowActivity.test.ts +++ b/src/test/extensionWindowActivity.test.ts @@ -1219,7 +1219,10 @@ test('Claude recursive watcher forwards nested subagent JSONL writes to a watch assert.equal(directory, path.join(root, 'projects')); assert.equal(options.recursive, true); listener = callback; - return { close: () => { watcherClosed += 1; } }; + return { + close: () => { watcherClosed += 1; }, + on: () => undefined, + }; }; try { @@ -1243,13 +1246,20 @@ test('Claude recursive watcher forwards nested subagent JSONL writes to a watch assert.equal(watcherClosed, 1); }); -test('Claude watcher errors close the watcher and fall back to polling', async () => { +test('Claude watcher errors back off, recover, and cannot rearm after disposal', async () => { const extension = bareExtension(); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccu-claude-watch-error-')); fs.mkdirSync(path.join(root, 'projects')); const originalFind = ClaudeDataLoader.findClaudeDataDirectory; const originalWatch = fs.watch; - let errorListener: ((error: Error) => void) | undefined; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const errorListeners: Array<(error: Error) => void> = []; + const retryCallbacks: Array<() => void> = []; + const retryDelays: number[] = []; + const clearedTimers: number[] = []; + let nextTimer = 0; + let watchCalls = 0; let watcherClosed = 0; const diagnostics: string[] = []; extension.windowActivity = new WindowActivityGate(true); @@ -1263,36 +1273,180 @@ test('Claude watcher errors close the watcher and fall back to polling', async ( dataDirectory: '', }); (ClaudeDataLoader as any).findClaudeDataDirectory = async () => root; - const watcher = { - close: () => { watcherClosed += 1; }, - on: (event: string, listener: (error: Error) => void) => { - if (event === 'error') errorListener = listener; - return watcher; - }, - }; - (fs as any).watch = () => watcher; + (fs as any).watch = () => { + watchCalls += 1; + const watcher = { + close: () => { watcherClosed += 1; }, + on: (event: string, listener: (error: Error) => void) => { + if (event === 'error') errorListeners.push(listener); + return watcher; + }, + }; + return watcher; + }; + globalThis.setTimeout = ((callback: () => void, milliseconds?: number) => { + retryCallbacks.push(callback); + retryDelays.push(milliseconds ?? 0); + nextTimer += 1; + return nextTimer as unknown as NodeJS.Timeout; + }) as typeof setTimeout; + globalThis.clearTimeout = ((timer: NodeJS.Timeout) => { + clearedTimers.push(timer as unknown as number); + }) as typeof clearTimeout; try { await extension.startFileWatching(); - assert.ok(errorListener, 'the watcher must handle asynchronous fs.watch errors'); - const error = Object.assign(new Error('watch resources exhausted'), { code: 'EMFILE' }); - errorListener(error); - await extension.drainResourceStops(); + assert.equal(watchCalls, 1); + assert.equal(errorListeners.length, 1, 'the watcher must handle asynchronous fs.watch errors'); + errorListeners[0](Object.assign(new Error('watch resources exhausted'), { code: 'EMFILE' })); + await extension.drainResourceStops(); assert.equal(extension.fileWatcher, undefined); assert.equal(extension.watchedDir, null); assert.equal(watcherClosed, 1); + assert.deepEqual(retryDelays, [1_000]); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.timer, 1); + + retryCallbacks[0](); + await new Promise((resolve) => setImmediate(resolve)); + await extension.drainResourceStops(); + assert.equal(watchCalls, 2, 'the first retry rearms the watcher'); + assert.equal(errorListeners.length, 2); + + errorListeners[1](Object.assign(new Error('watch resources still exhausted'), { code: 'EMFILE' })); + await extension.drainResourceStops(); + assert.deepEqual(retryDelays, [1_000, 2_000], 'consecutive failures back off instead of hot-looping'); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.timer, 1); + + retryCallbacks[1](); + await new Promise((resolve) => setImmediate(resolve)); + await extension.drainResourceStops(); + assert.equal(watchCalls, 3, 'a later retry can recover'); + assert.ok(extension.fileWatcher); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.watcher, 1); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.timer, 0); + + errorListeners[2](Object.assign(new Error('watch failed again'), { code: 'ENOSPC' })); + await extension.drainResourceStops(); + assert.deepEqual(retryDelays, [1_000, 2_000, 4_000]); + const staleRetry = retryCallbacks[2]; + extension.disposed = true; + extension.stopFileWatching('extension-dispose'); + await extension.drainResourceStops(); + staleRetry(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(watchCalls, 3, 'a cancelled disposal retry cannot recreate a watcher'); + assert.deepEqual(clearedTimers, [3]); assert.equal(extension.resourceOwnership.snapshotForTests().activeCount, 0); assert.match(diagnostics.join('\n'), /EMFILE/); + assert.match(diagnostics.join('\n'), /ENOSPC/); assert.match(diagnostics.join('\n'), /poll/i); } finally { extension.stopFileWatching(); (ClaudeDataLoader as any).findClaudeDataDirectory = originalFind; (fs as any).watch = originalWatch; + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; fs.rmSync(root, { recursive: true, force: true }); } }); +test('Codex watcher errors close the whole set and recover with bounded backoff', async () => { + const extension = bareExtension(); + const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccu-codex-watch-error-')); + fs.mkdirSync(path.join(codexHome, 'sessions')); + fs.mkdirSync(path.join(codexHome, 'archived_sessions')); + const originalWatch = fs.watch; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const errorListeners: Array<(error: Error) => void> = []; + const retryCallbacks: Array<() => void> = []; + const retryDelays: number[] = []; + const clearedTimers: number[] = []; + const diagnostics: string[] = []; + let nextTimer = 0; + let watchCalls = 0; + let watcherClosed = 0; + + extension.windowActivity = new WindowActivityGate(true); + extension.codexWatchDebounce = { clear: () => undefined }; + extension.codexWatchers = []; + extension.codexWatchedHome = null; + extension.outputChannel = { appendLine: (line: string) => diagnostics.push(line) }; + extension.getConfiguration = () => ({ + codexEnabled: true, + codexFileWatchSeconds: 30, + }); + extension.codexHome = () => codexHome; + (fs as any).watch = () => { + watchCalls += 1; + const watcher = { + close: () => { watcherClosed += 1; }, + on: (event: string, listener: (error: Error) => void) => { + if (event === 'error') errorListeners.push(listener); + return watcher; + }, + }; + return watcher; + }; + globalThis.setTimeout = ((callback: () => void, milliseconds?: number) => { + retryCallbacks.push(callback); + retryDelays.push(milliseconds ?? 0); + nextTimer += 1; + return nextTimer as unknown as NodeJS.Timeout; + }) as typeof setTimeout; + globalThis.clearTimeout = ((timer: NodeJS.Timeout) => { + clearedTimers.push(timer as unknown as number); + }) as typeof clearTimeout; + + try { + extension.startCodexWatching(); + assert.equal(watchCalls, 2); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.watcher, 2); + + errorListeners[0](Object.assign(new Error('watch resources exhausted'), { code: 'EMFILE' })); + await extension.drainResourceStops(); + assert.equal(watcherClosed, 2, 'one failed child watcher closes the entire Codex watcher set'); + assert.equal(extension.codexWatchers.length, 0); + assert.deepEqual(retryDelays, [1_000]); + + retryCallbacks[0](); + await extension.drainResourceStops(); + assert.equal(watchCalls, 4); + assert.equal(extension.codexWatchers.length, 2); + + errorListeners[2](Object.assign(new Error('watch resources still exhausted'), { code: 'EMFILE' })); + await extension.drainResourceStops(); + assert.deepEqual(retryDelays, [1_000, 2_000]); + + retryCallbacks[1](); + await extension.drainResourceStops(); + assert.equal(watchCalls, 6, 'Codex watcher recovery eventually restores both roots'); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.watcher, 2); + assert.equal(extension.resourceOwnership.snapshotForTests().byKind.timer, 0); + + errorListeners[4](Object.assign(new Error('watch failed again'), { code: 'ENOSPC' })); + await extension.drainResourceStops(); + assert.deepEqual(retryDelays, [1_000, 2_000, 4_000]); + const staleRetry = retryCallbacks[2]; + extension.disposed = true; + extension.stopCodexWatching('extension-dispose'); + await extension.drainResourceStops(); + staleRetry(); + assert.equal(watchCalls, 6, 'a cancelled disposal retry cannot recreate Codex watchers'); + assert.deepEqual(clearedTimers, [3]); + assert.equal(extension.resourceOwnership.snapshotForTests().activeCount, 0); + assert.match(diagnostics.join('\n'), /Codex/); + assert.match(diagnostics.join('\n'), /poll/i); + } finally { + extension.stopCodexWatching(); + (fs as any).watch = originalWatch; + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + fs.rmSync(codexHome, { recursive: true, force: true }); + } +}); + test('real Claude filesystem events flow through manifest, index, and dashboard refresh', { timeout: 10_000, }, async (t) => { diff --git a/src/test/format.test.ts b/src/test/format.test.ts index 68cfe9c2..1bc232db 100644 --- a/src/test/format.test.ts +++ b/src/test/format.test.ts @@ -56,3 +56,21 @@ test('setTokenDecimalPlaces clamps to 0..2 (out-of-range ignored)', () => { I18n.setTokenDecimalPlaces(-1); // ignored assert.equal(I18n.formatTokensCompact(1_200_000), '1.2M'); }); + +test('I18n applies the selected currency preset only to estimated-cost display', () => { + I18n.setDecimalPlaces(2); + I18n.setCurrencyDisplay('EUR'); + try { + assert.equal(I18n.formatCurrency(10), '≈EUR 8.58'); + assert.equal(I18n.formatUsdBaseline(10), '$10.00'); + assert.deepEqual(I18n.getCurrencyDisplay(), { + code: 'EUR', + label: 'EUR', + unitsPerUsd: 0.85822176, + converted: true, + referenceDate: '2026-09-09', + }); + } finally { + I18n.setCurrencyDisplay('USD'); + } +}); diff --git a/src/test/projectUsageMatrix.test.ts b/src/test/projectUsageMatrix.test.ts new file mode 100644 index 00000000..21f66306 --- /dev/null +++ b/src/test/projectUsageMatrix.test.ts @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { + buildProjectUsageMatrixSnapshot, + projectHeatmap, + projectTrend, +} from '../projectUsageMatrix'; + +test('project matrix keeps one sparse 90-day source and combines duplicate project days', () => { + const snapshot = buildProjectUsageMatrixSnapshot('codex', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 10, coverage: 'complete' }, + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 5, coverage: 'partial' }, + { projectKey: 'old', projectName: 'Old', day: '2026-01-01', tokens: 999, coverage: 'complete' }, + ], { + asOfDay: '2026-09-10', + timeZone: 'Asia/Shanghai', + coverage: 'partial', + }); + + assert.equal(snapshot.days.length, 90); + assert.equal(snapshot.days[snapshot.days.length - 1], '2026-09-10'); + assert.deepEqual(snapshot.points, [{ + projectKey: 'a', + projectName: 'Alpha', + day: '2026-09-09', + tokens: 15, + coverage: 'partial', + }]); +}); + +test('30-day and 90-day heatmaps are projections of the same source snapshot', () => { + const snapshot = buildProjectUsageMatrixSnapshot('claude', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 100, coverage: 'complete' }, + { projectKey: 'b', projectName: 'Beta', day: '2026-09-09', tokens: 25, coverage: 'complete' }, + { projectKey: 'a', projectName: 'Alpha', day: '2026-07-01', tokens: 50, coverage: 'complete' }, + ], { asOfDay: '2026-09-10', timeZone: 'UTC' }); + + const recent = projectHeatmap(snapshot, 30); + const extended = projectHeatmap(snapshot, 90); + assert.equal(recent[0].cells.length, 30); + assert.equal(extended[0].cells.length, 90); + assert.equal(recent.find((row) => row.projectKey === 'a')?.totalTokens, 100); + assert.equal(extended.find((row) => row.projectKey === 'a')?.totalTokens, 150); +}); + +test('heatmap uses one global 0-4 scale and ranks rows by range total', () => { + const snapshot = buildProjectUsageMatrixSnapshot('codex', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-09', tokens: 100, coverage: 'complete' }, + { projectKey: 'b', projectName: 'Beta', day: '2026-09-09', tokens: 25, coverage: 'complete' }, + { projectKey: 'c', projectName: 'Gamma', day: '2026-09-09', tokens: 1, coverage: 'complete' }, + ], { asOfDay: '2026-09-10', timeZone: 'UTC' }); + + const rows = projectHeatmap(snapshot, 30); + const dayIndex = rows[0].cells.findIndex((cell) => cell.day === '2026-09-09'); + assert.deepEqual(rows.map((row) => row.projectKey), ['a', 'b', 'c']); + assert.equal(rows[0].cells[dayIndex].bucket, 4); + assert.equal(rows[1].cells[dayIndex].bucket > rows[2].cells[dayIndex].bucket, true); + assert.equal(rows[2].cells[dayIndex].bucket, 1); +}); + +test('missing zero-usage cells retain range coverage instead of inventing missing data', () => { + const snapshot = buildProjectUsageMatrixSnapshot('claude', [ + { projectKey: 'a', projectName: 'Alpha', day: '2026-09-10', tokens: 8, coverage: 'complete' }, + ], { + asOfDay: '2026-09-10', + timeZone: 'UTC', + coverage: 'complete', + }); + + const row = projectHeatmap(snapshot, 30)[0]; + const empty = row.cells.find((cell) => cell.day === '2026-09-09'); + assert.deepEqual(empty, { + projectKey: 'a', + projectName: 'Alpha', + day: '2026-09-09', + tokens: 0, + coverage: 'complete', + bucket: 0, + }); +}); + +test('stacked trend is bounded, folds the long tail into Other, and preserves every day total', () => { + const points = Array.from({ length: 8 }, (_, projectIndex) => ({ + projectKey: `p${projectIndex}`, + projectName: `Project ${projectIndex}`, + day: projectIndex % 2 === 0 ? '2026-09-09' : '2026-09-10', + tokens: projectIndex + 1, + coverage: 'complete' as const, + })); + const snapshot = buildProjectUsageMatrixSnapshot('codex', points, { + asOfDay: '2026-09-10', + timeZone: 'UTC', + }); + + const series = projectTrend(snapshot, 30, 6); + assert.equal(series.length, 6); + assert.equal(series[series.length - 1]?.other, true); + assert.equal(series[series.length - 1]?.projectKey, '__other__'); + for (const day of ['2026-09-09', '2026-09-10']) { + const expected = points.filter((point) => point.day === day) + .reduce((sum, point) => sum + point.tokens, 0); + const actual = series.reduce( + (sum, row) => sum + (row.values.find((value) => value.day === day)?.tokens ?? 0), + 0, + ); + assert.equal(actual, expected); + } +}); + +test('matrix projections cap visible rows without changing the sparse source', () => { + const snapshot = buildProjectUsageMatrixSnapshot('claude', + Array.from({ length: 50 }, (_, index) => ({ + projectKey: `p${index}`, + projectName: `Project ${index}`, + day: '2026-09-10', + tokens: 50 - index, + coverage: 'complete' as const, + })), + { asOfDay: '2026-09-10', timeZone: 'UTC' }, + ); + + assert.equal(snapshot.points.length, 50); + assert.equal(projectHeatmap(snapshot, 30, 12).length, 12); + assert.equal(projectHeatmap(snapshot, 30, 40).length, 40); +}); + +test('heatmap intensity stays global even when lower-ranked rows are not rendered', () => { + const snapshot = buildProjectUsageMatrixSnapshot('codex', [ + { projectKey: 'total-leader', projectName: 'Total leader', day: '2026-09-09', tokens: 50, coverage: 'complete' }, + { projectKey: 'total-leader', projectName: 'Total leader', day: '2026-09-10', tokens: 50, coverage: 'complete' }, + { projectKey: 'hidden-peak', projectName: 'Hidden peak', day: '2026-09-10', tokens: 80, coverage: 'complete' }, + ], { asOfDay: '2026-09-10', timeZone: 'UTC' }); + + const visible = projectHeatmap(snapshot, 30, 1); + const latest = visible[0].cells[visible[0].cells.length - 1]; + assert.equal(visible[0].projectKey, 'total-leader'); + assert.equal(latest.bucket < 4, true); +}); diff --git a/src/test/projectUsageMatrixSettings.test.ts b/src/test/projectUsageMatrixSettings.test.ts new file mode 100644 index 00000000..bd29f12b --- /dev/null +++ b/src/test/projectUsageMatrixSettings.test.ts @@ -0,0 +1,47 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { I18n } from '../i18n'; +import { SupportedLanguage } from '../types'; + +const TRANSLATED: SupportedLanguage[] = [ + 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id', +]; + +test('project matrix is one shared default-on capability setting', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'settings.ts'), + 'utf8', + ); + const match = source.match(/\{\s*key: 'showProjectUsageMatrix',[\s\S]*?\n\s*\},/); + assert.ok(match, 'showProjectUsageMatrix must be declared in the settings catalog'); + assert.match(match[0], /type: 'boolean'/); + assert.match(match[0], /default: true/); + assert.match(match[0], /storage: 'state'/); + assert.match(match[0], /group: 'features'/); + assert.match(match[0], /providers: \['claude', 'codex'\]/); +}); + +test('project matrix setting copy is complete in every translated locale', () => { + const previous = I18n.getLocale() as SupportedLanguage; + try { + for (const locale of TRANSLATED) { + I18n.setLanguage(locale); + const copy = I18n.settingText('showProjectUsageMatrix'); + assert.ok(copy.label, `${locale}: setting label`); + assert.ok(copy.help, `${locale}: setting help`); + } + } finally { + I18n.setLanguage(previous); + } +}); + +test('project matrix model owns no scanner, watcher, timer, filesystem, or network path', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'projectUsageMatrix.ts'), + 'utf8', + ); + assert.doesNotMatch(source, /node:fs|watch\(|setInterval|setTimeout|fetch\(|https?:\/\//); +}); diff --git a/src/test/providerSelection.test.ts b/src/test/providerSelection.test.ts index d15b4fff..3bed7cf6 100644 --- a/src/test/providerSelection.test.ts +++ b/src/test/providerSelection.test.ts @@ -9,6 +9,7 @@ import { CODEX_COPY_EN } from '../codexView'; import { I18n } from '../i18n'; import { buildScopedCodexInsights } from '../providers/codex/codexInsights'; import { buildCodexUsageView } from '../providers/codex/codexUsage'; +import { buildProjectUsageMatrixSnapshot } from '../projectUsageMatrix'; import { ContentAnalysis, ProjectGroup, SessionUsage, SupportedLanguage, UsageData } from '../types'; import { CODEX_WEBVIEW_NOW, codexWebviewFixture } from './codexWebviewFixtures'; @@ -53,6 +54,7 @@ function claudeUsageFixture(): UsageData { test('Codex dashboard HTML uses only classes already rendered by the Claude dashboard', () => { const originalLoad = (Module as any)._load; + const originalNow = Date.now; (Module as any)._load = function(request: string, parent: unknown, isMain: boolean) { if (request === 'vscode') { return { workspace: { workspaceFolders: [] } }; @@ -60,6 +62,7 @@ test('Codex dashboard HTML uses only classes already rendered by the Claude dash return originalLoad.call(this, request, parent, isMain); }; try { + Date.now = () => CODEX_WEBVIEW_NOW; const { UsageWebviewProvider } = require('../webview') as typeof import('../webview'); const provider = new UsageWebviewProvider({} as any) as any; const usage = claudeUsageFixture(); @@ -102,13 +105,31 @@ test('Codex dashboard HTML uses only classes already rendered by the Claude dash skillUses: [], }; provider.todayData = usage; - provider.monthData = usage; + provider.rolling30DayData = usage; provider.allTimeData = usage; provider.hourlyDataForToday = [{ hour: '12:00', data: usage }]; - provider.dailyDataForMonth = [{ date: '2026-07-20', data: usage }]; + provider.dailyDataForRolling30Days = [{ date: '2026-07-20', data: usage }]; + provider.hourlyDataForRolling30DaysByDay = { + '2026-07-20': [{ hour: '12:00', data: usage }], + }; provider.dailyDataForAllTime = [{ date: '2026-07', data: usage }]; provider.sessionBreakdown = [session]; provider.projectBreakdown = [project]; + provider.claudeProjectUsageMatrix = buildProjectUsageMatrixSnapshot( + 'claude', + [1, 2, 3, 4, 5].map((value) => ({ + projectKey: `claude-project-${value}`, + projectName: `Claude project ${value}`, + day: '2026-07-20', + tokens: value * 1_000, + coverage: 'partial' as const, + })), + { + asOfDay: '2026-07-20', + timeZone: 'Asia/Hong_Kong', + coverage: 'partial', + }, + ); provider.contentAnalysis = content; provider.providerAvailability = { claude: true, codex: true }; const codexSnapshot = codexWebviewFixture(); @@ -132,6 +153,22 @@ test('Codex dashboard HTML uses only classes already rendered by the Claude dash .sort(); assert.deepEqual(codexOnly, []); + const drilldownDay = Object.keys(codexView.last30DaysHourlyByDay)[0]; + assert.ok(drilldownDay, 'fixture exposes one materialized hourly day'); + const drilldownMonth = drilldownDay.slice(0, 7); + assert.match( + codexHtml, + new RegExp('class="chart-bar cost-bar cost-stacked clickable"[^>]+data-cost[^>]+title="' + drilldownMonth), + ); + assert.match(codexHtml, new RegExp('aria-controls="monthly-detail-' + drilldownMonth + '"')); + const monthRows = codexView.allTimeDaily + .filter((row) => row.day.startsWith(drilldownMonth + '-')) + .sort((left, right) => left.day.localeCompare(right.day)); + const monthHtml = provider.renderCodexMonthDailyDetail(drilldownMonth, monthRows); + assert.match(monthHtml, /data-codex-alltime-daily/); + assert.match(monthHtml, new RegExp('id="codex-alltime-hourly-detail-' + drilldownDay + '"')); + assert.match(monthHtml, /data-codex-materialized-hours="true"/); + assert.match(monthHtml, new RegExp('onclick="toggleCodexHourlyDetail\\(\\\'' + drilldownDay + '\\\', this\\)"')); const equivalentCostIndex = codexHtml.indexOf('API-equivalent cost'); const processedIndex = codexHtml.indexOf('Processed'); assert.ok(equivalentCostIndex >= 0, 'Codex summary shows API-equivalent cost'); @@ -177,6 +214,7 @@ test('Codex dashboard HTML uses only classes already rendered by the Claude dash /Duplicate session identity is ambiguous; both local copies are retained[^<]*2/, ); } finally { + Date.now = originalNow; (Module as any)._load = originalLoad; } }); @@ -280,6 +318,71 @@ test('Codex index progress patches live text without rebuilding the webview', () } }); +test('a ready dashboard receives a live data fragment and reloads only when delivery fails', async () => { + const originalLoad = (Module as any)._load; + (Module as any)._load = function(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { workspace: { workspaceFolders: [] } }; + } + return originalLoad.call(this, request, parent, isMain); + }; + try { + const { UsageWebviewProvider } = require('../webview') as typeof import('../webview'); + const provider = new UsageWebviewProvider({} as any) as any; + const base = claudeUsageFixture(); + provider.currentProvider = 'claude'; + provider.currentTab = 'today'; + provider.todayData = base; + provider.rolling30DayData = base; + provider.allTimeData = base; + provider.providerAvailability = { claude: true, codex: false, codexData: false }; + + const documentAssignments: string[] = []; + const messages: Array> = []; + let delivered = true; + const webview = { + postMessage: (message: Record) => { + messages.push(message); + return Promise.resolve(delivered); + }, + set html(value: string) { + documentAssignments.push(value); + }, + }; + provider.panel = { webview }; + + provider.updateWebview(); + assert.equal(documentAssignments.length, 1, 'the first paint assigns the full document'); + provider.webviewClientReady = true; + provider.todayData = { ...base, totalCost: base.totalCost + 1 }; + provider.updateWebview(); + provider.todayData = { ...base, totalCost: base.totalCost + 1.5 }; + provider.updateWebview(); + await Promise.resolve(); + + assert.equal(documentAssignments.length, 1, 'a delivered refresh keeps the document alive'); + assert.equal(messages.length, 1, 'same-turn data updates coalesce into one patch'); + assert.equal(messages[0].command, 'dashboardDataPatch'); + assert.equal(messages[0].provider, 'claude'); + assert.equal(messages[0].tab, 'today'); + assert.equal(typeof messages[0].html, 'string'); + assert.doesNotMatch(String(messages[0].html), /)/i); + assert.deepEqual(messages[0].claudeLast30HoursByDay, {}); + + delivered = false; + provider.todayData = { ...base, totalCost: base.totalCost + 2 }; + provider.updateWebview(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(messages.length, 2); + assert.equal(documentAssignments.length, 2, 'failed delivery falls back to a full document'); + assert.match(documentAssignments[1], //); + assert.equal(provider.webviewClientReady, false); + } finally { + (Module as any)._load = originalLoad; + } +}); + test('webview provider changes are allowlisted and kept outside time tabs', () => { const source = readFileSync( path.resolve(__dirname, '..', '..', 'src', 'webview.ts'), @@ -407,7 +510,7 @@ test('provider and Codex view copy is complete in every UI locale', () => { if (typeof value === 'string') { assert.notEqual(value.trim(), '', `${language} has empty Codex copy`); } else { - assert.ok([5, 7, 15, 16, 17, 18].includes(Object.keys(value).length)); + assert.ok([5, 7, 15, 16, 17, 18, 19].includes(Object.keys(value).length)); } } assert.deepEqual( diff --git a/src/test/quotaObservationStore.test.ts b/src/test/quotaObservationStore.test.ts index 694a9083..cecfb1f8 100644 --- a/src/test/quotaObservationStore.test.ts +++ b/src/test/quotaObservationStore.test.ts @@ -324,6 +324,23 @@ test('retention and compaction preserve a live window first and last observation assert.equal(retained.observations.length, 0); }); +test('migration compaction keeps the oldest and newest boundary evidence', () => { + const additions = Array.from({ length: 10 }, (_unused, index) => capture({ + observedAt: NOW - (9 - index) * HOUR, + usedFraction: 0.1 + index * 0.01, + captureReason: 'migration', + })); + const compacted = mergeQuotaCaptures(createEmptyQuotaObservationStore(), additions, { + salt: SALT, + now: NOW, + limitPerSeries: 4, + }); + + assert.equal(compacted.observations.length, 4); + assert.ok(compacted.observations.some((item) => item.observedAt === NOW - 9 * HOUR)); + assert.ok(compacted.observations.some((item) => item.observedAt === NOW)); +}); + test('quota store writes atomically and reloads a strict schema-2 document', async () => { const root = path.join(os.tmpdir(), `ccu-quota-store-${process.pid}-${Math.random().toString(16).slice(2)}`); await mkdir(root, { recursive: true }); diff --git a/src/test/refreshPolicy.test.ts b/src/test/refreshPolicy.test.ts index f8283132..047c8db4 100644 --- a/src/test/refreshPolicy.test.ts +++ b/src/test/refreshPolicy.test.ts @@ -13,6 +13,7 @@ import { reportColdRefreshFailure, shouldCommitUsageLoad, shouldReloadUsage, + watcherFailureBackoffMs, WindowActivityGate, } from '../refreshPolicy'; @@ -32,6 +33,13 @@ test('quota failure backoff grows exponentially and caps at one hour', () => { ); }); +test('watcher failure backoff starts promptly and caps without hot-looping', () => { + assert.deepEqual( + [0, 1, 2, 3, 6, 7, 20].map(watcherFailureBackoffMs), + [0, 1_000, 2_000, 4_000, 32_000, 60_000, 60_000], + ); +}); + test('poll interval always honors refreshInterval and never applies an active override', () => { assert.equal(pollIntervalMs(30), 30_000); assert.equal(pollIntervalMs(60), 60_000); diff --git a/src/test/repositoryPolicy.test.ts b/src/test/repositoryPolicy.test.ts index f0aa7f5c..374d96ba 100644 --- a/src/test/repositoryPolicy.test.ts +++ b/src/test/repositoryPolicy.test.ts @@ -1447,7 +1447,7 @@ test('all seven README editions explain Codex Beta in their own language', () => } }); -test('v2.3.1 README editions share release evidence and local-data boundaries', () => { +test('v2.3 README editions share release evidence and local-data boundaries', () => { const readmes = [ 'README.md', 'README-en.md', @@ -1462,6 +1462,7 @@ test('v2.3.1 README editions share release evidence and local-data boundaries', 'images/v2.3.1/codex-overview-zh-CN-dark.png', 'images/v2.3.1/codex-weekly-estimate-en-dark.png', 'images/v2.3.1/compare-heatmap-en-light.png', + 'images/v2.3.2/project-activity-matrix-en-dark.png', ]; for (const image of releaseImages) { assert.ok( @@ -1480,6 +1481,11 @@ test('v2.3.1 README editions share release evidence and local-data boundaries', for (const readme of readmes) { const body = repoFile(readme); assert.match(body, /2\.3\.1/, `${readme} is missing the release section`); + assert.doesNotMatch( + body, + /^##[^\n]*2\.3\.2[^\n]*$/m, + `${readme} must keep What's new at minor-version granularity`, + ); assert.match(body, /LOCAL-DATA(?:\.zh-CN)?\.md/, `${readme} is missing the local-data inventory`); for (const image of releaseImages) { assert.ok(body.includes(image), `${readme} is missing ${image}`); @@ -1543,8 +1549,9 @@ test('changelog records the V2.2.2 energy patch after the released V2.2.1 baseli assert.doesNotMatch(changelog, /^## \[2\.2\.[01]\] — Unreleased$/m); }); -test('changelog records the v2.3.0 and v2.3.1 candidates', () => { +test('changelog records the v2.3.0 through v2.3.2 candidates', () => { const changelog = repoFile('CHANGELOG.md'); + assert.match(changelog, /^## \[2\.3\.2\] — Unreleased$/m); assert.match(changelog, /^## \[2\.3\.1\] — Unreleased$/m); assert.match(changelog, /^## \[2\.3\.0\] — Unreleased$/m); }); @@ -1555,6 +1562,7 @@ test('release announcements are exact-version and user-disableable', () => { assert.match(extension, /'2\.3\.0'/); assert.match(extension, /'2\.3\.1'/); + assert.match(extension, /'2\.3\.2'/); assert.doesNotMatch(extension, /'2\.2'\s*:/); assert.match(settings, /key:\s*'releaseAnnouncements'/); assert.match(settings, /default:\s*true/); diff --git a/src/test/settingsPrivacy.test.ts b/src/test/settingsPrivacy.test.ts index f3285b7c..73652141 100644 --- a/src/test/settingsPrivacy.test.ts +++ b/src/test/settingsPrivacy.test.ts @@ -112,6 +112,41 @@ function fakeContext(options: { }; } +test('local currency selection normalizes before entering global state', async () => { + activeConfiguration = fakeConfiguration(); + activeWorkspaceFolders = []; + activeFolderConfigurations = new Map(); + const context = fakeContext(); + const store = new SettingsStore(context); + + await store.set('displayCurrency', ' eur '); + assert.equal(context._state.get('ccu.setting.displayCurrency'), 'EUR'); + + await store.set('displayCurrency', ''); + assert.equal(context._state.get('ccu.setting.displayCurrency'), 'USD'); + assert.equal(store.snapshot().some((entry) => entry.key === 'usdConversionRate'), false); +}); + +test('early test-build currency state migrates to one preset and drops its manual rate', async () => { + activeConfiguration = fakeConfiguration(); + activeWorkspaceFolders = []; + activeFolderConfigurations = new Map(); + const context = fakeContext({ + state: new Map([ + ['ccu.setting.displayCurrency', ' eur '], + ['ccu.setting.usdConversionRate', 0.92], + ]), + }); + const store = new SettingsStore(context); + + assert.equal(store.get('displayCurrency'), 'EUR'); + await store.migrateCurrencyPreset(); + + assert.equal(context._state.get('ccu.setting.displayCurrency'), 'EUR'); + assert.equal(context._state.has('ccu.setting.usdConversionRate'), false); + assert.equal(context._state.get('ccu.migrated.currencyPreset.v2.3.2'), true); +}); + test('legacy plaintext BYOK migrates to SecretStorage and never enters a settings snapshot', async () => { const canary = 'sk-v2.3.1-privacy-canary'; activeConfiguration = fakeConfiguration(); diff --git a/src/test/shareCardSvg.test.ts b/src/test/shareCardSvg.test.ts index f3c6670b..8abcaa7c 100644 --- a/src/test/shareCardSvg.test.ts +++ b/src/test/shareCardSvg.test.ts @@ -41,6 +41,15 @@ test('falls back to cost as the hero when tokens are hidden', () => { assert.match(svg, /\$12\.50/); }); +test('share cards accept the same display-only currency formatter as the dashboard', () => { + const svg = renderShareCardSvg( + { ...base, totalTokens: 1_000, estimatedCost: 10 }, + { formatCurrency: (usd) => `≈EUR ${(usd * 0.92).toFixed(2)}` }, + ); + assert.match(svg, /≈EUR 9\.20/); + assert.doesNotMatch(svg, /\$10\.00/); +}); + test('omits sections that are absent (privacy: only draws what is present)', () => { const svg = renderShareCardSvg(base); assert.doesNotMatch(svg, />sessions { assert.equal(bg(95, CONTEXT_FILL_THRESHOLDS), 'statusBarItem.errorBackground'); }); +test('provider money bypasses the display-currency estimate conversion', () => { + I18n.setCurrencyDisplay('EUR'); + try { + assert.equal(bareStatusBar().formatCreditAmount(12.34, 'USD'), '$12.34'); + assert.equal(bareStatusBar().formatCreditAmount(12.34, 'GBP'), '12.34 GBP'); + } finally { + I18n.setCurrencyDisplay('USD'); + } +}); + test('Codex quota tooltip reuses the Claude table, progress bar, and line-broken notes', () => { const now = Date.parse('2026-09-02T12:00:00.000Z'); const tooltip = bareStatusBar().createCodexQuotaTooltip({ diff --git a/src/test/webviewWorkflowSummary.test.ts b/src/test/webviewWorkflowSummary.test.ts index 7b201300..3df7cd8d 100644 --- a/src/test/webviewWorkflowSummary.test.ts +++ b/src/test/webviewWorkflowSummary.test.ts @@ -61,7 +61,7 @@ test('Workflows summary matches the configured-zone rolling 30-day dashboard sco I18n.setLanguage('en'); I18n.setTimezone('Asia/Tokyo'); withWebviewProvider((provider) => { - provider.monthData = usage(40); + provider.rolling30DayData = usage(40); provider.workflowBreakdown = [ // The first included civil date is 2026-08-06 in Tokyo. workflow('included-by-start', '2026-08-05T15:00:00.000Z', '2026-09-05T00:00:00.000Z', 10), @@ -98,7 +98,7 @@ test('Workflows rolling-range summary is localized in every UI language', () => try { I18n.setTimezone('UTC'); withWebviewProvider((provider) => { - provider.monthData = usage(10); + provider.rolling30DayData = usage(10); provider.workflowBreakdown = [ workflow('recent', '2026-09-01T00:00:00.000Z', '2026-09-01T01:00:00.000Z', 10), ]; diff --git a/src/types.ts b/src/types.ts index defac15d..b9ce531a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -309,6 +309,8 @@ export interface ExtensionConfig { codexStatusMetric: 'fresh' | 'processed' | 'output'; language: string; decimalPlaces: number; + // Local-only presentation preset. All source pricing and aggregates stay USD. + displayCurrency: string; // Decimals for compact token display only (1.2M / 345.6K). tokenDecimalPlaces: number; compactNumbers: boolean; diff --git a/src/webview.ts b/src/webview.ts index 4f4f39ce..b6313667 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -51,6 +51,7 @@ import { emptyCodexScopedInsights, } from './providers/codex/codexInsights'; import { + CodexDailyUsageView, CodexMetricTotals, CodexHourlyUsageView, CodexProjectUsageView, @@ -61,6 +62,13 @@ import { } from './providers/codex/codexUsage'; import { createCodexLocalizedFormatters } from './codexFormat'; import { getProviderNavClientScript } from './providerNavClient'; +import { getDashboardRefreshClientScript } from './dashboardRefreshClient'; +import { getChartAccessibilityClientScript } from './chartAccessibilityClient'; +import { + ProjectUsageMatrixSnapshot, + projectHeatmap, + projectTrend, +} from './projectUsageMatrix'; import * as os from 'os'; import * as path from 'path'; import * as https from 'https'; @@ -120,7 +128,14 @@ import { UsageData, WorkflowUsage, } from './types'; -import { dayKeyInZone, resolveTimeZone, rollingDayKeys, rollingDayKeysFromDayKey } from './dateKeys'; +import { + dayKeyInZone, + formatHourLabel, + resolveTimeZone, + rollingDayKeys, + rollingDayKeysFromDayKey, +} from './dateKeys'; +import { clockHourKeys, completeDisplayRange } from './displayRange'; import { LocalDataAction, LocalDataActionResult, @@ -141,6 +156,119 @@ interface CodexRenderProgress { } const OPTIMIZER_FEEDBACK_RECOMMENDATION_ID = 'recommendation-optimizer-result-v1'; +const LIVE_PATCH_PANEL_START = ''; +const LIVE_PATCH_PANEL_END = ''; +const LIVE_PATCH_HOURS_START = '/* ccu-live-hours:start */'; +const LIVE_PATCH_HOURS_END = '/* ccu-live-hours:end */'; + +function emptyDisplayUsageData(): UsageData { + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCost: 0, + costBreakdown: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 }, + messageCount: 0, + modelBreakdown: {}, + }; +} + +function emptyCodexHourlyDisplayRow(hour: string): CodexHourlyUsageView { + const total: CodexMetricTotals = { + processed: 0, + fresh: 0, + input: 0, + cachedInput: 0, + output: 0, + reasoning: 0, + }; + return { + hour, + label: formatHourLabel(hour), + total, + apiEquivalent: { + equivalentUsd: 0, + freshInputUsd: 0, + cachedInputUsd: 0, + outputUsd: 0, + pricedTokens: 0, + totalTokens: 0, + pricingCoverage: 0, + }, + threads: 0, + }; +} + +function claudeHourlyDisplayDto( + rowsByDay: Record, +): Record }>> { + const projected: Record< + string, + Array<{ hour: string; data: Omit }> + > = {}; + for (const [day, rows] of Object.entries(rowsByDay)) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue; + projected[day] = rows.flatMap(({ hour, data }) => { + if (!/^(?:[01]\d|2[0-3]):00$/.test(hour)) return []; + return [{ + hour, + data: { + totalInputTokens: data.totalInputTokens, + totalOutputTokens: data.totalOutputTokens, + totalCacheCreationTokens: data.totalCacheCreationTokens, + totalCacheReadTokens: data.totalCacheReadTokens, + totalCost: data.totalCost, + costBreakdown: { ...data.costBreakdown }, + messageCount: data.messageCount, + }, + }]; + }); + } + return projected; +} + +function inlineScriptJson(value: unknown): string { + return JSON.stringify(value) + .replace(/; +} + +interface PendingDashboardLivePatch extends DashboardLivePatch { + documentHtml: string; + revision: number; +} + +function markedContent(source: string, startMarker: string, endMarker: string): string | undefined { + const start = source.indexOf(startMarker); + if (start < 0) return undefined; + const contentStart = start + startMarker.length; + const end = source.indexOf(endMarker, contentStart); + return end < 0 ? undefined : source.slice(contentStart, end); +} + +function replaceMarkedContent( + source: string, + startMarker: string, + endMarker: string, + replacement: string, +): string | undefined { + const start = source.indexOf(startMarker); + if (start < 0) return undefined; + const contentStart = start + startMarker.length; + const end = source.indexOf(endMarker, contentStart); + if (end < 0) return undefined; + return source.slice(0, contentStart) + replacement + source.slice(end); +} interface LocalDataUiCopy { title: string; @@ -602,15 +730,212 @@ function combinedHeatmapUiCopy(locale: string): CombinedHeatmapUiCopy { }; } +interface ProjectMatrixUiCopy { + title: string; + description: string; + rangeLabel: string; + viewLabel: string; + last30: string; + last90: string; + heatmap: string; + trend: string; + completeCoverage: string; + partialCoverage: string; + noCoverage: string; + project: string; + activeDays: string; + otherProjects: string; + showMore: string; + showLess: string; + matrixAria: string; + trendAria: string; +} + +function projectMatrixUiCopy(locale: string): ProjectMatrixUiCopy { + const copies: Record = { + en: { + title: 'Project activity', + description: 'One indexed project × calendar-day source powers both views. Token activity only; not cost or subscription quota.', + rangeLabel: 'Range', + viewLabel: 'View', + last30: '30 days', + last90: '90 days', + heatmap: 'Heatmap', + trend: 'Trend', + completeCoverage: 'Complete coverage', + partialCoverage: 'Indexed subtotal · partial coverage', + noCoverage: 'No indexed coverage', + project: 'Project', + activeDays: 'active days', + otherProjects: 'Other projects', + showMore: 'Show more projects', + showLess: 'Show fewer projects', + matrixAria: 'Project activity heatmap', + trendAria: 'Daily project activity stacked trend', + }, + 'de-DE': { + title: 'Projektaktivität', + description: 'Eine indexierte Projekt-×-Kalendertag-Quelle speist beide Ansichten. Nur Token-Aktivität; keine Kosten oder Abokontingente.', + rangeLabel: 'Zeitraum', + viewLabel: 'Ansicht', + last30: '30 Tage', + last90: '90 Tage', + heatmap: 'Heatmap', + trend: 'Verlauf', + completeCoverage: 'Vollständige Abdeckung', + partialCoverage: 'Indexierte Zwischensumme · teilweise Abdeckung', + noCoverage: 'Keine indexierte Abdeckung', + project: 'Projekt', + activeDays: 'aktive Tage', + otherProjects: 'Weitere Projekte', + showMore: 'Mehr Projekte anzeigen', + showLess: 'Weniger Projekte anzeigen', + matrixAria: 'Heatmap der Projektaktivität', + trendAria: 'Gestapelter Tagesverlauf der Projektaktivität', + }, + 'zh-TW': { + title: '專案活動', + description: '兩種檢視共用同一份已索引的「專案 × 本機日期」資料。僅表示 Token 活動,不是成本或訂閱額度。', + rangeLabel: '範圍', + viewLabel: '檢視', + last30: '30 天', + last90: '90 天', + heatmap: '熱力圖', + trend: '趨勢', + completeCoverage: '涵蓋完整', + partialCoverage: '已索引小計 · 涵蓋不完整', + noCoverage: '沒有已索引涵蓋資料', + project: '專案', + activeDays: '個活躍日', + otherProjects: '其他專案', + showMore: '顯示更多專案', + showLess: '收起專案', + matrixAria: '專案活動熱力圖', + trendAria: '每日專案活動堆疊趨勢', + }, + 'zh-CN': { + title: '项目活动', + description: '两种视图共用同一份已索引的“项目 × 本地日期”数据。仅表示 Token 活动,不是成本或订阅额度。', + rangeLabel: '范围', + viewLabel: '视图', + last30: '30 天', + last90: '90 天', + heatmap: '热力图', + trend: '趋势', + completeCoverage: '覆盖完整', + partialCoverage: '已索引小计 · 覆盖不完整', + noCoverage: '没有已索引覆盖数据', + project: '项目', + activeDays: '个活跃日', + otherProjects: '其他项目', + showMore: '显示更多项目', + showLess: '收起项目', + matrixAria: '项目活动热力图', + trendAria: '每日项目活动堆叠趋势', + }, + ja: { + title: 'プロジェクト活動', + description: '両方の表示は、同じ索引済み「プロジェクト × ローカル日付」データを使用します。Token 活動のみで、費用や契約枠ではありません。', + rangeLabel: '期間', + viewLabel: '表示', + last30: '30 日', + last90: '90 日', + heatmap: 'ヒートマップ', + trend: '推移', + completeCoverage: '完全なカバレッジ', + partialCoverage: '索引済み小計 · 一部カバレッジ', + noCoverage: '索引済みデータなし', + project: 'プロジェクト', + activeDays: '活動日', + otherProjects: 'その他のプロジェクト', + showMore: 'さらに表示', + showLess: '表示を減らす', + matrixAria: 'プロジェクト活動ヒートマップ', + trendAria: '日別プロジェクト活動の積み上げ推移', + }, + ko: { + title: '프로젝트 활동', + description: '두 보기는 동일한 인덱싱된 프로젝트 × 현지 날짜 데이터를 사용합니다. Token 활동일 뿐 비용이나 구독 한도가 아닙니다.', + rangeLabel: '기간', + viewLabel: '보기', + last30: '30일', + last90: '90일', + heatmap: '히트맵', + trend: '추세', + completeCoverage: '전체 범위', + partialCoverage: '인덱싱 소계 · 일부 범위', + noCoverage: '인덱싱된 범위 없음', + project: '프로젝트', + activeDays: '활동 일수', + otherProjects: '기타 프로젝트', + showMore: '프로젝트 더 보기', + showLess: '프로젝트 접기', + matrixAria: '프로젝트 활동 히트맵', + trendAria: '일별 프로젝트 활동 누적 추세', + }, + 'pt-BR': { + title: 'Atividade por projeto', + description: 'As duas visualizações usam a mesma fonte indexada de projeto × dia local. Apenas atividade de Tokens; não representa custo nem cota da assinatura.', + rangeLabel: 'Período', + viewLabel: 'Visualização', + last30: '30 dias', + last90: '90 dias', + heatmap: 'Mapa de calor', + trend: 'Tendência', + completeCoverage: 'Cobertura completa', + partialCoverage: 'Subtotal indexado · cobertura parcial', + noCoverage: 'Sem cobertura indexada', + project: 'Projeto', + activeDays: 'dias ativos', + otherProjects: 'Outros projetos', + showMore: 'Mostrar mais projetos', + showLess: 'Mostrar menos projetos', + matrixAria: 'Mapa de calor da atividade por projeto', + trendAria: 'Tendência diária empilhada da atividade por projeto', + }, + id: { + title: 'Aktivitas proyek', + description: 'Kedua tampilan memakai sumber terindeks proyek × hari lokal yang sama. Hanya aktivitas Token; bukan biaya atau kuota langganan.', + rangeLabel: 'Rentang', + viewLabel: 'Tampilan', + last30: '30 hari', + last90: '90 hari', + heatmap: 'Peta panas', + trend: 'Tren', + completeCoverage: 'Cakupan lengkap', + partialCoverage: 'Subtotal terindeks · cakupan sebagian', + noCoverage: 'Tidak ada cakupan terindeks', + project: 'Proyek', + activeDays: 'hari aktif', + otherProjects: 'Proyek lainnya', + showMore: 'Tampilkan lebih banyak proyek', + showLess: 'Tampilkan lebih sedikit proyek', + matrixAria: 'Peta panas aktivitas proyek', + trendAria: 'Tren bertumpuk aktivitas proyek harian', + }, + }; + return copies[locale] ?? copies.en; +} + export class UsageWebviewProvider { private panel: vscode.WebviewPanel | undefined; + private webviewClientReady = false; + private dashboardLivePatchRevision = 0; + private pendingDashboardLivePatch: PendingDashboardLivePatch | undefined; + private scheduledDashboardLivePatch: PendingDashboardLivePatch | undefined; + private dashboardLivePatchSendScheduled = false; + private lastLivePatchStructureKey: string | undefined; private currentSessionData: SessionData | null = null; private todayData: UsageData | null = null; - private monthData: UsageData | null = null; + private rolling30DayData: UsageData | null = null; private allTimeData: UsageData | null = null; - private dailyDataForMonth: { date: string; data: UsageData }[] = []; + private dailyDataForRolling30Days: { date: string; data: UsageData }[] = []; private dailyDataForAllTime: { date: string; data: UsageData }[] = []; private hourlyDataForToday: { hour: string; data: UsageData }[] = []; + private hourlyDataForRolling30DaysByDay: Record< + string, + { hour: string; data: UsageData }[] + > = {}; private isLoading: boolean = false; private error: string | null = null; private dataDirectory: string | null = null; @@ -622,7 +947,6 @@ export class UsageWebviewProvider { private codexLoading = false; private codexProgress: CodexRenderProgress | null = null; private providerSelectionInitialized = false; - private hourlyDataCache: Map = new Map(); private configuredDateTimeFormatter?: { configuredTimeZone: string; formatter: Intl.DateTimeFormat; @@ -630,6 +954,7 @@ export class UsageWebviewProvider { private allRecords: any[] = []; private sessionBreakdown: SessionUsage[] = []; private projectBreakdown: ProjectGroup[] = []; + private claudeProjectUsageMatrix: ProjectUsageMatrixSnapshot | null = null; private contentAnalysis: ContentAnalysis | null = null; private branchBreakdown: BranchUsage[] = []; private workflowBreakdown: WorkflowUsage[] = []; @@ -1248,6 +1573,12 @@ export class UsageWebviewProvider { this.lastShareCardConfig = undefined; } + /** A display-format change invalidates rendered money but not the user's + * chosen share-card range, scope, sections, or theme. */ + public invalidateShareCardPreview(): void { + this.lastShareCardSvg = undefined; + } + private async handleClearAdviceLocalDataMessage(): Promise { const ok = await this.clearAdviceLocalData(); this.postAdviceMessage({ command: 'adviceClearResult', ok }); @@ -1597,6 +1928,10 @@ export class UsageWebviewProvider { this.panel.onDidDispose(() => { this.panel = undefined; + this.webviewClientReady = false; + this.pendingDashboardLivePatch = undefined; + this.scheduledDashboardLivePatch = undefined; + this.lastLivePatchStructureKey = undefined; for (const requestId of [...this.localDataClientActionRequests.keys()]) { this.settleLocalDataClientAction(requestId, false); } @@ -1648,8 +1983,24 @@ export class UsageWebviewProvider { await this.handleClearAdviceLocalDataMessage(); break; case 'localDataClientReady': + this.webviewClientReady = true; this.onLocalDataClientReady?.(); break; + case 'dashboardDataPatchAck': { + const pending = this.pendingDashboardLivePatch; + if ( + pending && + Number.isSafeInteger(message.revision) && + message.revision === pending.revision + ) { + if (message.ok === true) { + this.pendingDashboardLivePatch = undefined; + } else { + this.replaceDocumentAfterPatchFailure(pending); + } + } + break; + } case 'requestLocalDataInventory': { const summary = message.clientSummary as Partial | undefined; const clientSummary: LocalDataClientSummary = { @@ -2065,36 +2416,36 @@ export class UsageWebviewProvider { } break; } - case 'getHourlyData': - const dateString = message.date; - if (dateString && this.panel) { - // Get hourly data for the specified date - const { ClaudeDataLoader } = await import('./dataLoader'); - const hourlyData = ClaudeDataLoader.getHourlyDataForDate(this.allRecords, dateString); - - // Send data back to webview - this.panel.webview.postMessage({ - command: 'hourlyDataResponse', - date: dateString, - data: hourlyData, - }); + case 'getDailyData': { + const monthString = typeof message.month === 'string' ? message.month : ''; + const requestedProvider = message.provider === 'codex' || message.provider === 'claude' + ? message.provider + : ''; + if (!/^\d{4}-\d{2}$/.test(monthString) || !this.panel || requestedProvider !== this.currentProvider) { + break; } - break; - case 'getDailyData': - const monthString = message.month; - if (monthString && this.panel) { - // Get daily data for the specified month - const { ClaudeDataLoader } = await import('./dataLoader'); - const dailyData = ClaudeDataLoader.getDailyDataForSpecificMonth(this.allRecords, monthString); - - // Send data back to webview - this.panel.webview.postMessage({ + if (requestedProvider === 'codex') { + const dailyRows = (this.codexView?.allTimeDaily ?? []) + .filter((row) => row.day.startsWith(monthString + '-')) + .sort((left, right) => left.day.localeCompare(right.day)); + await this.panel.webview.postMessage({ command: 'dailyDataResponse', + provider: 'codex', month: monthString, - data: dailyData, + html: this.renderCodexMonthDailyDetail(monthString, dailyRows), }); + break; } + const { ClaudeDataLoader } = await import('./dataLoader'); + const dailyData = ClaudeDataLoader.getDailyDataForSpecificMonth(this.allRecords, monthString); + await this.panel.webview.postMessage({ + command: 'dailyDataResponse', + provider: 'claude', + month: monthString, + data: dailyData, + }); break; + } } }); @@ -2104,9 +2455,9 @@ export class UsageWebviewProvider { updateData( sessionData: SessionData | null, todayData: UsageData | null, - monthData: UsageData | null, + rolling30DayData: UsageData | null, allTimeData: UsageData | null, - dailyDataForMonth: { date: string; data: UsageData }[] = [], + dailyDataForRolling30Days: { date: string; data: UsageData }[] = [], dailyDataForAllTime: { date: string; data: UsageData }[] = [], hourlyDataForToday: { hour: string; data: UsageData }[] = [], error?: string, @@ -2117,15 +2468,21 @@ export class UsageWebviewProvider { contentAnalysis: ContentAnalysis | null = null, branchBreakdown: BranchUsage[] = [], workflowBreakdown: WorkflowUsage[] = [], - costliestMessages: CostlyMessage[] = [] + costliestMessages: CostlyMessage[] = [], + hourlyDataForRolling30DaysByDay: Record< + string, + { hour: string; data: UsageData }[] + > = {}, + projectUsageMatrix: ProjectUsageMatrixSnapshot | null = null, ): void { this.currentSessionData = sessionData; this.todayData = todayData; - this.monthData = monthData; + this.rolling30DayData = rolling30DayData; this.allTimeData = allTimeData; - this.dailyDataForMonth = dailyDataForMonth; + this.dailyDataForRolling30Days = dailyDataForRolling30Days; this.dailyDataForAllTime = dailyDataForAllTime; this.hourlyDataForToday = hourlyDataForToday; + this.hourlyDataForRolling30DaysByDay = hourlyDataForRolling30DaysByDay; this.error = error || null; this.dataDirectory = dataDirectory || null; this.isLoading = false; @@ -2134,12 +2491,13 @@ export class UsageWebviewProvider { } this.sessionBreakdown = sessionBreakdown; this.projectBreakdown = projectBreakdown; + this.claudeProjectUsageMatrix = projectUsageMatrix; this.contentAnalysis = contentAnalysis; this.branchBreakdown = branchBreakdown; this.workflowBreakdown = workflowBreakdown; this.costliestMessages = costliestMessages; this.providerAvailability.claude = Boolean( - sessionData || todayData || monthData || allTimeData, + sessionData || todayData || rolling30DayData || allTimeData, ); if (this.panel) { @@ -2306,13 +2664,108 @@ export class UsageWebviewProvider { // is byte-identical — nothing visible would change anyway. private lastHtml: string = ''; + private dashboardLivePatchFor(html: string): DashboardLivePatch | undefined { + if (this.currentProvider === 'compare') return undefined; + const panelHtml = markedContent(html, LIVE_PATCH_PANEL_START, LIVE_PATCH_PANEL_END); + if (panelHtml === undefined) return undefined; + const panelNeutral = replaceMarkedContent( + html, + LIVE_PATCH_PANEL_START, + LIVE_PATCH_PANEL_END, + '', + ); + if (panelNeutral === undefined) return undefined; + const structureHtml = replaceMarkedContent( + panelNeutral, + LIVE_PATCH_HOURS_START, + LIVE_PATCH_HOURS_END, + '', + ); + if (structureHtml === undefined) return undefined; + return { + provider: this.currentProvider, + tab: this.currentTab, + panelHtml, + structureKey: createHash('sha256').update(structureHtml, 'utf8').digest('hex'), + claudeLast30HoursByDay: claudeHourlyDisplayDto( + this.hourlyDataForRolling30DaysByDay, + ), + }; + } + + private replaceDocumentAfterPatchFailure(pending: PendingDashboardLivePatch): void { + if (!this.panel || this.pendingDashboardLivePatch?.revision !== pending.revision) return; + const fallback = this.scheduledDashboardLivePatch ?? pending; + this.pendingDashboardLivePatch = undefined; + this.scheduledDashboardLivePatch = undefined; + this.webviewClientReady = false; + this.lastHtml = fallback.documentHtml; + this.lastLivePatchStructureKey = fallback.structureKey; + this.panel.webview.html = fallback.documentHtml; + } + + private scheduleDashboardLivePatch(pending: PendingDashboardLivePatch): void { + this.scheduledDashboardLivePatch = pending; + if (this.dashboardLivePatchSendScheduled) return; + this.dashboardLivePatchSendScheduled = true; + queueMicrotask(() => { + this.dashboardLivePatchSendScheduled = false; + const latest = this.scheduledDashboardLivePatch; + this.scheduledDashboardLivePatch = undefined; + if ( + !latest || + !this.panel || + !this.webviewClientReady || + this.lastHtml !== latest.documentHtml + ) { + return; + } + const panel = this.panel; + this.pendingDashboardLivePatch = latest; + void Promise.resolve(panel.webview.postMessage({ + command: 'dashboardDataPatch', + provider: latest.provider, + tab: latest.tab, + revision: latest.revision, + html: latest.panelHtml, + claudeLast30HoursByDay: latest.claudeLast30HoursByDay, + })).then((delivered) => { + if (delivered === false) { + this.replaceDocumentAfterPatchFailure(latest); + } + }, () => { + this.replaceDocumentAfterPatchFailure(latest); + }); + }); + } + private updateWebview(): void { if (!this.panel) return; const html = this.getWebviewContent(); if (html === this.lastHtml) { return; } + const nextPatch = this.dashboardLivePatchFor(html); + if ( + this.webviewClientReady && + nextPatch && + nextPatch.structureKey === this.lastLivePatchStructureKey + ) { + const pending: PendingDashboardLivePatch = { + ...nextPatch, + documentHtml: html, + revision: ++this.dashboardLivePatchRevision, + }; + this.lastHtml = html; + this.lastLivePatchStructureKey = nextPatch.structureKey; + this.scheduleDashboardLivePatch(pending); + return; + } + this.pendingDashboardLivePatch = undefined; + this.scheduledDashboardLivePatch = undefined; + this.webviewClientReady = false; this.lastHtml = html; + this.lastLivePatchStructureKey = nextPatch?.structureKey; this.panel.webview.html = html; } @@ -2333,7 +2786,7 @@ export class UsageWebviewProvider { if ( !this.currentSessionData && !this.todayData && - !this.monthData && + !this.rolling30DayData && !this.providerAvailability.codex ) { return this.getNoDataContent(); @@ -2658,7 +3111,7 @@ export class UsageWebviewProvider { // Keep the navigation familiar across providers. Codex still renders the // provider-native "Recent task" heading and aggregation inside this tab. const today = I18n.t.popup.today; - const thisMonth = provider === 'codex' ? codexCopy.last30Days : I18n.t.popup.last30days; + const middleRangeLabel = provider === 'codex' ? codexCopy.last30Days : I18n.t.popup.last30days; const allTime = provider === 'codex' ? codexCopy.allTime : I18n.t.popup.allTime; const sessions = provider === 'codex' ? codexCopy.sessions : I18n.t.popup.sessions; const projects = provider === 'codex' ? codexCopy.projects : I18n.t.popup.projects; @@ -2668,7 +3121,7 @@ export class UsageWebviewProvider { const settingsTab = I18n.t.popup.settingsTab; const todayActive = this.currentTab === 'today' ? 'active' : ''; - const monthActive = this.currentTab === 'month' ? 'active' : ''; + const rolling30Active = this.currentTab === 'month' ? 'active' : ''; const allActive = this.currentTab === 'all' ? 'active' : ''; const sessionsActive = this.currentTab === 'sessions' ? 'active' : ''; const projectsActive = this.currentTab === 'projects' ? 'active' : ''; @@ -2746,11 +3199,12 @@ export class UsageWebviewProvider { ` + this.renderProviderTabs() + `
` + + LIVE_PATCH_PANEL_START + this.renderQuotaBanner(provider) + `
` + dashboardTab('today', today, todayActive) + - dashboardTab('month', thisMonth, monthActive) + + dashboardTab('month', middleRangeLabel, rolling30Active) + dashboardTab('all', allTime, allActive) + dashboardTab('sessions', sessions, sessionsActive) + dashboardTab('projects', projects, projectsActive) + @@ -2763,7 +3217,7 @@ export class UsageWebviewProvider {
` + dashboardPanel('today', todayActive, this.renderTodayData(provider)) + - dashboardPanel('month', monthActive, this.renderMonthData(provider)) + + dashboardPanel('month', rolling30Active, this.renderMonthData(provider)) + dashboardPanel('all', allActive, this.renderAllTimeData(provider)) + dashboardPanel('sessions', sessionsActive, this.renderSessionData(provider)) + dashboardPanel('projects', projectsActive, this.renderProjectData(provider)) + @@ -2772,7 +3226,8 @@ export class UsageWebviewProvider { ? dashboardPanel('branches', branchesActive, this.renderBranchData()) + dashboardPanel('workflows', workflowsActive, this.renderWorkflowData()) : '') + - dashboardPanel('settings', settingsActive, this.renderSettingsPanel(provider)) + ` + dashboardPanel('settings', settingsActive, this.renderSettingsPanel(provider)) + + LIVE_PATCH_PANEL_END + `