From f86cee3a6b354c002b27669992aa1af42d4dfdcc Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:49:05 +0800 Subject: [PATCH 1/4] fix: stabilize v2.3.2 dashboard interactions (#100) Prepare the v2.3.2 patch with complete provider drill-down paths, state-preserving refresh, bounded background recovery, fixed display-currency presets, quieter hourly charts, and synchronized release documentation. Closes #91. Relates to #99. Co-authored-by: OpenAI Codex <215057067+openai-codex[bot]@users.noreply.github.com> --- CHANGELOG.md | 43 + LOCAL-DATA.md | 14 +- LOCAL-DATA.zh-CN.md | 11 +- README-en.md | 20 +- README-id.md | 22 +- README-ja.md | 20 +- README-ko.md | 20 +- README-zh-CN.md | 17 +- README-zh-TW.md | 17 +- README.md | 28 +- docs/roadmap/provider-parity-matrix-v2.4.md | 63 + .../provider-parity-matrix-v2.4.zh-CN.md | 59 + docs/roadmap/provider-parity-v2.4.md | 96 +- docs/roadmap/provider-parity-v2.4.zh-CN.md | 73 +- src/chartAccessibilityClient.ts | 102 ++ src/claudeIncrementalIndex.ts | 85 +- src/codexViewComponents.ts | 2 + src/currencyDisplay.ts | 134 +++ src/dashboardRefreshClient.ts | 265 +++++ src/displayRange.ts | 40 + src/extension.ts | 267 ++++- src/i18n.ts | 47 +- src/providers/codex/codexIdentity.ts | 169 ++- src/providers/codex/codexProvider.ts | 8 +- src/providers/codex/codexUsage.ts | 8 +- src/quotaObservationStore.ts | 36 +- src/refreshPolicy.ts | 9 + src/settings.ts | 67 +- src/shareCardSvg.ts | 7 +- src/statusBar.ts | 9 +- src/test/claudeIncrementalIndex.test.ts | 4 + src/test/codexIdentity.test.ts | 59 + src/test/codexQualityI18n.test.ts | 42 + src/test/codexUsage.test.ts | 3 + src/test/currencyDisplay.test.ts | 63 + src/test/dashboardOnlySettings.test.ts | 24 + src/test/displayRange.test.ts | 46 + src/test/extensionRefreshRouting.test.ts | 5 +- src/test/extensionWindowActivity.test.ts | 184 ++- src/test/format.test.ts | 18 + src/test/providerSelection.test.ts | 93 +- src/test/quotaObservationStore.test.ts | 17 + src/test/refreshPolicy.test.ts | 8 + src/test/settingsPrivacy.test.ts | 35 + src/test/shareCardSvg.test.ts | 9 + src/test/statusBarThresholds.test.ts | 11 + src/test/webviewWorkflowSummary.test.ts | 4 +- src/types.ts | 2 + src/webview.ts | 1047 ++++++++++++++--- tests/ui/advice-effectiveness.spec.mjs | 23 + tests/ui/codex-accessibility.spec.mjs | 41 + tests/ui/codex-interactions.spec.mjs | 280 ++++- tests/ui/codex-visual.spec.mjs | 20 + tests/ui/combined-heatmap.spec.mjs | 20 + tests/ui/currency-display.spec.mjs | 96 ++ tests/ui/support/render-harness.cjs | 38 +- tests/ui/support/server.mjs | 7 + tests/ui/time-range-presentation.spec.mjs | 75 ++ 58 files changed, 3679 insertions(+), 353 deletions(-) create mode 100644 docs/roadmap/provider-parity-matrix-v2.4.md create mode 100644 docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md create mode 100644 src/chartAccessibilityClient.ts create mode 100644 src/currencyDisplay.ts create mode 100644 src/dashboardRefreshClient.ts create mode 100644 src/displayRange.ts create mode 100644 src/test/codexQualityI18n.test.ts create mode 100644 src/test/currencyDisplay.test.ts create mode 100644 src/test/displayRange.test.ts create mode 100644 tests/ui/currency-display.spec.mjs create mode 100644 tests/ui/time-range-presentation.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 075e06ea..c2c00f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ 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 +- **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 +- **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..386c2a51 100644 --- a/README-en.md +++ b/README-en.md @@ -53,6 +53,24 @@ 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.2 + +- Settings now uses one curated currency dropdown, defaulting to USD. Thirteen + additional display currencies use a bundled, fixed 2026-09-09 ECB-derived + snapshot; rates are not editable or fetched, and source estimates remain USD. +- Claude and Codex now share complete All time month → day and Last 30 days day + → hour drill-down wherever materialized aggregates exist, with matching mouse, + keyboard, focus, nested-collapse, and reload behavior and no JSONL read on click. +- Normal live refresh preserves the active tab, expansion chain, selected + metric/hour, Optimizer draft, keyboard focus, and nearby scroll anchor. +- Every chart exposes a localized provider-qualified accessible name. Empty + hours keep their axis, table, tooltip, and selectable zero value without + repeating `0` above every bar; genuinely unpriced activity still shows `—`. +- Provider watchers recover with bounded backoff, and unchanged Codex + thread-title indexes use a validated memory cache to reduce background work. + +The reviewed draft is `v2.3.2`; the human-controlled publish workflow stamps package metadata from that release tag. + ## What's new in v2.3.1 - Exact pricing and context metadata now cover **GPT-6 Astra** @@ -77,8 +95,6 @@ These four v2.3.1 captures use the production renderer, synthetic fixtures, and - 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 - 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. diff --git a/README-id.md b/README-id.md index 603ef099..5fed5c09 100644 --- a/README-id.md +++ b/README-id.md @@ -53,6 +53,26 @@ 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.2 + +- Settings kini memakai satu dropdown mata uang dengan USD sebagai default. + Tiga belas mata uang tampilan lain memakai snapshot tetap kurs referensi ECB + tanggal 2026-09-09. Kurs tidak dapat diedit atau diambil secara online, dan + estimasi dasar tetap disimpan dalam USD. +- Claude dan Codex mendukung drill-down Sepanjang Waktu bulan → hari serta 30 + Hari Terakhir hari → jam jika agregatnya tersedia. Mouse, keyboard, fokus, + penutupan bertingkat, dan pemulihan setelah reload konsisten tanpa membaca JSONL saat diklik. +- Refresh langsung biasa mempertahankan tab, rantai ekspansi, metrik/jam terpilih, + draf Optimizer, fokus keyboard, dan posisi gulir terdekat. +- Setiap grafik memiliki nama aksesibel lokal yang menyertakan provider. Jam + kosong tetap memiliki sumbu, tabel, tooltip, dan nilai 0 yang dapat dipilih, + tetapi tidak lagi menampilkan 0 di atas setiap batang kosong. Aktivitas yang + benar-benar belum memiliki harga tetap menampilkan `—`. +- Watcher provider pulih dengan backoff terbatas, sedangkan indeks judul thread + Codex yang tidak berubah memakai cache memori tervalidasi untuk mengurangi kerja latar. + +Draft Release yang ditinjau adalah `v2.3.2`; alur publikasi manual menetapkan versi paket dari tag tersebut. + ## Yang baru di v2.3.1 - Metadata harga dan konteks yang tepat kini mencakup **GPT-6 Astra** @@ -77,8 +97,6 @@ Empat gambar v2.3.1 ini menggunakan renderer produksi, data sintetis, dan variab - 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 - 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. diff --git a/README-ja.md b/README-ja.md index 5ec8ebd9..30c405bf 100644 --- a/README-ja.md +++ b/README-ja.md @@ -53,6 +53,24 @@ - **マルチベンダー価格** — Opus 4.x / Sonnet 4.x / Haiku 4.5 は Anthropic 公式価格で検証済み。OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen の参考価格(ファミリー認識フォールバック付き)。`Refresh Token Pricing` で LiteLLM の最新価格を取得。 - **パーソナライズ** — 言語、タイムゾーン、小数点桁数、コンパクト数値、プロジェクトのグループ化、ダッシュボード自動更新の切り替え。 +## v2.3.2 の新機能 + +- 設定は 1 つの通貨ドロップダウンに整理され、既定値は USD です。ほかの 13 通貨は + 2026-09-09 の ECB 参照レートを固定スナップショットとして使用します。レートは + 編集もオンライン取得もされず、基礎となる見積もりは USD のままです。 +- Claude と Codex の全期間では月 → 日、直近 30 日では既存集計の日 → 時間を + 展開できます。マウス、キーボード、フォーカス、入れ子の折りたたみ、再読み込み後の + 復元が一致し、クリック時に JSONL を読みません。 +- 通常のライブ更新では、タブ、展開階層、選択中の指標/時間、Optimizer の下書き、 + キーボードフォーカス、近くのスクロール位置を保持します。 +- 各グラフには provider を含むローカライズ済みアクセシブル名があります。空の時間も + 軸、表、ツールチップ、選択可能な 0 値は保持しつつ、各棒の上の 0 表示は省略します。 + 実際の利用があり価格不明の場合は引き続き `—` を表示します。 +- provider watcher は上限付きバックオフで復旧し、変更のない Codex スレッドタイトル + 索引は検証済みメモリキャッシュを再利用してバックグラウンド処理を減らします。 + +レビュー済み Release ドラフトは `v2.3.2` です。手動公開ワークフローが、その tag からパッケージ版を設定します。 + ## v2.3.1 の新機能 - 正確な料金とコンテキスト情報が **GPT-6 Astra**(`gpt-6-astra`、 @@ -77,8 +95,6 @@ - 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 - Codex の使用量レコードは `sessions/**/*.jsonl` と `archived_sessions/**/*.jsonl` からだけ検出し、認証情報、DB、未知のファイルは対象外です。これとは別に、`$CODEX_HOME/session_index.jsonl` だけをストリームで読み込み、実際のスレッド名に使う `id` → `thread_name` の対応を取得します。名前に含まれる絶対パスは伏せ、名前はメモリ内だけに保持します。使用量 JSONL の各行は、許可リスト内の使用量・構造メタデータを抽出するためだけにストリーム処理で一時解析します。プロンプト、応答、コマンド、ツール引数の各フィールドは参照も分析利用もせず、保持・永続化もしません。 diff --git a/README-ko.md b/README-ko.md index 3e452860..5aa5b398 100644 --- a/README-ko.md +++ b/README-ko.md @@ -53,6 +53,24 @@ - **멀티 벤더 가격** — Opus 4.x / Sonnet 4.x / Haiku 4.5는 Anthropic 공식 가격으로 검증. OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 참고 가격(패밀리 인식 폴백 포함). `Refresh Token Pricing`으로 LiteLLM 최신 가격 가져오기. - **개인화** — 언어, 시간대, 소수 자릿수, 간략 숫자, 프로젝트 그룹화, 대시보드 자동 새로고침 토글. +## v2.3.2 새로운 기능 + +- 설정은 하나의 통화 드롭다운으로 정리되며 기본값은 USD입니다. 다른 13개 표시 통화는 + 2026-09-09 ECB 참조 환율의 고정 스냅샷을 사용합니다. 환율은 편집하거나 온라인으로 + 가져오지 않으며 기반 추정치는 계속 USD로 유지됩니다. +- Claude와 Codex의 전체 기간에서 월 → 일, 최근 30일에서 이미 집계된 일 → 시간 + 드릴다운을 지원합니다. 마우스, 키보드, 포커스, 중첩 접기, 다시 로드한 뒤 복원 동작이 + 일치하며 클릭할 때 JSONL을 읽지 않습니다. +- 일반 실시간 새로고침은 현재 탭, 확장 경로, 선택한 지표/시간, Optimizer 초안, + 키보드 포커스와 가까운 스크롤 위치를 유지합니다. +- 모든 차트는 provider가 포함된 현지화 접근성 이름을 제공합니다. 빈 시간도 축, 표, + 툴팁과 선택 가능한 0 값은 유지하지만 각 막대 위의 0 표시는 생략합니다. 실제 사용량의 + 가격을 알 수 없을 때는 계속 `—`를 표시합니다. +- provider watcher는 제한된 백오프로 복구하며, 변경되지 않은 Codex 스레드 제목 인덱스는 + 검증된 메모리 캐시를 재사용해 백그라운드 작업을 줄입니다. + +검토된 Release 초안은 `v2.3.2`이며, 수동 게시 워크플로가 해당 tag에서 패키지 버전을 설정합니다. + ## v2.3.1 새로운 기능 - 정확한 가격 및 컨텍스트 메타데이터가 **GPT-6 Astra**(`gpt-6-astra`, @@ -77,8 +95,6 @@ - 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 - Codex 사용량 레코드는 `sessions/**/*.jsonl`과 `archived_sessions/**/*.jsonl`에서만 찾으며 자격 증명, DB, 알 수 없는 파일은 제외합니다. 별도로 `$CODEX_HOME/session_index.jsonl` 하나만 정확히 스트리밍해 실제 스레드 제목에 사용할 `id` → `thread_name` 매핑을 가져옵니다. 제목 속 절대 경로는 가리고 제목은 메모리에만 유지합니다. 사용량 JSONL 각 줄은 허용 목록의 사용량·구조 메타데이터를 추출하기 위해서만 스트리밍 방식으로 일시 파싱합니다. 프롬프트, 응답, 명령, 도구 인수 필드는 검사하거나 분석에 사용하지 않으며 보관·영구 저장하지 않습니다. diff --git a/README-zh-CN.md b/README-zh-CN.md index c2884019..0a48c81a 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -73,6 +73,21 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 --- +## v2.3.2 新功能 + +- 设置现收敛为一个币种下拉栏,默认 USD;另有 13 种常用显示币种,采用固定的 + 2026-09-09 ECB 参考汇率快照。汇率不允许手填、不会联网获取,底层估算仍为 USD。 +- Claude 与 Codex 的全部时间均支持“月 → 日”,最近 30 天均支持已有聚合的 + “日 → 小时”;鼠标、键盘、焦点、嵌套收起和重载恢复行为一致,点击不读取 JSONL。 +- 普通实时刷新会保留当前标签、展开链、所选指标/小时、优化器草稿、键盘焦点和 + 附近滚动位置。 +- 每张图都有包含 provider 的本地化无障碍名称。空小时仍保留坐标、表格、提示与 + 可选中的 0 值,但不再在每根空柱上方重复标 0;真实未定价活动仍显示 `—`。 +- provider watcher 使用有界退避恢复;未变化的 Codex 线程标题索引使用经验证的 + 内存缓存,减少后台工作。 + +已审阅的 Release 草稿为 `v2.3.2`;人工发布工作流会从该 tag 写入包版本。 + ## v2.3.1 新功能 - 精确定价与上下文元数据现已覆盖 **GPT-6 Astra**(`gpt-6-astra`,105 万 @@ -96,8 +111,6 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 - Claude/Codex 共用卡片密度、层级、折叠、焦点、ARIA、窄宽度和深浅主题 token,同时保持各供应商指标含义独立。 - AI 建议与用量优化器共用精确预览和明确发送边界;没有默认/后台 AI 请求,本地证据与反馈保持有界。 -已审阅的 Release 草稿为 `v2.3.1`;人工发布工作流会从该 tag 写入包版本。 - ## 2.3 Codex Beta - Codex 用量记录只会从 `sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl` 中发现;凭据、数据库和未知文件仍明确排除。此外,插件会精确流式读取 `$CODEX_HOME/session_index.jsonl`,仅将 `id` 映射为 `thread_name`,用于真实线程标题。标题中的绝对路径会先脱敏,标题只保留在内存。用量 JSONL 会逐行流式、临时解析,以提取白名单内的用量与结构元数据;提示词、回复、命令和工具参数字段不会被检查或用于分析,也不会被保留或持久化。 diff --git a/README-zh-TW.md b/README-zh-TW.md index 733caff0..b8db1926 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -53,6 +53,21 @@ - **多廠商定價** — Opus 4.x / Sonnet 4.x / Haiku 4.5 對照 Anthropic 官方定價;OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 參考價,含家族感知回退。`Refresh Token Pricing` 可拉取 LiteLLM 即時價格。 - **個人化** — 語言、時區、小數位數、精簡數字、專案分組、儀表板自動刷新開關。 +## v2.3.2 新功能 + +- 設定現收斂為一個幣別下拉選單,預設 USD;另有 13 種常用顯示幣別,採用固定的 + 2026-09-09 ECB 參考匯率快照。匯率不可手動調整、不會連線取得,底層估算仍為 USD。 +- Claude 與 Codex 的全部時間均支援「月 → 日」,最近 30 天均支援已有彙總的 + 「日 → 小時」;滑鼠、鍵盤、焦點、巢狀收合與重新載入還原行為一致,點擊不讀取 JSONL。 +- 一般即時重新整理會保留目前分頁、展開鏈、所選指標/小時、最佳化器草稿、 + 鍵盤焦點與附近捲動位置。 +- 每張圖都有包含 provider 的本地化無障礙名稱。空白小時仍保留座標、表格、提示與 + 可選取的 0 值,但不再在每根空柱上方重複標示 0;真正無法定價的活動仍顯示 `—`。 +- provider watcher 採用有界退避恢復;未變更的 Codex 執行緒標題索引使用經驗證的 + 記憶體快取,減少背景工作。 + +已審閱的 Release 草稿為 `v2.3.2`;人工發佈工作流程會從該 tag 寫入套件版本。 + ## v2.3.1 新功能 - 精確定價與上下文中繼資料現已涵蓋 **GPT-6 Astra**(`gpt-6-astra`,105 萬 @@ -75,8 +90,6 @@ - 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 - Codex 用量記錄只會從 `sessions/**/*.jsonl` 與 `archived_sessions/**/*.jsonl` 中發現;憑證、資料庫與未知檔案仍明確排除。此外,擴充功能會精確串流讀取 `$CODEX_HOME/session_index.jsonl`,僅將 `id` 對應到 `thread_name`,用於真實執行緒標題。標題中的絕對路徑會先去識別化,標題只保留在記憶體。用量 JSONL 會逐行串流並暫時解析,以擷取白名單內的用量與結構中繼資料;提示、回覆、命令和工具參數欄位不會被檢查或用於分析,也不會被保留或持久化。 diff --git a/README.md b/README.md index 5a888726..fd56ab10 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,31 @@ same full-request preview and separate explicit Send action as AI advice. --- +## What's new in v2.3.2 + +- **One currency control** — Settings now offers one curated currency dropdown, + defaulting to USD. Thirteen additional display currencies use a bundled, + fixed 2026-09-09 ECB-derived snapshot; there is no editable multiplier or + exchange-rate network request. Source estimates remain USD and converted + values are visibly approximate. +- **Complete time drill-down** — Claude and Codex All time month bars expand to + days, and populated Last 30 days bars expand to materialized hours. Mouse and + keyboard paths share disclosure state, focus, nested collapse, and reload + restoration, with no JSONL read on click. +- **Refresh without losing your place** — normal live updates preserve the + active tab, expansion chain, selected metric/hour, draft Optimizer text, + keyboard focus, and nearby scroll anchor. +- **Clearer, accessible charts** — every chart has a provider-qualified, + localized accessible name. Empty hours keep their axis, table, tooltip, and + selectable zero value without repeating `0` above every bar; genuinely + unpriced activity still shows `—`. +- **Lower background overhead** — provider watchers recover with bounded + backoff, while unchanged Codex thread-title indexes are reused from a + validated in-memory cache. + +The reviewed draft is `v2.3.2`; the human-controlled publish workflow stamps +package metadata from that release tag. + ## What's new in v2.3.1 - **GPT-6 Astra and Fable 5.1 support** — exact pricing and context metadata now @@ -234,9 +259,6 @@ same full-request preview and separate explicit Send action as AI advice. 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 - **Codex Beta, enabled by default** — usage records are discovered only from 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..ec8bbf0c --- /dev/null +++ b/docs/roadmap/provider-parity-matrix-v2.4.md @@ -0,0 +1,63 @@ +# v2.4 provider parity matrix + +[简体中文](provider-parity-matrix-v2.4.zh-CN.md) · English + +Snapshot: 2026-09-09, local candidate branch `codex/v2.3.2-stabilization`. +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 | +| 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 | +| 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 the installed-VSIX status, quota, and supported drill-down 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..5d3be855 --- /dev/null +++ b/docs/roadmap/provider-parity-matrix-v2.4.zh-CN.md @@ -0,0 +1,59 @@ +# v2.4 Provider 交互一致性矩阵 + +简体中文 · [English](provider-parity-matrix-v2.4.md) + +快照日期:2026-09-09;本地候选分支:`codex/v2.3.2-stabilization`。提交号只是开发 +证据,不代表已发布版本的声明。 + +本矩阵记录交互一致性,但不假装 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、最长语言和桌面溢出 | +| 每周额度等价值详情 | 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 | +| 滚动 | 只记录浏览器滚动位置 | 按 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..fc4bf1a1 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,77 @@ 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 — 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,12 +99,12 @@ 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. diff --git a/docs/roadmap/provider-parity-v2.4.zh-CN.md b/docs/roadmap/provider-parity-v2.4.zh-CN.md index d88fae16..76792b79 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,60 @@ ## 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 稳定分支启动时,本组采用项的状态如下。提交号属于本地候选分支,只有 +经维护者审阅并合入后才成为发布证据。 + +- **已完成 — 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,12 +82,12 @@ - 表格:排序、数值对齐、行展开、滚动容器、空行和窄屏行为一致。 - 折叠项:默认开合状态、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、月→日和日→小时四组隐私安全实图。 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..0eca463a 100644 --- a/src/claudeIncrementalIndex.ts +++ b/src/claudeIncrementalIndex.ts @@ -184,6 +184,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 { @@ -1282,6 +1286,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 +1366,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,6 +1377,7 @@ export function claudeUsageAggregateSnapshot( dailyForMonth, monthlyForAllTime, hourlyForToday, + hourlyForLast30DaysByDay, }; } 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..6ff8c5c7 --- /dev/null +++ b/src/dashboardRefreshClient.ts @@ -0,0 +1,265 @@ +/** + * 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', + '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(); + 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..64d1a6eb 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, @@ -371,8 +401,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 +598,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 +1939,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 +2070,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 +2093,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 +2122,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'), @@ -3046,9 +3091,22 @@ export class ClaudeCodeUsageExtension { 'showWeeklyEquivalentValue', ]); + // 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 +3180,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 +3268,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 +3383,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 +3416,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 +3457,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 +3492,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 +3517,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 +3538,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 +3553,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 +3571,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 +3611,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 +4295,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 +4308,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); } } diff --git a/src/i18n.ts b/src/i18n.ts index 4c9d4e00..e3446e21 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 { @@ -601,6 +606,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 +650,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 +694,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 +738,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 +782,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 +826,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 +870,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', @@ -3573,6 +3585,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 +4084,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/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..1f97bfff 100644 --- a/src/providers/codex/codexUsage.ts +++ b/src/providers/codex/codexUsage.ts @@ -189,6 +189,9 @@ export interface CodexUsageView { last30Days: CodexUsageScopeView; allTime: CodexUsageScopeView; projects: CodexProjectUsageView[]; + /** 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 +618,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 { @@ -1085,7 +1087,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 +1181,7 @@ export function buildCodexUsageView( right.lastActiveAt - left.lastActiveAt || left.projectKey.localeCompare(right.projectKey), ), + 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..3d802b49 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', @@ -873,7 +897,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 +1089,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 +1106,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 +1221,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..14e0a3d4 100644 --- a/src/test/claudeIncrementalIndex.test.ts +++ b/src/test/claudeIncrementalIndex.test.ts @@ -395,6 +395,10 @@ 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.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..b7a64989 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'); 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..59c60abc 100644 --- a/src/test/dashboardOnlySettings.test.ts +++ b/src/test/dashboardOnlySettings.test.ts @@ -18,3 +18,27 @@ test('weekly equivalent panel toggle refreshes dashboard UI without rebuilding p /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/providerSelection.test.ts b/src/test/providerSelection.test.ts index d15b4fff..d801c35f 100644 --- a/src/test/providerSelection.test.ts +++ b/src/test/providerSelection.test.ts @@ -53,6 +53,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 +61,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,10 +104,13 @@ 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]; @@ -132,6 +137,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 +198,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 +302,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 +494,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/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..6c9398ed 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,8 @@ import { } from './providers/codex/codexUsage'; import { createCodexLocalizedFormatters } from './codexFormat'; import { getProviderNavClientScript } from './providerNavClient'; +import { getDashboardRefreshClientScript } from './dashboardRefreshClient'; +import { getChartAccessibilityClientScript } from './chartAccessibilityClient'; import * as os from 'os'; import * as path from 'path'; import * as https from 'https'; @@ -120,7 +123,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 +151,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; @@ -604,13 +727,23 @@ function combinedHeatmapUiCopy(locale: string): CombinedHeatmapUiCopy { 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 +755,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; @@ -1248,6 +1380,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 +1735,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 +1790,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 +2223,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 +2262,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 +2275,20 @@ export class UsageWebviewProvider { contentAnalysis: ContentAnalysis | null = null, branchBreakdown: BranchUsage[] = [], workflowBreakdown: WorkflowUsage[] = [], - costliestMessages: CostlyMessage[] = [] + costliestMessages: CostlyMessage[] = [], + hourlyDataForRolling30DaysByDay: Record< + string, + { hour: string; data: UsageData }[] + > = {}, ): 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; @@ -2139,7 +2302,7 @@ export class UsageWebviewProvider { this.workflowBreakdown = workflowBreakdown; this.costliestMessages = costliestMessages; this.providerAvailability.claude = Boolean( - sessionData || todayData || monthData || allTimeData, + sessionData || todayData || rolling30DayData || allTimeData, ); if (this.panel) { @@ -2306,13 +2469,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 +2591,7 @@ export class UsageWebviewProvider { if ( !this.currentSessionData && !this.todayData && - !this.monthData && + !this.rolling30DayData && !this.providerAvailability.codex ) { return this.getNoDataContent(); @@ -2658,7 +2916,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 +2926,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 +3004,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 +3022,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 +3031,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 + `