From e2003b96d02781d939c8d5db223011a71b96e19b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ufuk=20Tanr=C4=B1verdi?= <60823118+UfukTanriverdi8@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:39:31 +0300 Subject: [PATCH 01/12] fix: recognize Claude Sonnet 5 as a 1M-context model (#50) Co-authored-by: UfukTanriverdi8 --- CHANGELOG.md | 8 ++++++++ src/dataLoader.ts | 20 +++++++++++++++----- src/test/dataLoader.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 src/test/dataLoader.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 58281aef..3545a085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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). +## [Unreleased] + +### Fixed +- **Sonnet 5 context window** — `contextWindowFor()` only recognised the 1M + window via a "4.6+" pattern (e.g. `sonnet-4-6`), so `claude-sonnet-5` — which + has no `-4-` segment — fell through to the 200K legacy default. The dashboard + and status-bar context bar now correctly show a 1M window for Sonnet 5. + ## [2.1.1] — Unreleased ### Added diff --git a/src/dataLoader.ts b/src/dataLoader.ts index 4cc4be66..89c239a9 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -1202,11 +1202,15 @@ export class ClaudeDataLoader { } /** Model context-window size in tokens, plus whether it's a guess. Current - * Claude (Opus 4.6+, Sonnet 4.6+, Fable/Mythos 5) is 1M; Haiku and older - * Claude are 200K; a "[1m]" suffix forces 1M (the marker pricing.ts strips). - * A user override (>0) wins outright and is treated as exact. Unrecognised / - * proxied models fall back to 200K and are flagged `estimated` so the UI can - * mark the percentage as approximate. Verified vs the catalog 2026-06-13. */ + * Claude (Opus 4.6+, Sonnet 4.6+, Sonnet 5+, Fable/Mythos 5) is 1M; Haiku + * and older Claude are 200K; a "[1m]" suffix forces 1M (the marker + * pricing.ts strips). A user override (>0) wins outright and is treated as + * exact. Unrecognised / proxied models fall back to 200K and are flagged + * `estimated` so the UI can mark the percentage as approximate. + * Sonnet 5 (`claude-sonnet-5`) verified 2026-07-01 — + * https://platform.claude.com/docs/en/about-claude/models/whats-new-sonnet-5 + * ("1M tokens is both the default and the maximum; there is no smaller + * context variant"). */ private static contextWindowFor( model: string, override: number = 0 @@ -1225,6 +1229,12 @@ export class ClaudeDataLoader { if (/opus-4-(?:[6-9]|\d\d)\b/.test(m) || /sonnet-4-(?:[6-9]|\d\d)\b/.test(m)) { return { tokens: 1_000_000, estimated: false }; } + // Sonnet 5.x+ (e.g. "claude-sonnet-5") has no "-4-" segment to match + // above, so check the major version directly. (Opus 5 doesn't exist yet — + // don't guess its window size here.) + if (/sonnet-(?:[5-9]|\d\d)(?:-|\b)/.test(m)) { + return { tokens: 1_000_000, estimated: false }; + } if (/haiku/.test(m) || /opus|sonnet/.test(m)) { return { tokens: 200_000, estimated: false }; } diff --git a/src/test/dataLoader.test.ts b/src/test/dataLoader.test.ts new file mode 100644 index 00000000..b56992f8 --- /dev/null +++ b/src/test/dataLoader.test.ts @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { ClaudeDataLoader } from '../dataLoader'; +import { ClaudeUsageRecord } from '../types'; + +function record(model: string): ClaudeUsageRecord { + return { + timestamp: new Date().toISOString(), + message: { + model, + usage: { input_tokens: 1_000, output_tokens: 10 }, + }, + }; +} + +test('getCurrentContextInfo reports a 1M window for Sonnet 5', () => { + const info = ClaudeDataLoader.getCurrentContextInfo([record('claude-sonnet-5')]); + assert.ok(info, 'expected context info, got null'); + assert.equal(info!.windowTokens, 1_000_000); + assert.equal(info!.estimated, false); +}); From 0fa6d0e049cf6cd4602938445eaeaece8be34cb7 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:47:30 +0800 Subject: [PATCH 02/12] ci: autolabel PRs via release-drafter (pull_request_target) (#53) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autolabeler config in .github/release-drafter.yml was ready but never fired — the workflow only triggered on push-to-main, so PRs were never labelled and the maintainer had to set the version label by hand. Adds a pull_request_target trigger + pull-requests: write so title/branch-based labels are applied automatically (works on fork PRs too; the autolabeler reads the title/branch only, never runs PR code). Co-authored-by: Claude --- .github/workflows/release-drafter.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 37204378..a7e362a1 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -6,19 +6,24 @@ # maintainer reviews the draft and clicks "Publish release" when ready, which # creates the tag and triggers publish.yml. # -# Why push-to-main only (no pull_request trigger): Release Drafter reads its -# config from the *default* branch, and GitHub forces the default GITHUB_TOKEN -# read-only for PRs opened from forks — so a pull_request autolabeler couldn't -# label external contributions anyway. Maintainers set the version label when -# they merge (the PR template shows the mapping; unlabeled defaults to patch). -# To enable branch/title autolabeling for same-repo PRs later, add a -# `pull_request_target` trigger plus `pull-requests: write`. +# On PRs it also autolabels (title/branch -> version label; config in +# .github/release-drafter.yml) so the draft's version + categories are right +# without a manual label. It runs on `pull_request_target` (not `pull_request`) +# so the token can write labels even on fork PRs — the autolabeler only reads the +# PR title/branch, it never checks out or runs the PR's code. A maintainer can +# still override the label before merge (unlabeled defaults to patch). name: Release Drafter on: push: branches: [main] + # Autolabel PRs (title/branch -> version label) so the draft's next version and + # categories are right without a manual label. `pull_request_target` so the + # token has write access even for fork PRs; the autolabeler only reads the PR + # title/branch and applies labels — it never checks out or runs the PR's code. + pull_request_target: + types: [opened, reopened, synchronize] permissions: contents: read @@ -26,7 +31,8 @@ permissions: jobs: update_release_draft: permissions: - contents: write # create / update the draft release + contents: write # create / update the draft release + pull-requests: write # autolabeler applies labels to PRs runs-on: ubuntu-latest steps: - uses: release-drafter/release-drafter@6a93d829887aa2e0748befe2e808c66c0ec6e4c7 # v6 From d16061274af980cc266e8ea2aadf8268abd5067b Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:49:37 +0800 Subject: [PATCH 03/12] ci: enable @ccu-bot first-pass automation (issues + PRs), inert until switched on (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds only the automation-enablement slice so the bot can be tested without merging the rest of V2.2: - `.github/workflows/claude.yml` — maintainer `@ccu-bot` mention agent. - `.github/workflows/issue-first-pass.yml` — one first-pass comment per new issue. - `.github/workflows/pr-first-pass.yml` — one first-pass review per new PR (base checkout only; reads the diff via API, never runs PR code). - Publishes the technical `ARCHITECTURE.md` (+ zh-CN) so the bot has a real map. - CONTRIBUTING: a section describing @ccu-bot and its limits. SAFE BY DEFAULT: every job's `if:` requires the repo variable AUTOMATION_ENABLED == 'true' AND the ANTHROPIC_API_KEY secret; official or a third-party Anthropic-format endpoint via ANTHROPIC_BASE_URL. First-pass replies are labelled automated + disclaimed; a maintainer reviews everything. Co-authored-by: Claude --- .github/workflows/claude.yml | 78 ++++++++++ .github/workflows/issue-first-pass.yml | 68 +++++++++ .github/workflows/pr-first-pass.yml | 66 +++++++++ .gitignore | 12 +- ARCHITECTURE-zh-CN.md | 134 +++++++++++++++++ ARCHITECTURE.md | 190 +++++++++++++++++++++++++ CONTRIBUTING.md | 24 ++++ 7 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/claude.yml create mode 100644 .github/workflows/issue-first-pass.yml create mode 100644 .github/workflows/pr-first-pass.yml create mode 100644 ARCHITECTURE-zh-CN.md create mode 100644 ARCHITECTURE.md diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..5a68736f --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,78 @@ +# Mention-driven assistant (V2.2 automation — Track 2, A1). +# +# Lets a MAINTAINER summon the assistant by writing "@ccu-bot ..." in an issue, an issue +# comment, or a PR review/comment. Claude reads the repo (incl. CLAUDE.md / +# ARCHITECTURE.md / CONTRIBUTING.md) and can open commits/PRs or reply. +# +# SAFE BY DEFAULT — this job is INERT until the owner opts in: +# 1. Repo variable AUTOMATION_ENABLED = 'true' (Settings → Variables) — kill switch. +# 2. Repo secret ANTHROPIC_API_KEY (Settings → Secrets). +# With neither set, the `if:` gate is false and nothing runs / no budget is spent. +# +# THIRD-PARTY / NON-OFFICIAL MODELS: a Claude subscription (OAuth) can't be used +# in CI — Actions needs a key. If you don't have an official Anthropic key, point +# this at any Anthropic-format-compatible endpoint: set repo variable +# ANTHROPIC_BASE_URL to that provider's base URL and store its key as the +# ANTHROPIC_API_KEY secret (see the env: on the step below). Leave the variable +# unset to use the official API. +# +# It also only responds to OWNER / MEMBER / COLLABORATOR authors, so a random +# visitor can't spend the API budget by typing "@ccu-bot". Every other automation +# piece (auto-triage A2, PR first-pass A3) is separate and still drafts-only. +name: CCU Bot (mention) + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + pull_request_review: + types: [submitted] + issues: + types: [opened, assigned] + +# Least privilege: enough to comment / open a PR branch, nothing more. +permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read + +concurrency: + group: claude-mention-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + claude: + # Kill switch + "@ccu-bot" present + author is a maintainer. + if: | + vars.AUTOMATION_ENABLED == 'true' && ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@ccu-bot') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@ccu-bot') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@ccu-bot') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.review.author_association)) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@ccu-bot') || contains(github.event.issue.title, '@ccu-bot')) && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)) + ) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: Run Claude Code + uses: anthropics/claude-code-action@v1 + env: + # Empty by default → official API. Set the repo variable to use a + # third-party Anthropic-compatible endpoint (see header). + ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Cap the agent loop so a single mention can't run away with the budget. + # Pin a model here if you want (e.g. --model claude-sonnet-4-5, or a + # third-party model id); the action otherwise picks a sensible default. + # Repo guidance (architecture, compile-clean, 7-language i18n, opt-in + # defaults) is read from CLAUDE.md / ARCHITECTURE.md — so it doesn't + # re-read the whole repo every time. + claude_args: "--max-turns 12" diff --git a/.github/workflows/issue-first-pass.yml b/.github/workflows/issue-first-pass.yml new file mode 100644 index 00000000..580aef35 --- /dev/null +++ b/.github/workflows/issue-first-pass.yml @@ -0,0 +1,68 @@ +# Auto first-pass reply on every NEW issue (V2.2 automation — Carl's opt-in). +# +# When an issue is opened, the assistant reads the project's architecture docs +# and posts ONE helpful first-pass comment: what it understands, where in the +# code/architecture it points, and either a suggested direction or a clarifying +# question. Every comment is clearly labelled automated and ends with a +# disclaimer that a maintainer follows up. +# +# SAFE BY DEFAULT — inert until the owner sets: +# 1. Repo variable AUTOMATION_ENABLED = 'true' (kill switch) +# 2. Repo secret ANTHROPIC_API_KEY (official or third-party key) +# Optional: repo variable ANTHROPIC_BASE_URL for a third-party Anthropic-format +# endpoint. Bot-authored issues are skipped (no loops). +name: Issue first-pass (auto) + +on: + issues: + types: [opened] + +permissions: + contents: read # read repo files to answer accurately (never guess) + issues: write # post the reply + +concurrency: + group: issue-first-pass-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + reply: + if: vars.AUTOMATION_ENABLED == 'true' && github.event.issue.user.type != 'Bot' + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: First-pass reply + uses: anthropics/claude-code-action@v1 + env: + ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--max-turns 15" + prompt: | + You are the ClaudeCodeUsage repository assistant, running via Claude Code + (the model may be a third-party Anthropic-format model, so do not claim to + be Anthropic's Claude specifically). + + A new issue was just opened (title/body in the event context). Post ONE + comment as a first-pass response. Rules: + - First read CLAUDE.md and CONTRIBUTING.md (and ARCHITECTURE.md if present) + for context. + - If those docs are NOT enough to answer accurately, READ the relevant + repository files to find the real answer. Never guess or invent an + answer — if you still can't determine it, say what's unclear and ask a + specific clarifying question instead of speculating. + - Be concrete and concise: what you understand the request to be, where in + the code/architecture it relates, and a suggested direction OR the + clarifying question needed. No filler, no AI-flavoured padding. + - Reply in the same language the issue author used. + - START the comment with: "🤖 Automated first-pass reply (via Claude Code)". + - END the comment with this disclaimer on its own line: + "_This is model-generated from the repository's architecture docs and + the issue description — not a final decision. A maintainer will review + and follow up._" + - Do not change labels, close the issue, or open PRs. Just the one comment. diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml new file mode 100644 index 00000000..b9753181 --- /dev/null +++ b/.github/workflows/pr-first-pass.yml @@ -0,0 +1,66 @@ +# Auto first-pass review on every NEW pull request (V2.2 automation — opt-in). +# +# Uses pull_request_target so it has the token to comment, but it must NEVER run +# the PR's code with that token. We check out the BASE repo (the default for +# pull_request_target), read the PR diff via the API, and post ONE first-pass +# review comment. Fork code is never executed here. +# +# SAFE BY DEFAULT — inert until the owner sets AUTOMATION_ENABLED='true' and the +# ANTHROPIC_API_KEY secret (official or third-party). Bot-authored PRs skipped. +name: PR first-pass (auto) + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: pr-first-pass-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + review: + # Skip bots and huge diffs (a > ~3000-line PR wants a human first). + if: vars.AUTOMATION_ENABLED == 'true' && github.event.pull_request.user.type != 'Bot' + runs-on: ubuntu-latest + timeout-minutes: 12 + steps: + # Base repo only — do NOT check out or run the PR head with the token. + - name: Checkout base repository + uses: actions/checkout@v6 + with: + fetch-depth: 1 + + - name: First-pass review + uses: anthropics/claude-code-action@v1 + env: + ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_args: "--max-turns 15" + prompt: | + You are the ClaudeCodeUsage repository assistant, running via Claude Code + (the model may be a third-party Anthropic-format model; do not claim to be + Anthropic's Claude specifically). + + A new pull request was opened. Do a first-pass review and post ONE comment. + SECURITY: never execute the PR's code. Read the PR DIFF via the GitHub API + and the repository's own files only. + Rules: + - First read CLAUDE.md and CONTRIBUTING.md (and ARCHITECTURE.md if present) + for the + conventions (compile-clean, node:test green, 7-language i18n, opt-in + defaults, CHANGELOG). If those aren't enough to judge accurately, read + the relevant base-repo files — never guess. + - Cover: correctness risks, convention/i18n/CHANGELOG gaps, and + merge-readiness. Be specific and concise; skip praise padding. + - If the diff is very large (> ~3000 lines) or you're unsure, say a human + should review rather than speculate. + - START with: "🤖 Automated first-pass review (via Claude Code)". + - END with: "_This is model-generated from the architecture docs and the + PR diff — not a merge decision. A maintainer reviews everything._" + - Do not approve, request changes as a formal review, merge, or push. One + comment only. diff --git a/.gitignore b/.gitignore index 1cc7518a..6d3cf5f4 100644 --- a/.gitignore +++ b/.gitignore @@ -18,10 +18,10 @@ CLAUDE.local.md # samples. Curated excerpts may be hand-copied into the README, but the raw # reports must never be committed. -# Local-only planning / architecture / dev-reference docs. The whole docs/ -# tree is the maintainer's private context pack — synced to a personal repo, -# never shipped to the public repo. README / CHANGELOG live at the repo root. -# (Re-track a specific doc later with `!docs/` if it should become public.) +# Local-only planning / dev-reference docs. The whole docs/ tree is the +# maintainer's private context pack (product direction lives in +# docs/ARCHITECTURE-private.md) — synced to a personal repo, never public. +# README / CHANGELOG / ARCHITECTURE live at the repo root and ARE public: +# ARCHITECTURE.md is the technical map the @ccu-bot automation + contributors +# read (product strategy is kept out of it, in docs/ARCHITECTURE-private.md). docs/ -/ARCHITECTURE.md -/ARCHITECTURE-zh-CN.md diff --git a/ARCHITECTURE-zh-CN.md b/ARCHITECTURE-zh-CN.md new file mode 100644 index 00000000..cf5ad903 --- /dev/null +++ b/ARCHITECTURE-zh-CN.md @@ -0,0 +1,134 @@ +# 架构说明 + +> 本扩展的简明技术地图——它是什么、数据如何流动、以及那些"精确的" token / 成本 +> 数字是怎么算出来的。刻意写得简短:它是给贡献者(或自动化的 issue/PR 助手) +> 读的参考,让他们**不必**重新通读 `src/`。如果你改变了某个模块的职责或数据 +> 流,请同步更新本文件。英文版见 +> [`ARCHITECTURE.md`](ARCHITECTURE.md)。 + +## 它是什么(以及不是什么) + +**Claude Code Usage** 是一个 VS Code 扩展,通过读取 Claude Code 自己的本地日志, +在状态栏和仪表盘 webview 中报告 Claude Code 的 token 用量与成本。它刻意保持: + +- **只服务 Claude**——围绕 Claude Code 的日志格式和 Anthropic 的 OAuth 额度构建。 + (为方便 CC-Switch 用户,也给 DeepSeek 等做了定价,但 Claude 是核心。) +- **轻量**——界面精简、无运行时依赖、次要视图默认折叠。 +- **聚焦 token 归因**——头部的核心数字是**精确的**,直接读自每次请求自带的用量 + 记账,而非估算。 +- 对 `~/.claude` **只读**——绝不写入 Claude Code 的数据。 + +**范围之外**(用这张清单做 issue 初筛):把多供应商/多厂商仪表盘当作一等功能; +完整的账单/发票对账;写入或驱动 Claude Code;与用量无关的分析。能让 token 洞察、 +归因或建议体验更锋利的需求,才是合适的方向。 + +## 模块地图(`src/`) + +| 模块 | 职责 | +|---|---| +| `extension.ts` | 激活、命令注册,以及刷新编排:一个"感知活跃度"、会自我重排的定时器(你活跃时更快、空闲时更缓),带 generation 守卫与合并去重,外加 `pauseDashboardRefresh`。配置经 `SettingsStore` 读取,启动时跑一次设置迁移,监听 workspace 文件夹切换(重取额度),并持有 `runOptimizer`(用量优化器往返)。把 loader + 状态栏 + webview + 额度客户端串起来。 | +| `dataLoader.ts` | 数据管线。读取 `~/.claude/projects/**/*.jsonl`,解析/校验/去重记录,提取会话标题与用户提示标记,并完成全部聚合(`calculateUsageData` + 今日/本月/会话/项目/分组/分支等口径)。v2.1 还有:`getWorkflowBreakdown`(从 `subagents/` 日志识别多 agent 运行 + 临时批次)、`getUsageAttribution`(用量构成面板)、`getCurrentContextInfo` + `contextWindowFor`(按模型上下文窗口、取主线程记录、`estimated` 标记)、内容/校准分析(`windowDays` 可配)。逻辑量最大的一块。 | +| `settings.ts` | **(v2.1)** 所有用户设置的单一真源:`SETTINGS` 目录(类型/默认/存储/分组)+ `SettingsStore`。核心三项(`language`、`dataDirectory`、`advice.apiKey`)留 VS Code 配置;其余进 `globalState`,由 dashboard 的 ⚙ 设置标签页编辑。`migrateOnce()` 把 2.1 前 `settings.json` 的值搬进存储。 | +| `pricing.ts` | 按模型、按 token 的费率表(input / output / 缓存写 / 缓存读分别计价),模型家族推断,以及 `[1m]` 上下文后缀的剥离。`calculateCostBreakdown` 把一条用量记录变成四部分成本。 | +| `statusBar.ts` | 三个状态栏条目:主条目(今日成本**或** token 量,`statusBarMetric`;三项全隐藏时退化为只剩图标的入口)、额度条目(5h / 每周,可选 `opus:NN%`)、实验性上下文窗口指示器(条形 + 明细 tooltip,窗口为猜测时标 `~`)。 | +| `webview.ts` | 仪表盘:分页视图(今日、会话、项目、内容、分支、工作流、**⚙ 设置**)、AI 建议 + 用量优化器操作卡、图表与可排序表格。跨重渲染保留优化器状态,并跳过完全相同的重渲染。体量最大的文件——几乎所有 UI 都在这里。 | +| `claudeApiClient.ts` | Anthropic OAuth 额度:读取 `~/.claude/.credentials.json`,刷新 token(先重读磁盘),从 `api.anthropic.com/api/oauth/usage` 拉取真实用量。走 `httpClient`(fetch → curl);HTTP 429 冷却 60 秒。 | +| `httpClient.ts` | **(v2.1)** 共享传输层:`requestViaFetch` 与 `requestViaCurl`(curl 的 `cwd` 钉到 home,避免切 workspace 后陈旧 cwd 触发 ENOENT)。curl 是 Anthropic TLS 指纹门 `403 "Request not allowed"` 的兜底。 | +| `advisor.ts` | AI 建议 + **用量优化器**传输层:`callModel`(Anthropic `/v1/messages` 或 OpenAI chat-completions,带 curl 403 兜底)、`getUsageAdvice`,以及优化器纯函数 `buildOptimizerSystemPrompt` / `parseOptimizerOutput`。只支持 API(订阅分支留休眠)。需手动开启。 | +| `adviceSummary.ts` | **(v2.1)** 构建发给建议模型的用量摘要——聚合 + 多 agent 运行 + 思考占比 + 归因 + 一段按窗口取的用户自己的 prompt 样本。 | +| `adviceDemoSample.ts` | 在配置 key 之前展示的静态示例建议(六种语言全覆盖),让功能可被发现。 | +| `i18n.ts` | 六种语言的字符串表(`en`、`de-DE`、`zh-TW`、`zh-CN`、`ja`、`ko`)+ `SETTINGS_I18N`(⚙ 设置面板每项的标签/帮助)与 `settingText()`。所有面向用户的字符串都经过这里。 | +| `types.ts` | 共享接口(`ClaudeUsageRecord`、`UsageData`、`SessionUsage`、`SupportedLanguage`、`ExtensionConfig`、`ContextWindowInfo`、额度 + 归因 + 工作流类型……)。 | + +## 数据流 + +``` +~/.claude/projects/<编码后的cwd>/<会话>.jsonl (一个文件 = 一段对话) + │ 逐行读取,对每行 JSON.parse + ▼ +校验(是用量记录吗?)─► 去重(messageId+requestId,保留 token 更高的那条) + │ 给每条记录打上 会话 id + 项目(优先用真实 cwd) + ▼ +ClaudeUsageRecord[] ──► calculateUsageData() ──► UsageData + │ (对 4 个 token 桶求和并各自计价) │ + │ ├─► 状态栏(今日 + 额度) + └─ 会话标题、用户提示标记 └─► webview(各口径明细 + 图表) +``` + +额度是一条**独立**路径:`claudeApiClient` 调用 OAuth 用量接口,独立于本地日志地 +返回 5 小时 / 7 天 / 7 天 Opus 的用量占比(日志无从得知你套餐的上限,只有 +Anthropic 知道)。 + +## token 与成本如何计算(精确,而非估算) + +JSONL 里每一行助手回复都带一个 `message.usage` 对象——这是 **Anthropic API 自己 +的 token 记账**,与 Anthropic 计费所依据的数字相同。扩展不去估算它们,而是读取、 +校验、去重、求和、计价: + +1. **校验**——只保留 `usage.input_tokens` 为数字的记录(真实 API 响应);跳过 + 合成 / 报错 / `` 模型条目。 +2. **去重**——哈希 = `messageId + requestId`;冲突时保留 token 更高的那条(应对 + 某些代理会先记一行占位、再记真实数值的情况)。 +3. **求和**,把四个桶累加进总数: + - `input_tokens`——新鲜的 prompt,全价 + - `cache_read_input_tokens`——从缓存命中的前缀,约为 input 价的 10% + - `cache_creation_input_tokens`——**写入**缓存的前缀(即"输入缓存(未命中)" + 那根条),约为 input 价的 125%;在切换模型或间隔 >5 分钟后会飙升(prompt + 缓存按模型隔离,TTL 约 5 分钟) + - `output_tokens`——生成内容,output 价 +4. **计价**——每个桶 × 其按模型的费率(`pricing.ts`);成本 = 求和。 + +产品中**唯一**的估算,是"内容"标签页里基于字符数的"什么在吃 token"拆解(以及计划 +中的 v2.2 模型匹配 / 缓存浪费功能)——它们始终标注为估算,绝不并入精确总数。 +"消息数"只统计用户手敲的 prompt,靠合成的零 token 标记记录实现,因此永不影响 token +求和。 + +## 模型核心功能的日志格式事实(2026-06-13 在磁盘上验证) + +JSONL 日志里*有*和*没有*什么——任何要推理运行、模型、effort 的功能的参考。当 Claude +Code 格式漂移时,用 JSON 键探查重新验证(绝不要用子串 grep——见下方的坑)。 + +- **运行配置(effort / thinking 预算)不被记录。** 任何用量行上都没有 `effort` / + `ultracode` / `maxThinking` / 推理预算字段。唯一的 mode 记录是一条 `type:"mode"` 行, + 带 `permissionMode`(如 `"normal"`)。**我们无法得知一次运行用的是什么 effort 等级。** + ⇒ 唯一可靠的"动态工作流已启用"信号是 **`subagents/workflows/wf_/` 目录的存在**, + 而非 effort。普通 Task 工具扇出(无 `wf_` 目录)是"临时批次",不是工作流。 + - **坑:** 子串 grep `effort`/`ultracode` *看起来*命中了——但只在 prompt 正文和工具调用 + 日志里(包括本插件自己的命令一旦被记录)。解析 JSON 键;绝不为配置去 grep。 +- **skill / 插件归因是一等的。** 助手用量行带 `attributionSkill`(如 + `"superpowers:executing-plans"`)和 `attributionPlugin`(如 `"superpowers"`)。它们是 + 权威的——优先用它们而非旧的 `` / `Skill` tool_use 启发式,并用 skill/插件 + 自身行的*精确* `message.usage` 来加权。 +- **为什么原生 Claude 的"工作流"在工作流 tab 里看起来缺失。** Claude 子代理日志*确实* + 存在(某些 `subagents/` 目录下有 haiku/opus)。但当原生 Claude 运行用 ultracode 时, + 昂贵的 **Opus/Fable 编排留在主会话日志里**,只有便宜的 **haiku** 子代理(或没有)写 + `agent-*.jsonl`。一个重度 Fable/Opus 的主线程可能**根本没有 `subagents/` 目录**。工作流 + tab 按*子代理文件*分组,所以它显示便宜模型、漏掉主线程成本——这正是 v2.1-PartII 要修的 + (关联运行的编排主会话并显示其成本/模型)。**通则:** *昂贵*模型通常是主线程编排者; + 子代理文件偏便宜。永远不要只凭子代理文件推断"我主要用哪个模型"。 +- **较新的字段(Claude Code ≥ 2.1)** 值得知道:顶层 `entrypoint`(如 `claude-vscode`)、 + `permissionMode`、`version`、`context_management`;行 `type`:`mode`、`last-prompt` + (逐字的最后一条用户 prompt)、`queue-operation`、`file-history-snapshot`、`attachment`、 + `system`(hook 摘要);`message.usage` 上:`service_tier`、`iterations`、`speed`、 + `inference_geo`、`cache_creation`(对象)。总量(`input/output/cache_*`)仍然精确。 + +## 关键不变量(别破坏它们) + +- 对 `~/.claude` **只读**(唯一的写是 `claudeApiClient` 刷新 `advice.apiKey` 的 token;绝不动用户日志)。 +- 所有面向用户的字符串都要走 `i18n.ts`、**六种语言齐全**;设置面板的标签/帮助走 `SETTINGS_I18N`(英文回退到目录)。 +- **设置统一走 `SettingsStore`,不要散读。** 只有核心三项(`language`、`dataDirectory`、`advice.apiKey`)声明在 `package.json` / VS Code 配置;其余在 `globalState`,必须经 store 读(`config.get` 找不到)。加一个设置 = `settings.ts` 目录里一条 +(其 `SETTINGS_I18N` 行)。 +- 新设置**默认维持现有行为**(opt-in);实验性/近似功能默认关闭(上下文指示器、用量优化器)。 +- **不新增运行时依赖。** 对外调用(额度、建议/优化器)走 `httpClient` 的 fetch→curl 403 兜底;spawn `curl` 时 `cwd: os.homedir()`。 +- 精确总数与标注过的估算,永不混在一起。 +- 建议/优化器**只**发送用量摘要 / 粘贴的草稿——绝不发送用户的文件。 + +## 刷新模型 + +仪表盘 / 状态栏靠 `extension.ts` 里一个感知活跃度的循环保持最新:一个自我重排的 +定时器,你活跃时间隔缩短、空闲时拉长,由 `refreshGen` generation 计数器与合并去重 +守护,避免重叠刷新堆叠。对 `~/.claude/projects` 的文件监听(可关闭)带来约 1.5 秒 +延迟;定时器是兜底。面板打开查看时,`pauseDashboardRefresh` 会冻结更新。 + +## 发布 + +TypeScript strict、干净编译(`node ./node_modules/typescript/bin/tsc -p ./`——F5/调试任务用它而非 `npm`,绕开 Windows 的 `npm.ps1` 执行策略拦截)、`node:test` 全绿。仓库在组织 **`ClaudeCodeUsage/ClaudeCodeUsage`**,`main` 有分支保护(PR + `test` 检查)。流程(Release Drafter,v2.0.3 搭好):贡献者提 PR;维护者带版本标签(`patch`/`minor`/`major`,默认 patch)**squash-merge**——这就是每个 PR 的全部操作;草稿 Release 自动攒好分类发布说明;点 **Publish** 打 `v*` 标签,`publish.yml` 盖好 `package.json`、用 `@vscode/vsce` 打包,发到 VS Code Marketplace + Open VSX 并附 `.vsix`。跨 fork PR 的署名由 squash-merge 保住;个别手动重落用 `git merge -s ours` 把贡献者提交留作已合并祖先。关 issue 的 PR 用 `Closes #N`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..a211da3a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,190 @@ +# Architecture + +> A concise technical map of this extension — what it is, how data flows, and +> how the exact token/cost numbers are produced. Kept deliberately short: it is +> the reference a contributor (or an automated issue/PR helper) reads *instead +> of* re-reading `src/`. If you change a module's role or the data flow, update +> this file. A Simplified-Chinese version lives in +> [`ARCHITECTURE-zh-CN.md`](ARCHITECTURE-zh-CN.md). + +## What it is (and isn't) + +**Claude Code Usage** is a VS Code extension that reports Claude Code token +usage and cost — in the status bar and a dashboard webview — by reading Claude +Code's own local logs. It is deliberately: + +- **Claude-only** — built around Claude Code's log format and Anthropic's OAuth + quota. (DeepSeek etc. are priced as a courtesy for CC-Switch users, but Claude + is the focus.) +- **Lightweight** — minimal UI, no runtime dependencies, secondary views + collapsed by default. +- **Token-attribution focused** — the headline numbers are *exact*, read from + each request's own usage accounting, not estimated. +- **Read-only** over `~/.claude` — it never writes to Claude Code's data. + +**Out of scope** (use this list to triage requests): multi-provider/vendor +dashboards as a first-class feature; full billing/invoice reconciliation; +writing to or driving Claude Code; non-usage analytics. Requests that sharpen +token insight, attribution, or the advice experience are the right fit. + +## Module map (`src/`) + +| Module | Role | +|---|---| +| `extension.ts` | Activation, command registration, and the refresh orchestration: an activity-aware, self-rescheduling timer (faster while you're active, calmer when idle) with a generation guard and coalescing, plus `pauseDashboardRefresh`. Reads config through `SettingsStore`, runs the settings migration once, listens for workspace-folder changes (re-fetches quota), and owns `runOptimizer` (the Usage Optimizer round-trip). Wires loader + status bar + webview + quota client together. | +| `dataLoader.ts` | The data pipeline. Reads `~/.claude/projects/**/*.jsonl`, parses/validates/dedups records, harvests session titles and user-prompt markers, and aggregates everything (`calculateUsageData` + today/month/session/project/group/branch breakdowns). Also v2.1: `getWorkflowBreakdown` (multi-agent runs from `subagents/` logs + ad-hoc batches), `getUsageAttribution` (the "what's contributing" panel), `getCurrentContextInfo` + `contextWindowFor` (per-model context window, main-thread record, `estimated` flag), and the content/calibration analysis (`windowDays` configurable). The largest piece of logic. | +| `settings.ts` | **(v2.1)** Single source of truth for every user setting: the `SETTINGS` catalog (type/default/storage/group) + `SettingsStore`. A small core (`language`, `dataDirectory`, `advice.apiKey`) lives in VS Code config; the rest in `globalState`, edited from the dashboard's ⚙ Settings tab. `migrateOnce()` copies pre-2.1 `settings.json` values into the store. | +| `pricing.ts` | Per-model, per-token rate table (separate input / output / cache-write / cache-read rates), model-family inference, and `[1m]`-context-suffix stripping. `calculateCostBreakdown` turns a usage record into a four-part cost. | +| `statusBar.ts` | Three status-bar items: the primary (today's cost **or** token count, `statusBarMetric`; icon-only entry fallback when all three are hidden), the quota item (5h / weekly, opt-in `opus:NN%`), and the experimental context-window indicator (bar + breakdown tooltip, `~` for guessed windows). | +| `webview.ts` | The dashboard: tabbed views (today, sessions, projects, content, branches, workflows, **⚙ settings**), the AI-advice + Usage-Optimizer action cards, charts and sortable tables. Holds optimizer state across re-renders and skips identical re-renders. By far the biggest file — almost all UI lives here. | +| `claudeApiClient.ts` | Anthropic OAuth quota: reads `~/.claude/.credentials.json`, refreshes the token (re-reading disk first), and fetches real utilisation from `api.anthropic.com/api/oauth/usage`. Uses `httpClient` (fetch → curl). 60s cool-down on HTTP 429. | +| `httpClient.ts` | **(v2.1)** Shared transport: `requestViaFetch` and `requestViaCurl` (curl `cwd` pinned to home so a stale workspace cwd can't ENOENT it). curl is the fallback for Anthropic's TLS-fingerprint `403 "Request not allowed"` gate. | +| `advisor.ts` | The AI-advice + **Usage Optimizer** transport: `callModel` (Anthropic `/v1/messages` or OpenAI chat-completions, with the curl 403 fallback), `getUsageAdvice`, and the optimizer's pure helpers `buildOptimizerSystemPrompt` / `parseOptimizerOutput`. API-only (a dormant subscription branch remains). Opt-in. | +| `adviceSummary.ts` | **(v2.1)** Builds the usage digest sent to the advice model — aggregates + multi-agent runs + thinking share + attribution + a windowed sample of the user's own prompts. | +| `adviceDemoSample.ts` | Static sample advice (all seven languages) shown before a key is configured, so the feature is discoverable. | +| `i18n.ts` | The string table for seven languages (`en`, `de-DE`, `zh-TW`, `zh-CN`, `ja`, `ko`, `pt-BR`) + `SETTINGS_I18N` (per-setting label/help for the ⚙ Settings panel) and `settingText()`. Every user-facing string goes through here. | +| `types.ts` | Shared interfaces (`ClaudeUsageRecord`, `UsageData`, `SessionUsage`, `CostlyMessage`, `SupportedLanguage`, `ExtensionConfig`, `ContextWindowInfo`, quota + attribution + workflow types, …). | + +### V2.2 additions (pure, unit-tested modules) +| Module | Role | +|---|---| +| `shareCard.ts` | Pure share-card logic: `buildShareCardData` (privacy redaction — the type carries no prompt/path/id field), `ShareSections`/`DEFAULT_SECTIONS`, `selectShareBadge`, `prettyModelName`, `rangeLabel`, `shareCardFilename`. | +| `shareCardSvg.ts` | Self-contained SVG renderer for the share card (no DOM/fonts/network). `SHARE_CARD_THEMES` (claudeCream / claudeClassic / auroraDark) + `resolveShareCardTheme`; `BADGE_COPY`. Landscape 1200×680. | +| `heatmap.ts` / `heatmapSvg.ts` | GitHub-contribution-style token heatmap logic + SVG (`buildContributionGrid`, `renderHeatmapSvg`). Exported/published to a GitHub profile repo (`extension.publishHeatmapToGitHub`, built-in GitHub auth + Contents API). | +| `dateKeys.ts` | Timezone-consistent day/month bucketing (`dayKeyInZone`/`monthKeyInZone`) so dashboard buckets match the configured zone. | +| `promptDedup.ts` | Dedups api_error retry re-logs so message counts aren't inflated. | +| `quotaFormat.ts` | Pure status-bar quota text formatter (kept clean; reset detail lives in the tooltip). | +| `dataContribution.ts` | Opt-in, OFF-by-default SCAFFOLD for future authorized cross-user signals (e.g. cache-TTL by tier). No endpoint / no-op; the Observation type has no identifying field. | + +New data-layer methods on `dataLoader.ts`: `getCostliestMessages` (top-N single +turns + prompt + skill + cost split + gap + prev-model, for the Content tab's +"costliest messages"), `estimateCacheTtl` (hit-rate-by-gap-bin → warm-window +estimate; measured ~60 min), `buildShareInput` (range today/week/last30/month/ +year/`month:YYYY-MM` + scope all/project/session). Efficiency insights +(cost/tokens per message, realised cache savings, "Cache warmth"), thinking-share +"hidden" handling (Fable 5 / Opus omit CoT text), and timezone = full UTC-offset +coverage are all V2.2. CI: `@ccu-bot` first-pass automation (`claude.yml`, +`issue-first-pass.yml`, `pr-first-pass.yml`) — INERT unless `AUTOMATION_ENABLED`; +official or third-party (`ANTHROPIC_BASE_URL`) key; reads this file. + +## Data flow + +``` +~/.claude/projects//.jsonl (one file = one conversation) + │ read line-by-line, JSON.parse each line + ▼ +validate (is it a usage record?) ─► dedup (messageId+requestId, keep higher tokens) + │ tag each record with session id + project (real cwd preferred) + ▼ +ClaudeUsageRecord[] ──► calculateUsageData() ──► UsageData + │ (sum the 4 token buckets, price each) │ + │ ├─► status bar (today + quota) + └─ session titles, user-prompt markers └─► webview (breakdowns + charts) +``` + +Quota is a **separate** path: `claudeApiClient` calls the OAuth usage endpoint +and returns five-hour / seven-day / seven-day-opus utilisation independently of +the local logs (the logs cannot know your plan's limits; only Anthropic does). + +## How tokens & cost are computed (exact, not estimated) + +Each assistant-response line in the JSONL carries a `message.usage` object — the +**Anthropic API's own token accounting**, the same counts Anthropic bills on. +The extension does not estimate these; it reads, validates, dedups, sums, and +prices them: + +1. **Validate** — keep only records whose `usage.input_tokens` is a number (real + API responses); skip synthetic/error/`` model entries. +2. **Dedup** — hash = `messageId + requestId`; on collision keep the + higher-token copy (handles proxies that log a placeholder line first, then + the real values). +3. **Sum** the four buckets into the totals: + - `input_tokens` — fresh prompt, full price + - `cache_read_input_tokens` — prefix served from cache, ~10% of input price + - `cache_creation_input_tokens` — prefix *written* to cache (the + "Input Cache (Miss)" bar), ~125% of input price; spikes on a model switch + or after an idle gap past the cache TTL (the prompt cache is per-model; the + TTL is platform-side — MEASURED at ~60 min in practice, not the assumed + 5 min — and `estimateCacheTtl` infers it from the user's own turns) + - `output_tokens` — generated, output price +4. **Price** — each bucket × its per-model rate (`pricing.ts`); cost = the sum. + +The **only** estimates in the product are the Content tab's character-based +"what's consuming tokens" breakdown (and the planned v2.2 model-fit / +cache-waste features) — always labelled as estimates and never folded into the +exact totals. "Messages" counts user-typed prompts only, via synthetic +zero-token marker records, so it never affects token sums. + +## Log-format facts for model-core features (verified on disk 2026-06-13) + +What the JSONL logs *do* and *don't* contain — the reference for any feature +that reasons about runs, models, or effort. Re-verify with a JSON-key probe +(never a substring grep — see the trap below) when Claude Code's format drifts. + +- **Run config (effort / thinking budget) is NOT logged.** There is no + `effort` / `ultracode` / `maxThinking` / reasoning-budget field on any usage + line. The only mode record is a `type:"mode"` line carrying `permissionMode` + (e.g. `"normal"`). **We cannot tell what effort level a run used.** ⇒ the only + reliable "dynamic-workflow engaged" signal is the **presence of a + `subagents/workflows/wf_/` directory**, not effort. A plain Task-tool + fan-out (no `wf_` dir) is an "ad-hoc batch", not a workflow. + - **Trap:** a substring grep for `effort`/`ultracode` *appears* to hit — but + only inside prompt text and tool-call logs (including this extension's own + commands once they're logged). Parse JSON keys; never grep for config. +- **Skill / plugin attribution IS first-class.** Assistant usage lines carry + `attributionSkill` (e.g. `"superpowers:executing-plans"`) and + `attributionPlugin` (e.g. `"superpowers"`). These are authoritative — prefer + them over the older `` / `Skill` tool_use heuristic, and + cost-weight a skill/plugin by the *exact* `message.usage` of its own lines. +- **Why native-Claude "workflows" look missing from the Workflows tab.** Claude + sub-agent logs *do* exist (haiku/opus appear under some `subagents/` dirs). + But when a native-Claude run uses ultracode, the expensive **Opus/Fable + orchestration stays in the main session log**, and only cheap **haiku** + sub-agents (or none) write `agent-*.jsonl`. A heavy Fable/Opus main thread can + produce **no `subagents/` dir at all**. The Workflows tab groups *sub-agent + files*, so it shows the cheap models and misses the main-thread cost — hence + the v2.1-PartII fix to associate a run's orchestrating main session and surface + its cost/models. **General principle:** the *expensive* model is usually the + main-thread orchestrator; sub-agent files skew cheap. Never infer "which model + I mainly use" from sub-agent files alone. +- **Newer fields (Claude Code ≥ 2.1)** worth knowing: top-level `entrypoint` + (e.g. `claude-vscode`), `permissionMode`, `version`, `context_management`; line + `type`s `mode`, `last-prompt` (verbatim last user prompt), `queue-operation`, + `file-history-snapshot`, `attachment`, `system` (hook summaries); and on + `message.usage`: `service_tier`, `iterations`, `speed`, `inference_geo`, + `cache_creation` (object). Totals (`input/output/cache_*`) remain exact. + +## Key invariants (don't break these) + +- **Read-only** over `~/.claude` (the one write is `advice.apiKey` token refresh in `claudeApiClient`; never touch user logs). +- Every user-facing string goes through `i18n.ts` in **all seven languages**; settings-panel labels/help go through `SETTINGS_I18N` (English fallback from the catalog). +- **Settings live in `SettingsStore`, not scattered reads.** Only the core trio (`language`, `dataDirectory`, `advice.apiKey`) is declared in `package.json` / VS Code config; everything else is `globalState` and must be read via the store (so `config.get` won't find it). Adding a setting = one catalog entry in `settings.ts` (+ its `SETTINGS_I18N` rows). +- New settings **default to existing behaviour** (opt-in); experimental/approximate features default OFF (context indicator, Usage Optimizer). +- **No new runtime dependencies.** External calls (quota, advice/optimizer) go through `httpClient` with the fetch→curl 403 fallback; spawn `curl` with `cwd: os.homedir()`. +- Exact totals and labelled estimates never mix. +- Advice/Optimizer send **only** the usage digest / the pasted draft — never the user's files. + +## Refresh model + +The dashboard/status bar stay current via an activity-aware loop in +`extension.ts`: a self-rescheduling timer whose interval shortens when you're +active and lengthens when idle, guarded by a `refreshGen` generation counter and +coalescing so overlapping refreshes can't stack. File-watching of +`~/.claude/projects` (opt-out) gives ~1.5s latency; an interval is the fallback. +`pauseDashboardRefresh` freezes updates while the panel is open for inspection. + +## Release + +TypeScript strict, clean compile (`node ./node_modules/typescript/bin/tsc -p ./` +— the F5/debug task uses this, not `npm`, to dodge the Windows `npm.ps1` +execution-policy block), green `node:test`. The repo lives at the org +**`ClaudeCodeUsage/ClaudeCodeUsage`**; `main` is branch-protected (PR + `test` +check). Flow (Release Drafter, set up in v2.0.3): contributors open PRs; the +maintainer **squash-merges** with a version label (`patch`/`minor`/`major`, +default patch) — that's the whole per-PR action; a **draft GitHub Release** +auto-accumulates categorised notes; clicking **Publish** tags `v*` and +`publish.yml` stamps `package.json`, packages with `@vscode/vsce`, and ships to +the VS Code Marketplace + Open VSX with the `.vsix` attached. A cross-fork PR's +attribution is preserved by squash-merge; the rare manual re-apply uses +`git merge -s ours` to keep the contributor's commits as merged ancestors. +Issue-closing PRs use `Closes #N`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67b1a19f..3dfb85b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,30 @@ tags: Because changes ship by **merging** your PR — not by re-applying it — your commit authorship and the PR's *merged* status are preserved. +## Automated first-pass replies (@ccu-bot) + +New issues and pull requests may get an **automated first-pass reply** from +`@ccu-bot` — an assistant run via [Claude Code](https://github.com/anthropics/claude-code-action) +that reads the project's architecture docs to help triage faster. + +What to expect and its limits: + +- Every automated comment is clearly labelled **"🤖 Automated first-pass reply + (via Claude Code)"** and ends with a note that it is model-generated and a + **maintainer reviews everything** — it is not a decision. +- It reads `ARCHITECTURE.md`, `CLAUDE.md`, `CONTRIBUTING.md` and, when those + aren't enough, the relevant repository files. It should **ask rather than + guess** when unsure — if it ever states something wrong, just correct it; a + maintainer will confirm. +- It only **comments**. It does not label, close, merge, approve, or push. For + PRs it reads the diff via the API and **never runs your code**. +- You can summon it in a comment with `@ccu-bot ...` (maintainer-gated). +- The whole thing is **off unless the maintainer enables it** (a repo variable + kill switch), and the model may be a third-party Anthropic-compatible one, so + "Claude Code" refers to the tooling, not necessarily Anthropic's Claude. + +Treat its replies as a helpful starting point, not authority. + ## Code of conduct Be kind and constructive. We're all here to make a useful tool. From 39150acb8ac24bd541047ecb4f3415750aa88980 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:32:43 +0800 Subject: [PATCH 04/12] fix(ci): add id-token: write to the first-pass workflows (OIDC) (#58) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue/PR first-pass runs failed with "Could not fetch an OIDC token. Did you remember to add id-token: write?" — claude-code-action@v1 mints its GitHub token via OIDC and needs that permission (the mention workflow already had it). Added it to issue-first-pass.yml and pr-first-pass.yml. Co-authored-by: Claude --- .github/workflows/issue-first-pass.yml | 1 + .github/workflows/pr-first-pass.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/issue-first-pass.yml b/.github/workflows/issue-first-pass.yml index 580aef35..64670712 100644 --- a/.github/workflows/issue-first-pass.yml +++ b/.github/workflows/issue-first-pass.yml @@ -20,6 +20,7 @@ on: permissions: contents: read # read repo files to answer accurately (never guess) issues: write # post the reply + id-token: write # claude-code-action mints its GitHub token via OIDC concurrency: group: issue-first-pass-${{ github.event.issue.number }} diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index b9753181..729f083f 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -16,6 +16,7 @@ on: permissions: contents: read pull-requests: write + id-token: write # claude-code-action mints its GitHub token via OIDC concurrency: group: pr-first-pass-${{ github.event.pull_request.number }} From 1ba48d04ca2ad6f43ba4021e7366555f5bc8e925 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:37:32 +0800 Subject: [PATCH 05/12] fix(ci): use github.token so @ccu-bot needs no Claude GitHub App (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first-pass runs failed with "Claude Code is not installed on this repository" — by default claude-code-action@v1 exchanges its OIDC token for a Claude GitHub App installation token. We drive a third-party model (DeepSeek), so installing Anthropic's app is the wrong dependency. Passing `github_token: ${{ github.token }}` makes the action use the workflow token directly for commenting (issues/pull-requests: write already granted), skipping the app entirely. Applied to all three workflows. Co-authored-by: Claude --- .github/workflows/claude.yml | 3 +++ .github/workflows/issue-first-pass.yml | 3 +++ .github/workflows/pr-first-pass.yml | 3 +++ 3 files changed, 9 insertions(+) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 5a68736f..53f1d8cb 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -69,6 +69,9 @@ jobs: ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Use the workflow token so no Claude GitHub App install is needed + # (we drive a third-party model; the app is only Anthropic's GitHub auth). + github_token: ${{ github.token }} # Cap the agent loop so a single mention can't run away with the budget. # Pin a model here if you want (e.g. --model claude-sonnet-4-5, or a # third-party model id); the action otherwise picks a sensible default. diff --git a/.github/workflows/issue-first-pass.yml b/.github/workflows/issue-first-pass.yml index 64670712..fe9773ba 100644 --- a/.github/workflows/issue-first-pass.yml +++ b/.github/workflows/issue-first-pass.yml @@ -43,6 +43,9 @@ jobs: ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Use the workflow token so no Claude GitHub App install is needed + # (we drive a third-party model; the app is only Anthropic's GitHub auth). + github_token: ${{ github.token }} claude_args: "--max-turns 15" prompt: | You are the ClaudeCodeUsage repository assistant, running via Claude Code diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index 729f083f..4b1e0e45 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -41,6 +41,9 @@ jobs: ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + # Use the workflow token so no Claude GitHub App install is needed + # (we drive a third-party model; the app is only Anthropic's GitHub auth). + github_token: ${{ github.token }} claude_args: "--max-turns 15" prompt: | You are the ClaudeCodeUsage repository assistant, running via Claude Code From e52634c72c9f34cab46acd7b362b554bc6efc5a6 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:58:32 +0800 Subject: [PATCH 06/12] feat(ci): controlled single-call @ccu-bot first-pass (no autonomous agent) (#64) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the autonomous claude-code-action first-pass (which hit CI tool- permission denials AND is a prompt-injection risk on public issues) with a deterministic script: .github/scripts/first-pass.mjs reads the project docs (+ PR diff), makes ONE Anthropic-format model call, and posts ONE comment. It runs NO tools and never executes anything from the issue/PR, so a malicious public issue can't steer it into misusing the token or leaking the key. - Works against a third-party endpoint (ANTHROPIC_BASE_URL); model defaults to deepseek-v4-flash (override via CCU_BOT_MODEL). - PR path checks out the BASE repo, fetches the diff via the API (`gh pr diff`) — never runs PR head code. - Labelled automated + disclaimed; reply in the author's language; answer from docs, ask-don't-guess. INERT until AUTOMATION_ENABLED='true'. - The @ccu-bot MENTION workflow keeps the agent (maintainer-gated = safe). Co-authored-by: Claude --- .github/scripts/first-pass.mjs | 111 +++++++++++++++++++++++++ .github/workflows/issue-first-pass.yml | 67 +++++---------- .github/workflows/pr-first-pass.yml | 65 +++++---------- 3 files changed, 155 insertions(+), 88 deletions(-) create mode 100644 .github/scripts/first-pass.mjs diff --git a/.github/scripts/first-pass.mjs b/.github/scripts/first-pass.mjs new file mode 100644 index 00000000..b79cfa9c --- /dev/null +++ b/.github/scripts/first-pass.mjs @@ -0,0 +1,111 @@ +// Controlled first-pass reply for @ccu-bot — ONE model call, then post ONE +// comment. Deliberately NOT an autonomous agent: it uses no tools and never +// runs anything from the issue/PR, so a malicious public issue cannot steer it +// into misusing the token or leaking the key (prompt-injection safe). It only +// reads the project docs (+ PR diff), asks the model once, and comments. +// +// Env in: GH_TOKEN, REPO (owner/name), EVENT_KIND (issue|pr), ITEM_NUMBER, +// ITEM_TITLE, ITEM_BODY, DIFF_FILE (pr only), ANTHROPIC_API_KEY, +// ANTHROPIC_BASE_URL (third-party OK), CCU_BOT_MODEL. + +import { readFileSync, existsSync } from 'node:fs'; + +const env = process.env; +const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, ''); +const model = env.CCU_BOT_MODEL || 'deepseek-v4-flash'; +const isPr = env.EVENT_KIND === 'pr'; +const num = env.ITEM_NUMBER; + +const readDoc = (path, max = 12000) => { + try { + return existsSync(path) ? readFileSync(path, 'utf8').slice(0, max) : ''; + } catch { + return ''; + } +}; +const docs = + `# ARCHITECTURE.md\n${readDoc('ARCHITECTURE.md')}\n\n` + + `# CLAUDE.md\n${readDoc('CLAUDE.md')}\n\n` + + `# CONTRIBUTING.md\n${readDoc('CONTRIBUTING.md')}`; +let diff = ''; +if (isPr && env.DIFF_FILE) { + diff = readDoc(env.DIFF_FILE, 40000); +} + +const kind = isPr ? 'review' : 'reply'; +const system = [ + 'You are the ClaudeCodeUsage repository assistant, replying via Claude Code tooling.', + 'The underlying model may be a third-party Anthropic-format model, so do not claim to be Anthropic\'s Claude specifically.', + `Write ONE concise, concrete first-pass ${kind} for the ${isPr ? 'pull request' : 'issue'} below.`, + isPr + ? '- Cover correctness risks, convention/i18n/CHANGELOG gaps, and merge-readiness, grounded in the diff + docs.' + : '- Say what you understand the request to be, where in the architecture it relates, and a concrete suggested direction OR a specific clarifying question.', + `- Answer ONLY from the provided project docs${isPr ? ' and diff' : ''}. If they are insufficient, say exactly what is unclear and ask a specific question — NEVER guess or invent facts about the code.`, + '- Reply in the SAME language the author wrote in. Be specific and concise; no filler, no AI-flavoured padding.', + `- Start the comment with: "🤖 Automated first-pass ${kind} (via Claude Code)".`, + `- End with, on its own line: "_This is model-generated from the repository docs and the ${isPr ? 'PR diff' : 'issue'} — not a final decision. A maintainer reviews everything._"`, +].join('\n'); + +const userText = isPr + ? `PR #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no description)'}\n\n--- DIFF (truncated) ---\n${diff}\n\n--- PROJECT DOCS ---\n${docs}` + : `Issue #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no body)'}\n\n--- PROJECT DOCS ---\n${docs}`; + +const fail = (msg) => { + console.error(msg); + process.exit(1); +}; + +// 1) One model call (Anthropic Messages format; works against a third-party +// Anthropic-compatible endpoint via ANTHROPIC_BASE_URL). +let reply = ''; +try { + const res = await fetch(`${base}/v1/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': env.ANTHROPIC_API_KEY, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model, + max_tokens: 1200, + system, + messages: [{ role: 'user', content: userText }], + }), + }); + if (!res.ok) { + fail(`Model API error ${res.status}: ${(await res.text()).slice(0, 500)}`); + } + const data = await res.json(); + reply = (data.content || []) + .filter((b) => b && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') + .trim(); +} catch (e) { + fail(`Model call failed: ${e.message}`); +} +if (!reply) { + fail('Empty model reply.'); +} + +// 2) Post ONE comment via the GitHub API (issues + PRs share this endpoint). +const [owner, repo] = (env.REPO || '/').split('/'); +try { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments`, { + method: 'POST', + headers: { + authorization: `Bearer ${env.GH_TOKEN}`, + accept: 'application/vnd.github+json', + 'content-type': 'application/json', + 'user-agent': 'ccu-bot', + }, + body: JSON.stringify({ body: reply }), + }); + if (!res.ok) { + fail(`Comment post failed ${res.status}: ${(await res.text()).slice(0, 300)}`); + } +} catch (e) { + fail(`Comment post failed: ${e.message}`); +} +console.log(`Posted first-pass ${kind} on #${num}.`); diff --git a/.github/workflows/issue-first-pass.yml b/.github/workflows/issue-first-pass.yml index fe9773ba..1d0c4e77 100644 --- a/.github/workflows/issue-first-pass.yml +++ b/.github/workflows/issue-first-pass.yml @@ -1,16 +1,13 @@ -# Auto first-pass reply on every NEW issue (V2.2 automation — Carl's opt-in). +# Auto first-pass reply on every NEW issue — a CONTROLLED single model call +# (not an autonomous agent). See .github/scripts/first-pass.mjs: it reads the +# project docs, asks the model once, and posts one comment. No tools run, so a +# malicious public issue can't steer it into misusing the token or key. # -# When an issue is opened, the assistant reads the project's architecture docs -# and posts ONE helpful first-pass comment: what it understands, where in the -# code/architecture it points, and either a suggested direction or a clarifying -# question. Every comment is clearly labelled automated and ends with a -# disclaimer that a maintainer follows up. -# -# SAFE BY DEFAULT — inert until the owner sets: -# 1. Repo variable AUTOMATION_ENABLED = 'true' (kill switch) -# 2. Repo secret ANTHROPIC_API_KEY (official or third-party key) -# Optional: repo variable ANTHROPIC_BASE_URL for a third-party Anthropic-format -# endpoint. Bot-authored issues are skipped (no loops). +# INERT until the owner sets: +# AUTOMATION_ENABLED = 'true' (repo variable, kill switch) +# ANTHROPIC_API_KEY (repo secret — official or third-party key) +# Optional repo variables: ANTHROPIC_BASE_URL (third-party Anthropic-format +# endpoint), CCU_BOT_MODEL (defaults to deepseek-v4-flash). name: Issue first-pass (auto) on: @@ -18,9 +15,8 @@ on: types: [opened] permissions: - contents: read # read repo files to answer accurately (never guess) - issues: write # post the reply - id-token: write # claude-code-action mints its GitHub token via OIDC + contents: read # read the repo docs the reply is grounded in + issues: write # post the comment concurrency: group: issue-first-pass-${{ github.event.issue.number }} @@ -30,7 +26,7 @@ jobs: reply: if: vars.AUTOMATION_ENABLED == 'true' && github.event.issue.user.type != 'Bot' runs-on: ubuntu-latest - timeout-minutes: 12 + timeout-minutes: 8 steps: - name: Checkout repository uses: actions/checkout@v6 @@ -38,35 +34,14 @@ jobs: fetch-depth: 1 - name: First-pass reply - uses: anthropics/claude-code-action@v1 env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT_KIND: issue + ITEM_NUMBER: ${{ github.event.issue.number }} + ITEM_TITLE: ${{ github.event.issue.title }} + ITEM_BODY: ${{ github.event.issue.body }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Use the workflow token so no Claude GitHub App install is needed - # (we drive a third-party model; the app is only Anthropic's GitHub auth). - github_token: ${{ github.token }} - claude_args: "--max-turns 15" - prompt: | - You are the ClaudeCodeUsage repository assistant, running via Claude Code - (the model may be a third-party Anthropic-format model, so do not claim to - be Anthropic's Claude specifically). - - A new issue was just opened (title/body in the event context). Post ONE - comment as a first-pass response. Rules: - - First read CLAUDE.md and CONTRIBUTING.md (and ARCHITECTURE.md if present) - for context. - - If those docs are NOT enough to answer accurately, READ the relevant - repository files to find the real answer. Never guess or invent an - answer — if you still can't determine it, say what's unclear and ask a - specific clarifying question instead of speculating. - - Be concrete and concise: what you understand the request to be, where in - the code/architecture it relates, and a suggested direction OR the - clarifying question needed. No filler, no AI-flavoured padding. - - Reply in the same language the issue author used. - - START the comment with: "🤖 Automated first-pass reply (via Claude Code)". - - END the comment with this disclaimer on its own line: - "_This is model-generated from the repository's architecture docs and - the issue description — not a final decision. A maintainer will review - and follow up._" - - Do not change labels, close the issue, or open PRs. Just the one comment. + CCU_BOT_MODEL: ${{ vars.CCU_BOT_MODEL }} + run: node .github/scripts/first-pass.mjs diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index 4b1e0e45..601250e2 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -1,12 +1,10 @@ -# Auto first-pass review on every NEW pull request (V2.2 automation — opt-in). +# Auto first-pass review on every NEW pull request — a CONTROLLED single model +# call (not an autonomous agent). Uses pull_request_target for the token but +# NEVER runs the PR's code: it checks out the BASE repo, fetches the diff via the +# API, asks the model once, and posts one comment. No tools → injection-safe. # -# Uses pull_request_target so it has the token to comment, but it must NEVER run -# the PR's code with that token. We check out the BASE repo (the default for -# pull_request_target), read the PR diff via the API, and post ONE first-pass -# review comment. Fork code is never executed here. -# -# SAFE BY DEFAULT — inert until the owner sets AUTOMATION_ENABLED='true' and the -# ANTHROPIC_API_KEY secret (official or third-party). Bot-authored PRs skipped. +# INERT until AUTOMATION_ENABLED = 'true' + the ANTHROPIC_API_KEY secret. +# Optional repo vars: ANTHROPIC_BASE_URL, CCU_BOT_MODEL (default deepseek-v4-flash). name: PR first-pass (auto) on: @@ -16,7 +14,6 @@ on: permissions: contents: read pull-requests: write - id-token: write # claude-code-action mints its GitHub token via OIDC concurrency: group: pr-first-pass-${{ github.event.pull_request.number }} @@ -24,47 +21,31 @@ concurrency: jobs: review: - # Skip bots and huge diffs (a > ~3000-line PR wants a human first). if: vars.AUTOMATION_ENABLED == 'true' && github.event.pull_request.user.type != 'Bot' runs-on: ubuntu-latest - timeout-minutes: 12 + timeout-minutes: 8 steps: - # Base repo only — do NOT check out or run the PR head with the token. + # Base repo only — never checks out or runs the PR head with the token. - name: Checkout base repository uses: actions/checkout@v6 with: fetch-depth: 1 + - name: Fetch PR diff (via API, no code run) + env: + GH_TOKEN: ${{ github.token }} + run: gh pr diff ${{ github.event.pull_request.number }} --repo ${{ github.repository }} > /tmp/pr.diff || true + - name: First-pass review - uses: anthropics/claude-code-action@v1 env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + EVENT_KIND: pr + ITEM_NUMBER: ${{ github.event.pull_request.number }} + ITEM_TITLE: ${{ github.event.pull_request.title }} + ITEM_BODY: ${{ github.event.pull_request.body }} + DIFF_FILE: /tmp/pr.diff + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - # Use the workflow token so no Claude GitHub App install is needed - # (we drive a third-party model; the app is only Anthropic's GitHub auth). - github_token: ${{ github.token }} - claude_args: "--max-turns 15" - prompt: | - You are the ClaudeCodeUsage repository assistant, running via Claude Code - (the model may be a third-party Anthropic-format model; do not claim to be - Anthropic's Claude specifically). - - A new pull request was opened. Do a first-pass review and post ONE comment. - SECURITY: never execute the PR's code. Read the PR DIFF via the GitHub API - and the repository's own files only. - Rules: - - First read CLAUDE.md and CONTRIBUTING.md (and ARCHITECTURE.md if present) - for the - conventions (compile-clean, node:test green, 7-language i18n, opt-in - defaults, CHANGELOG). If those aren't enough to judge accurately, read - the relevant base-repo files — never guess. - - Cover: correctness risks, convention/i18n/CHANGELOG gaps, and - merge-readiness. Be specific and concise; skip praise padding. - - If the diff is very large (> ~3000 lines) or you're unsure, say a human - should review rather than speculate. - - START with: "🤖 Automated first-pass review (via Claude Code)". - - END with: "_This is model-generated from the architecture docs and the - PR diff — not a merge decision. A maintainer reviews everything._" - - Do not approve, request changes as a formal review, merge, or push. One - comment only. + CCU_BOT_MODEL: ${{ vars.CCU_BOT_MODEL }} + run: node .github/scripts/first-pass.mjs From a55968aaeff4e089eb0aa3a7f65c2fd08c8a31ea Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:21:27 +0800 Subject: [PATCH 07/12] feat(ci): two-tier first-pass bot with controlled repo-file escalation (#66) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #64. When the cheap first-pass model (CCU_BOT_MODEL, default deepseek-v4-flash) reports it can't answer from the docs alone, escalate to a stronger model (new CCU_BOT_MODEL_PRO, default deepseek-v4-pro) with thinking on, and let it NAME a few repo source files. The SCRIPT reads them — repo- relative text files only, no "..", size-capped, must resolve inside the repo root — so there is still no agent tool and no way for a malicious issue to make it read or execute anything arbitrary (same injection-safe property as #64, just with a controlled retrieval step). If it still can't answer, the reply asks for the specific missing info/logs and suggests the issue template. Also standardizes every reply into a skimmable shape: TL;DR/结论 first, then analysis, then suggested next steps. The thinking param is best-effort — the script retries once without it if the endpoint rejects it — so this stays portable across third-party Anthropic-format endpoints. Still INERT until AUTOMATION_ENABLED='true' + ANTHROPIC_API_KEY, unchanged. Co-authored-by: Claude --- .github/scripts/first-pass.mjs | 205 ++++++++++++++++++++----- .github/workflows/issue-first-pass.yml | 5 +- .github/workflows/pr-first-pass.yml | 4 +- 3 files changed, 172 insertions(+), 42 deletions(-) diff --git a/.github/scripts/first-pass.mjs b/.github/scripts/first-pass.mjs index b79cfa9c..f9260d46 100644 --- a/.github/scripts/first-pass.mjs +++ b/.github/scripts/first-pass.mjs @@ -1,20 +1,39 @@ -// Controlled first-pass reply for @ccu-bot — ONE model call, then post ONE -// comment. Deliberately NOT an autonomous agent: it uses no tools and never -// runs anything from the issue/PR, so a malicious public issue cannot steer it -// into misusing the token or leaking the key (prompt-injection safe). It only -// reads the project docs (+ PR diff), asks the model once, and comments. +// Controlled first-pass reply for issues / PRs — still NOT an autonomous agent. +// It uses no agent tools and never runs anything from the issue/PR, so a +// malicious public issue cannot steer it into misusing the token or leaking the +// key (prompt-injection safe). Flow: +// +// Tier 1 cheap model (CCU_BOT_MODEL, default deepseek-v4-flash) answers from +// the project docs (+ PR diff). It also returns a signal. +// Tier 2 if it says it can't answer from the docs, escalate to a stronger +// model (CCU_BOT_MODEL_PRO, default deepseek-v4-pro) with thinking on, +// and let it name a few repo source files — WHICH THE SCRIPT reads and +// validates (no tool call, no traversal, size-capped). It re-answers. +// Tier 3 if it still can't, its reply politely asks for the missing info / +// logs and (when fields are missing) suggests the issue template. +// +// Then post ONE comment. The model only ever READS repo-relative text files the +// script hand-picks; it never executes anything. // // Env in: GH_TOKEN, REPO (owner/name), EVENT_KIND (issue|pr), ITEM_NUMBER, // ITEM_TITLE, ITEM_BODY, DIFF_FILE (pr only), ANTHROPIC_API_KEY, -// ANTHROPIC_BASE_URL (third-party OK), CCU_BOT_MODEL. +// ANTHROPIC_BASE_URL (third-party OK), CCU_BOT_MODEL, CCU_BOT_MODEL_PRO. -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; const env = process.env; const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, ''); const model = env.CCU_BOT_MODEL || 'deepseek-v4-flash'; +const modelPro = env.CCU_BOT_MODEL_PRO || 'deepseek-v4-pro'; const isPr = env.EVENT_KIND === 'pr'; const num = env.ITEM_NUMBER; +const kind = isPr ? 'review' : 'reply'; + +const fail = (msg) => { + console.error(msg); + process.exit(1); +}; const readDoc = (path, max = 12000) => { try { @@ -32,56 +51,162 @@ if (isPr && env.DIFF_FILE) { diff = readDoc(env.DIFF_FILE, 40000); } -const kind = isPr ? 'review' : 'reply'; -const system = [ - 'You are the ClaudeCodeUsage repository assistant, replying via Claude Code tooling.', - 'The underlying model may be a third-party Anthropic-format model, so do not claim to be Anthropic\'s Claude specifically.', - `Write ONE concise, concrete first-pass ${kind} for the ${isPr ? 'pull request' : 'issue'} below.`, - isPr - ? '- Cover correctness risks, convention/i18n/CHANGELOG gaps, and merge-readiness, grounded in the diff + docs.' - : '- Say what you understand the request to be, where in the architecture it relates, and a concrete suggested direction OR a specific clarifying question.', - `- Answer ONLY from the provided project docs${isPr ? ' and diff' : ''}. If they are insufficient, say exactly what is unclear and ask a specific question — NEVER guess or invent facts about the code.`, - '- Reply in the SAME language the author wrote in. Be specific and concise; no filler, no AI-flavoured padding.', - `- Start the comment with: "🤖 Automated first-pass ${kind} (via Claude Code)".`, - `- End with, on its own line: "_This is model-generated from the repository docs and the ${isPr ? 'PR diff' : 'issue'} — not a final decision. A maintainer reviews everything._"`, -].join('\n'); +// --- Safe repo-file reader: only repo-relative text files, no traversal ------ +const REPO_ROOT = resolve(process.cwd()); +const ALLOWED_EXT = /\.(ts|tsx|js|jsx|mjs|cjs|json|md|ya?ml|txt)$/i; +const readRepoFiles = (wanted) => { + const out = []; + let budget = 60000; // total bytes across all requested files + for (const raw of (wanted || []).slice(0, 6)) { + const rel = String(raw || '').trim().replace(/^\.?\//, ''); + if (!rel || rel.includes('..') || rel.includes('\0') || !ALLOWED_EXT.test(rel)) { + continue; + } + const abs = resolve(REPO_ROOT, rel); + if (abs !== REPO_ROOT && !abs.startsWith(REPO_ROOT + sep)) { + continue; // escaped the repo root + } + try { + if (!existsSync(abs) || !statSync(abs).isFile()) { + continue; + } + const body = readFileSync(abs, 'utf8').slice(0, Math.min(16000, budget)); + budget -= body.length; + out.push(`# ${rel}\n${body}`); + if (budget <= 0) { + break; + } + } catch { + /* skip unreadable */ + } + } + return out.join('\n\n'); +}; -const userText = isPr - ? `PR #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no description)'}\n\n--- DIFF (truncated) ---\n${diff}\n\n--- PROJECT DOCS ---\n${docs}` - : `Issue #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no body)'}\n\n--- PROJECT DOCS ---\n${docs}`; +// --- System prompt ----------------------------------------------------------- +const buildSystem = (final) => + [ + 'You are the ClaudeCodeUsage repository assistant, replying via Claude Code tooling.', + "The underlying model may be a third-party Anthropic-format model, so do not claim to be Anthropic's Claude specifically.", + `Write ONE concise, concrete first-pass ${kind} for the ${isPr ? 'pull request' : 'issue'} below.`, + isPr + ? '- Focus on correctness risks, convention/i18n/CHANGELOG gaps, and merge-readiness, grounded in the diff + docs.' + : '- Identify the request, where in the architecture it relates, and a concrete direction or a specific clarifying question.', + '- Answer ONLY from the provided material (docs, diff, and any source files supplied). NEVER guess or invent code facts.', + '- Reply in the SAME language the author wrote in. Be specific; no filler, no AI-flavoured padding.', + '', + 'Format your reply markdown in this order so a human can skim it:', + `- First line: "🤖 Automated first-pass ${kind} (via Claude Code)".`, + '- Then **TL;DR / 结论**: one or two sentences — the answer or the current status.', + '- Then **分析 / Analysis**: briefly why / what is happening / where it sits.', + '- Then **建议 / Suggested next step(s)**: a concrete direction, a specific question, or exactly what info is needed.', + `- Last line, on its own: "_This is model-generated from the repository docs and the ${isPr ? 'PR diff' : 'issue'} — not a final decision. A maintainer reviews everything._"`, + '', + final + ? '- If you STILL cannot determine the answer from everything provided, say so plainly in the TL;DR, then ask for the specific missing information (repro steps, logs, versions, config). If the issue omits obvious details, politely suggest filling out the issue template next time.' + : '- If the docs/diff are insufficient, you may request source files (see the control block); do not pad the answer with guesses.', + '', + 'Before the reply, emit exactly one control line and nothing before it:', + '{"answerable": , "want_files": []}', + 'Then the reply, wrapped as:', + '', + '...markdown reply...', + '', + ].join('\n'); -const fail = (msg) => { - console.error(msg); - process.exit(1); -}; +const buildUser = (extraFiles) => + (isPr + ? `PR #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no description)'}\n\n--- DIFF (truncated) ---\n${diff}` + : `Issue #${num}: ${env.ITEM_TITLE}\n\n${env.ITEM_BODY || '(no body)'}`) + + `\n\n--- PROJECT DOCS ---\n${docs}` + + (extraFiles ? `\n\n--- REPO SOURCE FILES (read-only) ---\n${extraFiles}` : ''); -// 1) One model call (Anthropic Messages format; works against a third-party -// Anthropic-compatible endpoint via ANTHROPIC_BASE_URL). -let reply = ''; -try { - const res = await fetch(`${base}/v1/messages`, { +// --- One model call (Anthropic Messages format; third-party-compatible) ------ +const askModel = async (useModel, system, userText, think) => { + const body = { + model: useModel, + max_tokens: 1400, + system, + messages: [{ role: 'user', content: userText }], + }; + if (think) { + body.thinking = { type: 'enabled', budget_tokens: 6000 }; + } + let res = await fetch(`${base}/v1/messages`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-api-key': env.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', }, - body: JSON.stringify({ - model, - max_tokens: 1200, - system, - messages: [{ role: 'user', content: userText }], - }), + body: JSON.stringify(body), }); + if (!res.ok && think) { + // Endpoint may not accept the thinking param — retry once without it. + const errTxt = await res.text(); + if (/think|budget|adaptive|reason/i.test(errTxt)) { + delete body.thinking; + res = await fetch(`${base}/v1/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': env.ANTHROPIC_API_KEY, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + }); + } else { + fail(`Model API error ${res.status}: ${errTxt.slice(0, 500)}`); + } + } if (!res.ok) { fail(`Model API error ${res.status}: ${(await res.text()).slice(0, 500)}`); } const data = await res.json(); - reply = (data.content || []) + return (data.content || []) .filter((b) => b && b.type === 'text' && typeof b.text === 'string') .map((b) => b.text) .join('') .trim(); +}; + +const tag = (text, name) => { + const m = text.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, 'i')); + return m ? m[1].trim() : null; +}; +const parse = (raw) => { + const replyBody = tag(raw, 'reply') || raw.replace(/[\s\S]*?<\/control>/i, '').trim(); + let control = { answerable: true, want_files: [] }; + const ctl = tag(raw, 'control'); + if (ctl) { + try { + const j = JSON.parse(ctl); + control = { + answerable: j.answerable !== false, + want_files: Array.isArray(j.want_files) ? j.want_files : [], + }; + } catch { + /* keep default (treat as answerable → no escalation) */ + } + } + return { reply: replyBody, ...control }; +}; + +let reply = ''; +try { + // Tier 1: cheap model, docs only. + const first = parse(await askModel(model, buildSystem(false), buildUser(''))); + reply = first.reply; + + // Tier 2/3: escalate to the stronger model with thinking + the source files + // it named (read & validated by the script), and take its final reply. + if (!first.answerable) { + const extra = readRepoFiles(first.want_files); + const second = parse(await askModel(modelPro, buildSystem(true), buildUser(extra), true)); + if (second.reply) { + reply = second.reply; + } + } } catch (e) { fail(`Model call failed: ${e.message}`); } @@ -89,7 +214,7 @@ if (!reply) { fail('Empty model reply.'); } -// 2) Post ONE comment via the GitHub API (issues + PRs share this endpoint). +// Post ONE comment via the GitHub API (issues + PRs share this endpoint). const [owner, repo] = (env.REPO || '/').split('/'); try { const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/issues/${num}/comments`, { diff --git a/.github/workflows/issue-first-pass.yml b/.github/workflows/issue-first-pass.yml index 1d0c4e77..f93bfdf1 100644 --- a/.github/workflows/issue-first-pass.yml +++ b/.github/workflows/issue-first-pass.yml @@ -7,7 +7,9 @@ # AUTOMATION_ENABLED = 'true' (repo variable, kill switch) # ANTHROPIC_API_KEY (repo secret — official or third-party key) # Optional repo variables: ANTHROPIC_BASE_URL (third-party Anthropic-format -# endpoint), CCU_BOT_MODEL (defaults to deepseek-v4-flash). +# endpoint), CCU_BOT_MODEL (cheap tier, default deepseek-v4-flash), +# CCU_BOT_MODEL_PRO (escalation tier used when the cheap tier can't answer from +# the docs, default deepseek-v4-pro). name: Issue first-pass (auto) on: @@ -44,4 +46,5 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} CCU_BOT_MODEL: ${{ vars.CCU_BOT_MODEL }} + CCU_BOT_MODEL_PRO: ${{ vars.CCU_BOT_MODEL_PRO }} run: node .github/scripts/first-pass.mjs diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index 601250e2..2ca54c39 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -4,7 +4,8 @@ # API, asks the model once, and posts one comment. No tools → injection-safe. # # INERT until AUTOMATION_ENABLED = 'true' + the ANTHROPIC_API_KEY secret. -# Optional repo vars: ANTHROPIC_BASE_URL, CCU_BOT_MODEL (default deepseek-v4-flash). +# Optional repo vars: ANTHROPIC_BASE_URL, CCU_BOT_MODEL (cheap tier, default +# deepseek-v4-flash), CCU_BOT_MODEL_PRO (escalation tier, default deepseek-v4-pro). name: PR first-pass (auto) on: @@ -48,4 +49,5 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} CCU_BOT_MODEL: ${{ vars.CCU_BOT_MODEL }} + CCU_BOT_MODEL_PRO: ${{ vars.CCU_BOT_MODEL_PRO }} run: node .github/scripts/first-pass.mjs From 947ce32f24ed521677e374971a545a4cf1d43a3e Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Sun, 5 Jul 2026 02:18:26 +0800 Subject: [PATCH 08/12] fix(ci): don't auto-review maintainer PRs; treat an empty model reply as a no-op (#67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The PR first-pass now SKIPS pull requests authored by OWNER / MEMBER / COLLABORATOR — a maintainer's own finished PR doesn't need an auto first-pass (that review is for external contributors). #66 showed it firing on our own PR and failing on the big self-referential diff. - An empty model reply is now a soft no-op (exit 0) instead of a hard failure, so a benign blank response doesn't leave a red check on the PR/issue. Co-authored-by: Claude --- .github/scripts/first-pass.mjs | 6 +++++- .github/workflows/pr-first-pass.yml | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/scripts/first-pass.mjs b/.github/scripts/first-pass.mjs index f9260d46..a1c54a07 100644 --- a/.github/scripts/first-pass.mjs +++ b/.github/scripts/first-pass.mjs @@ -211,7 +211,11 @@ try { fail(`Model call failed: ${e.message}`); } if (!reply) { - fail('Empty model reply.'); + // A blank reply is a soft no-op, not a failure: posting nothing is fine, and + // we don't want a red ✗ check on the PR/issue just because the model returned + // no text (e.g. a huge or self-referential diff). + console.warn('Empty model reply — nothing to post.'); + process.exit(0); } // Post ONE comment via the GitHub API (issues + PRs share this endpoint). diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index 2ca54c39..fc9cdf17 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -22,7 +22,13 @@ concurrency: jobs: review: - if: vars.AUTOMATION_ENABLED == 'true' && github.event.pull_request.user.type != 'Bot' + # Skip PRs from maintainers (OWNER / MEMBER / COLLABORATOR): their PRs are + # usually finished work that doesn't need an auto first-pass — the review is + # for external contributors. Also skip bot-authored PRs. + if: >- + vars.AUTOMATION_ENABLED == 'true' + && github.event.pull_request.user.type != 'Bot' + && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) runs-on: ubuntu-latest timeout-minutes: 8 steps: From 434b8e6853104cf0dffe424860c70082053d0554 Mon Sep 17 00:00:00 2001 From: Zeyu Tang Date: Sun, 5 Jul 2026 07:55:41 -0700 Subject: [PATCH 09/12] fix: timezone bucketing and cache-write cost accuracy (#62) * fix: timezone bucketing and cache-write cost accuracy * bucket Today/month/daily/hourly totals by the configured IANA zone * keep the loader zone in lockstep with display; recompute on change * split cache writes into 1h (2x) vs 5m (1.25x) when the log has the ttl * add tests for zone bucketing and the cache-write split * i18n: note timezone sets the daily cost boundary * add the day-boundary clause to the timezone help in all six locales * update the timezone description in README and the locale editions * fix: changelog minor touch-up * Maintainer edit: keep the cache-write TTL pricing; drop the timezone changes The timezone-bucketing half overlaps with the shared zone-aware day-key rework landing in V2.2, so this reduces the PR to just the cache-write TTL pricing (pricing.ts + types.ts + pricing.test.ts) to merge cleanly. zeyutang's original commits are preserved; the timezone work is welcome as a follow-up PR. * Remove stray sample-card SVGs accidentally swept into the maintainer edit --------- Co-authored-by: Carl <74130028+Carl723000@users.noreply.github.com> --- src/pricing.ts | 60 ++++++++++++++++++++++++++++++++++++---- src/test/pricing.test.ts | 37 ++++++++++++++++++++++++- src/types.ts | 11 ++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/pricing.ts b/src/pricing.ts index 5a2cbd62..cf3c2eb2 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -13,6 +13,45 @@ export interface TokenUsage { output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; + // Optional TTL split of cache_creation_input_tokens, as Claude Code writes + // it. When present, the 1-hour portion is billed at 2x base input and the + // 5-minute portion at 1.25x; when absent, the whole total uses the 5-minute + // rate (see cacheCreationCost). + cache_creation?: { + ephemeral_1h_input_tokens?: number; + ephemeral_5m_input_tokens?: number; + }; +} + +/** + * Cost of the cache-write (cache_creation) tokens, splitting the 1-hour and + * 5-minute TTL portions when the usage record carries them + * (usage.cache_creation.ephemeral_1h/5m). Claude bills a 1-hour write at 2x + * base input versus the 5-minute write's 1.25x, so lumping both at the 5-minute + * rate under-counts any 1-hour writes. Falls back to the 5-minute rate for the + * whole total when the split (or the 1-hour rate) is unavailable — matching + * Claude Code's default 5-minute writes and every older/proxy log. + */ +function cacheCreationCost(tokens: TokenUsage, pricing: ModelPricing): number { + const fiveMRate = pricing.cache_creation_input_token_cost; + if (fiveMRate == null) { + return 0; + } + const split = tokens.cache_creation; + const oneH = split?.ephemeral_1h_input_tokens || 0; + const fiveMExplicit = split?.ephemeral_5m_input_tokens; + // Total is the max of the flat field and the split's sum, so a record that + // carries only one of the two shapes is still priced fully. + const total = Math.max( + tokens.cache_creation_input_tokens || 0, + oneH + (fiveMExplicit || 0) + ); + if (total <= 0) { + return 0; + } + const oneHRate = pricing.cache_creation_1h_input_token_cost ?? fiveMRate; + const fiveM = fiveMExplicit != null ? fiveMExplicit : Math.max(0, total - oneH); + return fiveM * fiveMRate + oneH * oneHRate; } const MILL = 1_000_000; @@ -23,8 +62,13 @@ const MILL = 1_000_000; // // Cache pricing follows Anthropic's standard multipliers vs base input price: // - 5-minute cache write : 1.25x base input (what Claude Code writes by default) +// - 1-hour cache write : 2.00x base input (used when a 1h TTL is requested) // - cache read (hit) : 0.10x base input -// `cache_creation_input_token_cost` below uses the 5-minute write rate. +// `cache_creation_input_token_cost` below is the 5-minute write rate; +// `cache_creation_1h_input_token_cost` is the 1-hour write rate. When a log +// record carries the TTL split (usage.cache_creation.ephemeral_1h/5m), each +// portion is billed at its own rate; otherwise the whole cache-write total +// falls back to the 5-minute rate (what Claude Code writes by default). // ===================================================================== // Fable 5 / Mythos 5 — frontier tier ($10 / $50), verified 2026-06-09 @@ -33,6 +77,7 @@ const FABLE_5: ModelPricing = { input_cost_per_token: 10 / MILL, output_cost_per_token: 50 / MILL, cache_creation_input_token_cost: 12.5 / MILL, + cache_creation_1h_input_token_cost: 20 / MILL, cache_read_input_token_cost: 1 / MILL, }; @@ -41,6 +86,7 @@ const OPUS_CURRENT: ModelPricing = { input_cost_per_token: 5 / MILL, output_cost_per_token: 25 / MILL, cache_creation_input_token_cost: 6.25 / MILL, + cache_creation_1h_input_token_cost: 10 / MILL, cache_read_input_token_cost: 0.5 / MILL, }; @@ -49,6 +95,7 @@ const OPUS_LEGACY: ModelPricing = { input_cost_per_token: 15 / MILL, output_cost_per_token: 75 / MILL, cache_creation_input_token_cost: 18.75 / MILL, + cache_creation_1h_input_token_cost: 30 / MILL, cache_read_input_token_cost: 1.5 / MILL, }; @@ -57,6 +104,7 @@ const SONNET: ModelPricing = { input_cost_per_token: 3 / MILL, output_cost_per_token: 15 / MILL, cache_creation_input_token_cost: 3.75 / MILL, + cache_creation_1h_input_token_cost: 6 / MILL, cache_read_input_token_cost: 0.3 / MILL, }; @@ -65,6 +113,7 @@ const HAIKU_45: ModelPricing = { input_cost_per_token: 1 / MILL, output_cost_per_token: 5 / MILL, cache_creation_input_token_cost: 1.25 / MILL, + cache_creation_1h_input_token_cost: 2 / MILL, cache_read_input_token_cost: 0.1 / MILL, }; @@ -73,6 +122,7 @@ const HAIKU_35: ModelPricing = { input_cost_per_token: 0.8 / MILL, output_cost_per_token: 4 / MILL, cache_creation_input_token_cost: 1.0 / MILL, + cache_creation_1h_input_token_cost: 1.6 / MILL, cache_read_input_token_cost: 0.08 / MILL, }; @@ -358,10 +408,8 @@ export function calculateCostFromPricing(tokens: TokenUsage, pricing: ModelPrici cost += tokens.output_tokens * pricing.output_cost_per_token; } - // Cache creation tokens cost - if (tokens.cache_creation_input_tokens != null && pricing.cache_creation_input_token_cost != null) { - cost += tokens.cache_creation_input_tokens * pricing.cache_creation_input_token_cost; - } + // Cache creation tokens cost (splits 1-hour vs 5-minute writes when known) + cost += cacheCreationCost(tokens, pricing); // Cache read tokens cost if (tokens.cache_read_input_tokens != null && pricing.cache_read_input_token_cost != null) { @@ -402,7 +450,7 @@ export function calculateCostBreakdown( return { input: tokens.input_tokens * (pricing.input_cost_per_token || 0), output: tokens.output_tokens * (pricing.output_cost_per_token || 0), - cacheWrite: (tokens.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0), + cacheWrite: cacheCreationCost(tokens, pricing), cacheRead: (tokens.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0), }; } diff --git a/src/test/pricing.test.ts b/src/test/pricing.test.ts index 049266a9..4195896d 100644 --- a/src/test/pricing.test.ts +++ b/src/test/pricing.test.ts @@ -14,7 +14,7 @@ import { test } from 'node:test'; import * as assert from 'node:assert/strict'; -import { calculateCostFromTokens, getModelPricing } from '../pricing'; +import { calculateCostFromTokens, calculateCostBreakdown, getModelPricing } from '../pricing'; test('calculateCostFromTokens prices a known model from its per-token rates', () => { // Opus current tier: $5 / $25 / $6.25 / $0.50 per million in/out/write/read. @@ -32,6 +32,41 @@ test('calculateCostFromTokens prices a known model from its per-token rates', () assert.ok(Math.abs(cost - 36.75) < 1e-9, `expected ~36.75, got ${cost}`); }); +test('cache-write pricing bills 1-hour writes at 2x input, 5-minute at 1.25x', () => { + // Opus current tier: base input $5/MTok, so a 5m write is $6.25/MTok and a + // 1h write is $10/MTok. Split 1M tokens evenly across the two TTLs. + const breakdown = calculateCostBreakdown( + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 1_000_000, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_1h_input_tokens: 500_000, + ephemeral_5m_input_tokens: 500_000, + }, + }, + 'claude-opus-4-8' + ); + // 0.5M * 10 + 0.5M * 6.25 = 5 + 3.125 = 8.125 (vs 6.25 if lumped at 5m). + assert.ok(Math.abs(breakdown.cacheWrite - 8.125) < 1e-9, `expected ~8.125, got ${breakdown.cacheWrite}`); +}); + +test('cache-write pricing falls back to the 5-minute rate when no TTL split is present', () => { + // Backward-compatible: a record with only the flat cache_creation_input_tokens + // (older logs / proxies) is billed entirely at the 5-minute rate. + const breakdown = calculateCostBreakdown( + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 1_000_000, + cache_read_input_tokens: 0, + }, + 'claude-opus-4-8' + ); + assert.ok(Math.abs(breakdown.cacheWrite - 6.25) < 1e-9, `expected ~6.25, got ${breakdown.cacheWrite}`); +}); + test('getModelPricing falls back to the right family for an unknown snapshot', () => { // An unreleased Opus snapshot isn't in the exact table; it should still // resolve to the current Opus tier ($5/MTok input) rather than a wrong rate. diff --git a/src/types.ts b/src/types.ts index 3d6cb3f5..5b366963 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,13 @@ export interface ClaudeUsageRecord { output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; + // TTL split of cache_creation_input_tokens, as Claude Code writes it. + // Lets cost pricing bill 1-hour writes (2x input) apart from the default + // 5-minute writes (1.25x input); absent on older logs / proxies. + cache_creation?: { + ephemeral_1h_input_tokens?: number; + ephemeral_5m_input_tokens?: number; + }; }; model?: string; id?: string; @@ -278,7 +285,11 @@ export interface ExtensionConfig { export interface ModelPricing { input_cost_per_token?: number; output_cost_per_token?: number; + // 5-minute cache write rate (the default Claude Code writes). cache_creation_input_token_cost?: number; + // 1-hour cache write rate (2x base input for Claude). Optional: when absent, + // any 1-hour cache-write tokens fall back to the 5-minute rate above. + cache_creation_1h_input_token_cost?: number; cache_read_input_token_cost?: number; } From 547d52af7a07664f95520745fec8373a8ca35efe Mon Sep 17 00:00:00 2001 From: Zeyu Tang Date: Sun, 5 Jul 2026 09:08:42 -0700 Subject: [PATCH 10/12] Drop unused @types/glob to clear vulnerability (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * @types/glob was unused and deprecated (glob ships its own types) * it transitively pulled glob → minimatch → brace-expansion * removing it clears 3 high-severity advisories; audit now clean * never shipped to users (devDependency; node_modules excluded from the vsix) --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 1df88e62..6147b02d 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "test": "npm run compile && node --test out/test/*.test.js" }, "devDependencies": { - "@types/glob": "^9.0.0", "@types/node": "24.0.15", "@types/vscode": "^1.74.0", "typescript": "^5.8.3" From 3badd1c3c0ab7d670fa9c3b9c70f9b14dd02a243 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:53:16 +0800 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20V2.2=20=E2=80=94=20usage=20share?= =?UTF-8?q?=20card,=20conversation=20viewer,=20insights=20suite,=20heatmap?= =?UTF-8?q?=20&=20more=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: V2.2 changelog scaffold, v2.0.3 + v2.1.0 dates, org metadata - CHANGELOG: add [2.2.0] — Unreleased; date [2.1.0] — 2026-06-26; add the missing [2.0.3] maintenance section (macOS Keychain, node:test, issue templates, CI hardening, release workflow). - package.json: repository / bugs / homepage now point at the org (ClaudeCodeUsage/ClaudeCodeUsage), not jack21. Co-Authored-By: Claude * feat(settings): tokenDecimalPlaces — decimals for compact token display New setting (default 1, 0–2) controlling only compact token rendering (1.2M / 345.6K), separate from cost decimalPlaces; full integer counts are unaffected. Wired through I18n.formatTokensCompact and the formatNumber compact path, the settings catalog + SETTINGS_I18N (5 locales), ExtensionConfig and config apply. 7 new node:test cases (0/1/2 places, K/M/B, integer pass-through, clamp). Co-Authored-By: Claude * feat(quota): clean status-bar quota text; opt-in reset + 5h-only Status bar stays clean — default '5h 6% · wk 1%' (space, middot), not the dense '5h:6%:4.8h | wk:1%:1.6d'. Two opt-in settings (both default off): - showResetInStatusBar → '5h 6% ↻4.8h | wk 1% ↻1.6d' (compact countdown, bar sep) - quotaFiveHourOnly → show only the 5h window Full reset times always remain in the tooltip. Extracted a pure, dependency-free src/quotaFormat.ts (formatQuotaStatusText / compactReset / worstShownUtilisation) so it's unit-tested without vscode; statusBar.updateQuota now uses it. Catalog + SETTINGS_I18N (5 locales) + config wiring. 8 new node:test cases. Co-Authored-By: Claude * feat(settings): dashboardAutoRefresh (positive) replacing pauseDashboardRefresh Double-negative 'Pause dashboard auto-refresh' becomes the positive 'Dashboard auto-refresh' (key dashboardAutoRefresh, default true). One-shot SettingsStore.migrateDashboardAutoRefresh inverts any old pauseDashboardRefresh value (globalState or settings.json) — true→false, false/undefined→true — behind its own migration flag so it runs even post-v1-migration. All consumers (extension refresh gating, webview body auto-off class + toggle handler) and SETTINGS_I18N (5 locales) updated. Compile clean; 26/26 node:test green. Co-Authored-By: Claude * feat(v2.2): share-card + heatmap pure-logic cores (tested) Tested, dependency-free foundations for the two big V2.2 features; the webview rendering + PNG export + heatmap cells will build on these in follow-up PRs. - src/shareCard.ts (Usage Share Card / 用量卡片): totalTokens, cacheSharePct, modelFamily (no version exposed), selectShareBadge (deterministic, local rules: Context Marathoner / Cache Saver / Token Sprinter / Workflow Pilot / Steady Builder), buildShareCardData (privacy redaction — project name OFF by default, no room in the data shape for paths/ids/prompts), DEFAULT_SECTIONS, shareCardFilename. - src/heatmap.ts: daysInMonth, intensityBucket (0..4), buildMonthHeatmap (zero- fills every day, tokens/cost/sessions metric switch). - 11 new node:test cases (Phase 5.8 + 6.4). 37/37 green. Co-Authored-By: Claude * docs: V2.2 roadmap + V2.3 achievements spec - docs/V2.2.md: what shipped in 2.2 (status-legended), the Usage Share Card design + enforced privacy model, the token heatmap design, the settings changes, session/quota status, the contributor/PR credit policy, and the planning items (advice 'new session/clear' nudge, Optimizer opt-in file reading, conversation export, subscription sharing, Codex adapter, GitHub profile heatmap). - docs/V2.3-achievements.md: SPEC ONLY (no V2.2 code) — positioning, privacy, the full achievement catalogue (token/session/prompt/workflow/model/plugin/ hidden), levels, anti-gaming, community roadmap, the icon-prompt template, milestones, and the GitHub-profile sharing plan. Co-Authored-By: Claude * docs(changelog): fill [2.2.0] with shipped items Co-Authored-By: Claude * docs: consolidate V2.2 specs into one local master Fold the public roadmap (docs/V2.2.md) and the two local working specs (V2.2-FEATURES-SPEC.md, V2.2-AUTOMATION-SPEC.md) into a single gitignored master, docs/V2.2-SPEC.md (+ a zh-CN mirror for review), so the whole V2.2 plan can be handed to one execution pass. Drop the CHANGELOG pointer to the now-removed public roadmap; the Added/Changed entries already record what actually shipped in 2.2.0. * fix(stats): drop API-error retry re-logs from the Messages count When a request errors, Claude Code retries and re-logs the same user prompt, so an identical prompt can appear several times seconds apart around a system:api_error record — inflating "Messages". Dedupe identical prompts that recur within a short window (retry re-logs); genuine re-sends minutes/hours later still count. Logic isolated in a pure, unit-tested module. Verified on real sessions: removes only true retry re-logs, keeps repeats. Co-Authored-By: Claude * feat(dashboard): cache-hit-rate column in the breakdown tables Adds a cache hit rate (cacheRead / input-side tokens) column to the All-time monthly table, the This-month daily table, and both expanded drill-downs (month→days, day→hours), so cache efficiency is readable per row. Reuses the existing cacheHitRate/formatPercent helpers server-side and a small cacheHitPct helper client-side; detail-row colspans bumped 8→9. Co-Authored-By: Claude * fix(dashboard): timezone-correct date bucketing + table scroll behavior Bucketing: the month/day breakdowns mixed a UTC day key with a local month boundary, so a record just after local midnight on the 1st was filed under the previous month's last day (visible as a stray "30th" inside This-Month). Day and month keys now both derive from the configured timezone (empty = system) via a pure, unit-tested dateKeys module. Table scroll (2b): number cells no longer wrap — compact k/M numbers fit the panel with no horizontal scrollbar; full integer numbers overflow so only the table scrolls, while the daily bar chart keeps its own independent scroll. Co-Authored-By: Claude * feat(dashboard): click a month's Token composition to drill into daily The All-time Token-composition bars are now clickable (each is a month): a click expands that month's detail — which now includes a per-day composition chart next to the daily chart and table — so the input/output/cache split is readable day by day. Reuses the existing month-detail round-trip (toggleMonthlyDetail → getDailyData), with a small client-side composition renderer mirroring the server one. Co-Authored-By: Claude * fix(i18n): finish wiring pt-BR (settings dropdown + READMEs + CLAUDE.md) pt-BR shipped in 2.1.1 (#48) with the strings, package.json enum and SupportedLanguage type, but was missing from the dashboard language dropdown (settings.ts enumValues) and every README's UI-language list — so users couldn't actually pick it. Added it in all those places and noted it in CLAUDE.md (which also flags that de-DE strings exist but are exposed nowhere). Co-Authored-By: Claude * feat(heatmap): GitHub-style contribution-graph SVG core (Claude orange) Pure, unit-tested heatmapSvg module: lays a calendar year of daily usage (Jan 1 → today, no future padding, GitHub current-year style) into a contribution grid and renders a self-contained SVG — Claude-orange intensity ramp, title row (no month-label overlap), month + weekday labels, Less/More legend, a bottom-left source watermark, per-day tooltips — for a GitHub profile README. Not wired to a command yet; a real-data preview was generated. Co-Authored-By: Claude * feat(heatmap): GitHub-style contribution-graph SVG core (Claude orange) Pure, unit-tested heatmapSvg module for a GitHub profile README: trailing ~year ending today (GitHub's default view, no future padding), Claude-orange intensity ramp, a top-left summary ("5.3B tokens in Claude Code · 2026"), GitHub-style per-day tooltips ("1.2M tokens on June 18th"), month + weekday labels, Less/More legend, and a bottom-left source watermark. Not wired to a command yet. Co-Authored-By: Claude * feat(heatmap): Export Token Heatmap command (GitHub-style SVG) Adds "Claude Code Usage: Export Token Heatmap (GitHub style SVG)" — builds a per-day usage map (timezone-aware, via getDailyUsageMap) and writes the contribution-graph SVG to a chosen file, offering to copy the Markdown embed snippet for a GitHub profile README. Command-palette accessible. Co-Authored-By: Claude * feat(heatmap): larger fonts (title 15 / months 12 / labels 11), 12px cells * feat(dashboard): opt-in token heatmap on the All tab (showHeatmap, default off) Adds a showHeatmap setting (default off). When on, the All tab shows a GitHub-style yearly token heatmap at the top, built from allRecords via getDailyUsageMap + renderHeatmapSvg. Inline SVG, so per-day hover tooltips work in the dashboard (unlike GitHub's image-sanitised READMEs). Mainly a shareable re-view of existing data, hence opt-in. Co-Authored-By: Claude * fix(stats): exclude the compaction "session continued" summary from Messages Verifying the Messages count on real logs surfaced ~5 counted "prompts" that were actually Claude Code's auto-injected "This session is being continued from a previous conversation…" compaction summary (a user-role message the user never typed). isSyntheticUserText now drops it, alongside interrupted-request and slash-command echoes. (Tool-result user messages were already excluded.) Co-Authored-By: Claude * fix: timezone is a validated dropdown, not free text (#51) The reporter suggested a dropdown of valid zones — the right fix, since it makes an invalid value impossible to enter. The Timezone setting is now an enum populated from Intl.supportedValuesOf('timeZone') ('' = system default). Kept a validation guard in I18n.setTimezone as belt-and-suspenders for old synced configs that already hold a bad hand-typed value (which otherwise made Intl throw and crashed the dashboard). Also the explicit claude-sonnet-5 pricing entry. Unit-tested. Co-Authored-By: Claude * feat: one-time "what's new" prompt after an upgrade (feature discovery) Shows a single dismissible notification the first time a new major.minor runs, pointing at the dashboard — so users find new features, including the opt-in default-off ones they'd otherwise never notice. Keyed by major.minor in globalState (patch releases don't re-nag); skipped on fresh installs. Co-Authored-By: Claude * feat(content): opt-in top-10 costliest conversations (showEfficiency) New showEfficiency setting (default off — not everyone wants cost metrics). When on, the Content tab gains a "Top 10 costliest conversations" panel: rows ranked by cost, each expandable (native
, no JS) to its tokens, cache-hit rate, top model and project. Same toggle will gate the cost-per-message + cache-savings chips on Today/projects (next). Co-Authored-By: Claude * feat(content): efficiency chips — cost per message + realised cache savings Under the usage summary (Today / Month / All), gated by showEfficiency: an exact "cost per message" chip (totalCost ÷ messages) and a "cache savings" chip (cacheRead tokens × the per-model input−cacheRead price gap — what caching actually saved vs. full input price). Off by default. Co-Authored-By: Claude * feat(share-card): render usage share card as self-contained SVG + export command Batch 6 (V2.2 share card) — make the tested shareCard.ts core visible/exportable. - shareCardSvg.ts: renderShareCardSvg(ShareCardData) -> SVG. No DOM, no html-to-image dep, no CSP concerns. Landscape 1200x630 social-card layout: header, big token hero (falls back to cost/sessions), up to four stat tiles, a per-day rhythm strip, badge pill, and watermark. Only draws fields present in ShareCardData, so privacy stays enforced by the type's shape. - ClaudeDataLoader.buildShareInput(records, range): aggregate inputs for today / last 7 days / this month, timezone-bucketed to match the dashboard. Returns only aggregate numbers (no records, no identifiers). - extension.ts: "Export Usage Share Card (SVG)" command — pick range, save, and open for review. - 8 renderer tests (well-formed output, hero fallback, omitted sections, tiles/badge/rhythm, XSS-escaping, watermark toggle). 75/75 pass. Co-Authored-By: Claude * fix(efficiency): scope efficiency chips to the Today view Per spec the cost/message + cache-savings chips belong on Today (and per-project), not on the Month / All-time summaries. renderUsageData now takes an { efficiency } opt and only Today passes it, so the chips no longer leak onto the other two scopes. Still opt-in via showEfficiency. Co-Authored-By: Claude * feat(efficiency): add opt-in "Cost/msg" column to the projects table Completes the "Today + projects" efficiency surfacing. Projects is a table, so per-project cost-per-message reads better as a column than a summary chip. Gated by showEfficiency (off by default); shows "—" when a project has no counted messages. Non-sortable column — sortTable() reads data-sort-* row attributes, so inserting it doesn't disturb the other columns' sorting. Co-Authored-By: Claude * feat(v2.2): efficiency tokens/msg chip, curated tz dropdown, quota Xd Yh, session-delete opt-in, whats-new preview Batch of the smaller items from Carl's review: - Efficiency chips: add "Tokens / message"; show on Today AND Month AND All-time again (removed the Today-only scoping). Projects keeps its column. - Timezone: trimmed the ~400-zone dropdown to a curated common set, each labelled with its live (DST-aware) UTC offset, e.g. "(UTC+08:00) Shanghai". Settings enum renderer now keeps a stored-but-uncurated value selectable. - Quota tooltip: weekly reset countdown now "4d 12h" instead of "4.5d" (statusBar.formatCountdown); the compact status-bar form keeps "4.5d". - Session delete: gated behind new opt-in "enableSessionDelete" (off by default) — it touches local history files, so it shouldn't be on for everyone. Resume tooltip now explains what resume does (EN/zh-CN/zh-TW). - What's-new: extracted showWhatsNew(); added "Preview What's New" command so it's testable (a fresh F5 install otherwise just sets the baseline silently). Compile clean, 75/75 tests pass. Co-Authored-By: Claude * feat(content): rework "costliest" panel to top-10 single MESSAGES The old panel just re-ranked sessions (a dupe of the Sessions tab). Carl wants the Content tab's version to fit that tab's theme (AI advice / optimizer): rank individual assistant turns and let each expand to explain itself. - getCostliestMessages(records, limit): walks each session in timestamp order, attaches the preceding user prompt, and keeps the top-N billed turns by cost (bounded insertion). Each carries the full model id, the authoritative skill/plugin attribution, the 4 token types, cost, prompt, project. - Store a truncated _promptText (300 chars) on the synthetic user-prompt records so the panel can show what triggered an expensive turn. - Webview: renderCostliestMessages() — summary row = rank + prompt + cost; expands to the prompt (blockquote), model, skill, token breakdown, project. Precomputed in the extension and passed in (not re-walked per re-render). Verified on real data (8855 records): top-10 with prompts + full model ids (fable-5 / opus-4-8 / opus-4-7) attach correctly. Co-Authored-By: Claude * feat(share-card): discoverable in-dashboard preview + richer card (composition, QR, full model, peak) Addresses Carl's share-card review: - Discoverable: an always-visible "Share card" panel on the All tab with an inline, responsive preview + "Export as SVG…" button (the command alone was unfindable). Export now matches the preview (composition on). - More metrics: a token-composition stacked bar (the four billed token types) with a legend — off in DEFAULT_SECTIONS but on in the dashboard/export. - Bar scale: the daily-rhythm strip now has a baseline, a peak gridline and a "peak " label so the bars carry a scale. - Full model name: prettyModelName() → "Opus 4.8" (not just "Opus"); the card shows the specific model. - Traffic (引流): a real QR to the upstream repo bottom-right, wrapped in a link + "★ Star us on GitHub". QR is a static embedded asset (generated once with `qrcode`, then uninstalled — zero runtime deps). See shareCardQr.ts. - Canvas grew to 1200×760 to fit the new blocks; layout verified overlap-free. +6 tests (QR link, full model, composition bar, peak label, prettyModelName). 80/80 pass, compile clean. Co-Authored-By: Claude * fix(content): show hidden thinking honestly + full scrollable prompt in top-10 Two fixes from Carl's review: #6 Thinking-share = 0 bug. Models that omit their raw reasoning (Fable 5; Opus 4.8 by default) log `"thinking":""` with the CoT encrypted in `signature`, so the text-length estimate read 0% even though thinking was on and billed. Now: detect a thinking block with empty text + a signature and flag the session (ThinkingShare.hiddenThinking). The Sessions "thinking share" column shows "hidden" (tooltip explains) instead of a misleading 0%; a partly-hidden session shows "N%*". i18n across all 7 languages. #1 Top-10 costliest messages: raise the stored prompt cap 300 → 4000 chars and render the prompt in a scrollable box (max-height 220px) so the whole triggering prompt is visible for cost analysis; giant pastes still capped + marked "(truncated)". Verified on real data: 33 measurable / 2 hidden / 14 genuine-zero sessions. Compile clean, 80/80 tests pass. Co-Authored-By: Claude * feat(settings): timezone dropdown covers all IANA zones (Common + All optgroups) #3: the curated list was nicer but not complete. Now enumValues holds every IANA zone (418) so any is valid, and the dropdown groups them with : "Common" (25 curated, offset-labelled) at the top for quick picking, then "All zones" (the remaining 395, alphabetical, also offset-labelled). Added an optional enumGroups to SettingDef; the webview enum renderer draws optgroups when present and still injects a previously-stored out-of-list value. Co-Authored-By: Claude * feat(share-card): configurable, default-off share panel (range/scope/metrics, on-demand) Overhaul from Carl's review (Q2): - Drop the QR (deleted shareCardQr.ts) — watermark now carries the repo URL. - Default OFF: new `enableShareCard` setting; the All-tab panel shows a discoverable pointer to Settings until enabled. - Config IN the plugin: range (this month / last 7d / last 12 months / today / last 12 specific calendar months) + scope (overall / a project / a session) + per-metric checkboxes. buildShareInput now takes range+scope; supports `month:YYYY-MM` and `year`, resolves a project/session filter, and returns dailyDates + a human range label ("June 2026"). - Generate-on-demand: nothing renders until "Generate preview" (no per-keystroke re-render). Export uses the same config (webview builds + saves). - More selectable sections incl. a new `messages` (message count); default ON = est. cost + top model + cache-hit (+ token hero, rhythm, badge). Sessions, messages, composition, project name are opt-in. - Token composition legend now shows the absolute amount as well as the %. - Rhythm strip labels the first & last date so the x-axis is meaningful. - Buttons reuse the plugin's .btn-secondary style. Canvas back to 1200×680. Verified on real data (month/year/specific-month, project & session scope). Compile clean, 83/83 tests pass. Co-Authored-By: Claude * feat(heatmap): publish token heatmap to GitHub via built-in auth (Q4) One-click "Publish Token Heatmap to GitHub" — uses VS Code's GitHub authentication (getSession 'github' ['repo'], no PAT handling), then creates or updates the heatmap SVG in a repo via the Contents API. Defaults to the user's profile repo (owner/name = username) so it renders on their GitHub home page; remembers the last repo/path. Buttons added to the heatmap panel (Export / Publish to GitHub) + a command-palette entry. Minimal https helper (the codebase avoids fetch). Supersedes the spec's earlier "no GitHub in V2.2" note — Carl approved it for V2.2. Co-Authored-By: Claude * feat(ci): mention-driven @claude agent (Track 2 A1) — inert until owner enables First piece of the V2.2 AI automation. A maintainer can summon Claude with "@claude ..." in an issue / comment / PR review; it reads the repo (CLAUDE.md, ARCHITECTURE.md, CONTRIBUTING.md) and can reply or open a PR. SAFE BY DEFAULT — the job's `if:` requires BOTH a repo variable AUTOMATION_ENABLED='true' (kill switch) AND the ANTHROPIC_API_KEY secret, and only fires for OWNER/MEMBER/COLLABORATOR authors. With neither set it never runs and spends no budget. `anthropics/claude-code-action@v1`, --max-turns 12, timeout 15m, concurrency-guarded. A2 (triage draft-queue) and A3 (PR first-pass review) — the auto-posting pieces — are the next, separately-reviewed step; they need the owner to add the ANTHROPIC_API_KEY secret first. See docs/V2.2-SPEC.md Track 2. Co-Authored-By: Claude * feat(settings): timezone dropdown = full UTC-offset coverage (not every city) Carl clarified Q3: it's the UTC *offsets* that must be complete, not every location. Replaced the 418-city "All zones" group with a "UTC offset" group covering every offset (-12:00 … +14:00, incl. the real :30/:45 ones). Whole hours use fixed-offset Etc/GMT zones (no DST); fractional offsets use a representative zone with a region hint. "Common" named zones stay at the top. Co-Authored-By: Claude * feat(content): quantify cache-miss in top-10 costliest messages (Q7) Seeing the prompt alone didn't explain the cost. Each costly message now carries its cost split by token type (cache write / read / output / uncached input), and the panel shows the main cost driver (e.g. "cache writes (miss) — 96% of cost"), the cache-hit rate, and the full split. On real data the top turns are ~0-2% cache-hit and cache-write-dominated — i.e. the context was re-sent uncached (a cache miss), not that the answer was long. Now distinguishable at a glance. Co-Authored-By: Claude * feat(share-card): portrait + size presets, avatar, hourly today, tidy panel (Q1/Q2/Q3/Q5/Q6 review) Share-card round 2 from Carl's review: - Layout rewritten to flow vertically and distribute spare height as even gaps, so the same content reads well at every size. Size presets: Landscape 1200×680, Square 1080×1080, Portrait 1080×1350, Story 1080×1920 (portrait for phones). Watermark pinned to the bottom with a clear gap — fixes the date/name overlap. - Rhythm bars are centred + width-capped, so short ranges don't look sparse; "today" now uses HOURLY buckets (24 bars, 00:00→23:00) instead of one bar. - Avatar: opt-in "GitHub avatar" fetches the signed-in user's avatar (read:user, on demand, cached) and embeds it (circular, top-right, replacing the badge). Renderer supports opts.size + opts.avatarDataUri. - Config panel beautified to the plugin's card language (bordered card, field grid, chip checkboxes, checkerboard preview frame) + a Size select; the range dropdown is grouped (Rolling / Specific month) with a "(last month)" marker. - Heatmap→GitHub publish now shows a consent modal first (it's an auth+write action, unlike a local export). +4 tests (size presets, avatar embed). 85/85 pass, compile clean. Co-Authored-By: Claude * feat(settings): group opt-in toggles under "Optional features" (Q3) Reorganized the settings for user habit: the four opt-in dashboard toggles (token heatmap, efficiency insights, share card, delete-session action) move out of the General grab-bag into a dedicated "Optional features" group, so General holds just locale/format/behaviour. New SettingGroup 'features' + i18n label across all 7 languages. (Share-card range dropdown grouping / "last month" shipped in af3509e.) Co-Authored-By: Claude * feat(ci): let the @claude agent use a third-party Anthropic-compatible endpoint (Q8) A Claude subscription can't drive CI (Actions needs a key). For maintainers without an official Anthropic key, the mention agent now honours a repo variable ANTHROPIC_BASE_URL (env on the action step) so it can point at any Anthropic-format-compatible provider, with that provider's key in the ANTHROPIC_API_KEY secret. Unset = official API. Still inert until enabled. Clarified that repo guidance is read from CLAUDE.md/ARCHITECTURE.md (no full-repo re-read each run). Co-Authored-By: Claude * fix(quota): don't fabricate a 5h reset countdown while idle after a reset (#3) The 5h / weekly windows are usage-anchored: the next window (and its reset) only starts when you send your next message. When a window's resets_at passed while idle, liveWindows() rolled it forward ("reset = old + 5h") and showed a countdown that was wrong until the next real fetch — exactly the "reset already ticking before I sent anything, then jumps when I do" bug. Now a recently-expired window shows 0% with NO countdown (resets_at cleared → "—"); the real reset lands on next use. (Long-expired still drops; unparseable still kept.) Co-Authored-By: Claude * feat(share-card+content): round-4 review — landscape-only, last-30 default, full numbers, >4 fix, avatar/refresh fixes, top-10 privacy toggle, neutral tz labels Share card: - Size selector removed — landscape 1200×680 only for now (other sizes need per-size tuning; deferred to a patch). Aesthetic redesign is coming separately. - Default range is now the last 30 days (added `last30`), not "this month". - New "Full numbers" toggle shows the exact token count in the hero (font auto-shrinks so it fits). - Fixed the >4-metrics bug: tiles allow up to 6 and wrap to a second row. - Rhythm first/last date labels now sit UNDER their bars (centred), not pinned to the axis ends — correct for short, centred ranges. - Avatar fix: httpsGet now follows redirects (GitHub avatars 302 to a CDN — not following them was why the avatar came back blank); + xlink:href for export. - Refresh no longer wipes the card: the last generated SVG + the config (range/ scope/metrics/toggles) are remembered and rehydrated on re-render. (Same class of issue Carl hit with the optimizer output.) Content: the "top 10 costliest messages" panel is now its OWN opt-in (`showCostliestMessages`, off) — separate from the efficiency chips — because it displays your prompt text (privacy). Timezone: labels use canonical IANA identifiers (official/apolitical) — dropped the editorialised country groupings; the UTC-offset group shows only the offset. 85/85 tests pass, compile clean. Co-Authored-By: Claude * feat(content+webview): top-10 input-interval metric, keep expanded rows on refresh, fix avatar CSP #1 Top-10: each costly turn now shows "Time since last turn" (gap to the previous billable turn in the same session; "New conversation" for the first), and both Cache-hit rate and the gap are bold. A gap past the ~5-min cache TTL is flagged. On real data the priciest turns pair 0-2% cache-hit with multi-hour gaps — i.e. the cache went cold while idle, then had to be rewritten. Now obvious at a glance. #2 Auto-refresh no longer collapses expanded
rows — open state is saved (vscode.setState) and restored across re-renders, so a data refresh doesn't interrupt reading. Switching tabs resets to default (as asked). #7 Avatar root cause: the webview CSP had no img-src, so it inherited default-src 'none' and blocked ALL images incl. data: URIs — the avatar never rendered even once fetched. Added `img-src data:;` (data URIs only, still no external hosts). Combined with the earlier redirect-follow fix, the avatar should now show. 85/85 tests pass, compile clean. Co-Authored-By: Claude * feat(ci): AI auto first-pass on every issue + PR; rename mention handle to @ccu-bot (Q4) Carl authorized auto-replies and added the API key (repo secret, third-party 1M-context, ANTHROPIC_BASE_URL-capable). Two new workflows: - issue-first-pass.yml (issues: opened): posts ONE first-pass comment. - pr-first-pass.yml (pull_request_target: opened): posts ONE first-pass review; base-repo checkout only, reads the diff via API — never runs PR code. Both: labelled "🤖 Automated first-pass reply (via Claude Code)", end with a model-generated / a-maintainer-follows-up disclaimer, read ARCHITECTURE.md/ CLAUDE.md/CONTRIBUTING.md and — if those are insufficient — the repo files (never guess), reply in the author's language. Skip bot authors; concurrency + timeout + max-turns. Inert until AUTOMATION_ENABLED='true'; third-party endpoint via ANTHROPIC_BASE_URL. Renamed the mention trigger @claude → @ccu-bot (the model may be third-party, so "@claude" was misleading). A2 draft-queue stays deferred until this runs stably. Co-Authored-By: Claude * feat(share-card): avatar + badge + GitHub name together (personalized corner) Carl: avatar and badge shouldn't be mutually exclusive, and a name adds personality. The top-right corner now shows the avatar circle with a two-line name / badge block to its left — any subset renders. New opt-in "GitHub name" checkbox; getGithubIdentity() fetches avatar + display name in one call (cached), so the name and avatar share the single read:user sign-in. Config (avatar/name) is persisted across refresh like the rest. Co-Authored-By: Claude * feat(content): detect model-switch cache flush + estimate the cache TTL (Q1) Model switch: top-10 messages now carry the previous turn's model; when the hit rate is low the panel names the likely cause — "model switch (Opus→Sonnet) flushed the cache" vs "idle Xh — cache went cold". Distinguishes the two cache-miss reasons Carl noticed (a 17m/1h25m gap with a low hit rate is usually a switch, not idle expiry). Cache-TTL estimate (Carl approved for V2.2): estimateCacheTtl() buckets same-model consecutive turns by idle gap and finds where the hit rate drops. On real data the hit rate holds ~100% to ~60 min then falls off — i.e. the warm window is ~1 hour here, NOT the assumed 5 min. Surfaced as a "Cache warmth" insight on the Content tab (opt-in via showEfficiency; aggregate only, no prompts): "cache stays warm ~60 min; batch complex work within that window." 86/86 tests pass, compile clean. Co-Authored-By: Claude * feat: @ccu-bot contributor note, data-contribution scaffold; graceful arch-doc fallback - CONTRIBUTING.md: section explaining @ccu-bot (labelled + disclaimed, comment-only, ask-not-guess, off unless the maintainer enables it). (Q9) - src/dataContribution.ts: opt-in data-contribution SCAFFOLD for future cross-user features (cache-TTL by tier/task, etc.). OFF by default, no endpoint, no-op transport; Observation type has no identifying field; tier redacted unless shared. +4 tests. (Q2) - Automation: ARCHITECTURE.md is gitignored (a private local context pack), so CI can't read it — the first-pass prompts now say "ARCHITECTURE.md if present" and fall back to CLAUDE.md + reading src/. Whether to publish a scrubbed technical ARCHITECTURE.md is Carl's call (see docs/ARCHITECTURE-private.md). (Q6) 90/90 tests pass, compile clean. Co-Authored-By: Claude * feat(share-card): implement GPT "Code Pulse · Aurora Console" redesign with dual themes Executes docs/V2.2-ShareCard-GPT-design.md. One renderer, theme tokens: - Two themes: **Claude Cream** (default — warm/editorial) and **Aurora Dark** (technical/social). SHARE_CARD_THEMES token set drives bg, panels, text, accents, composition/rhythm colors, badge. Theme option auto|claudeCream| auroraDark; "auto" follows the VS Code theme (resolveShareCardTheme). - New composition: aurora gradient bg + soft blobs, brand block ("Claude Code Usage" / "AI coding usage snapshot" / range), glass stat tiles with an accent rail, token-mix bar (semantic colors), daily-pulse strip (peak highlighted), brand-signature watermark. No longer looks like a dashboard screenshot. - Badge redesigned as a sticker card with on-brand copy + a one-line explanation (BADGE_COPY: Cache Alchemist "缓存命中高,token 没白烧" etc.), sharing the corner with the avatar + GitHub name. - Defaults follow the design: hero + 4 tiles (cost/cache/model/sessions) + token mix + daily pulse + badge; messages/project/workflow/peak opt-in. - Theme selector added to the config panel (default Claude Cream), persisted. Palette export command + webview both theme-aware (isDark for "auto"). - Landscape 1200×680 only (square/portrait deferred, per the brief). Privacy/self-contained constraints unchanged. Tests updated for the new look + theme parsing/default/auto/colors/hidden-fields. 89/89 pass. Co-Authored-By: Claude * docs: publish the technical ARCHITECTURE.md (Q6 — Carl chose to publish) Un-ignored ARCHITECTURE.md + ARCHITECTURE-zh-CN.md so they're public: they're already technical-only (module map, data flow, exact token/cost computation, log-format facts, invariants, refresh, release), so nothing needed scrubbing. Now the @ccu-bot first-pass automation and contributors get a real technical map instead of re-reading src/. Maintainer product-direction stays private in docs/ARCHITECTURE-private.md; the public file must remain technical-only. Co-Authored-By: Claude * feat(share-card): add Claude Classic (orange) theme + adaptive layout fixes - New "Claude Classic (orange)" theme — the pre-redesign single-hue orange (monochrome orange ramp for the token mix, no violet/mint), for Carl to compare against the busier Claude Cream. Aurora Dark unchanged. - Layout: content moved up (contentTop 134); the hero now sits at a fixed top-left position and the full-width sections flow BELOW whichever of the hero / top-right corner reaches lower — so enabling the GitHub avatar no longer pushes the whole card down and off the canvas (the reported overflow). Verified: with avatar, max content y=592, watermark at 652, all within 680. - Token mix gets internal padding (h 64→84) so it no longer crowds the tiles above / pulse below; inter-section gaps clamped to [14,38] (not hollow, not cramped). Adapts to whichever sections are enabled. 89/89 tests pass, compile clean. Theme selector now offers Cream / Classic / Aurora Dark / Auto. Co-Authored-By: Claude * docs(architecture): bring the public technical map current (V2.2) - Fixed stale facts: 6→7 languages (added pt-BR); the "~5-min cache TTL" note corrected to the MEASURED ~60 min (estimateCacheTtl infers it from the user's own turns). - Added a "V2.2 additions" table: shareCard/shareCardSvg (dual themes), heatmap/heatmapSvg + GitHub publish, dateKeys, promptDedup, quotaFormat, dataContribution; new dataLoader methods (getCostliestMessages, estimateCacheTtl, buildShareInput); efficiency/cache insights; thinking-hidden; UTC-offset timezone; and the @ccu-bot first-pass CI (inert unless enabled). Keeps the file technical-only; product direction stays in the private companion. The @ccu-bot automation now reads an up-to-date map. Co-Authored-By: Claude * feat(share-card): default to Claude Classic (orange) theme Carl's pick — Claude Classic is now the default (dropdown first, panel/export/ handler defaults, and resolve's light fallback); Cream + Aurora Dark + Auto stay available. Tests updated. Co-Authored-By: Claude * chore(v2.2): settings i18n for new toggles (7 langs), resume tooltip de/ja/ko/pt, what's-new + CHANGELOG Pre-release polish so V2.2 is release-ready: - SETTINGS_I18N: added the 5 new opt-in toggles (showHeatmap, showEfficiency, showCostliestMessages, enableShareCard, enableSessionDelete) in de-DE / zh-TW / zh-CN / ja / ko / pt-BR, and refreshed the timezone help (now UTC-offset). - Resume-session tooltip: the fuller "reopens in Claude Code / claude --resume" explanation now in de/ja/ko/pt too (en/zh already done). - What's-new toast copy updated for the real V2.2 (share card, GitHub heatmap publish, efficiency + cache insights). - CHANGELOG [2.2.0]: added share card (themes), heatmap publish, top-10 costliest MESSAGES (cost split / cache-miss / interval / model-switch), cache warmth, efficiency chips, session-delete opt-in, UTC-offset timezone, and the thinking-hidden / quota-format / refresh-persist / avatar-CSP fixes. 89/89 tests pass, compile clean. Co-Authored-By: Claude * feat(share-card): localize the card to the UI language (no en/zh mixing) Carl: the card was English-only, and the badge line was Chinese even on an English card. Now the whole card (subtitle, hero unit, tile labels, token-mix legend, pulse, watermark, and the badge title + line) renders in the UI language. Fully localized for en / zh-CN / zh-TW (the maintainer's languages); any other UI language falls back to English — so a card never mixes languages. Card strings + per-language BADGE_COPY live in shareCardSvg.ts (self-contained + tested); the webview/export pass I18n.getLocale(). Badge line width widened so English lines aren't clipped. 90/90 tests pass. de/ja/ko/pt card localization = a follow-up (they fall back to en for now, consistently). Co-Authored-By: Claude * fix(settings): heatmap toggle references the actual "All" tab name per language Carl: the token-heatmap toggle said "全部标签" (unclear), not the real tab name. Now the parenthetical matches each language's All-time tab label — zh-CN "("所有"选项卡)", zh-TW "(「所有」分頁)", ja "(「すべて」タブ)", de/pt use their tab labels; en "(All-time tab)". Co-Authored-By: Claude * feat(share-card): Chinese cards use 万/亿 units + hero keeps one decimal Carl: on a zh-CN card the token unit should read in 万 / 亿 (万 / 億 for zh-TW), not M/K — and the hero total should always show one decimal (e.g. "5.1亿", "5.0B"). compact() is now language-aware (万/亿 for Chinese) with a keepDecimal flag the hero uses; tiles, composition amounts and the pulse peak follow the card language too. Co-Authored-By: Claude * feat(analysis): cache-churn cost data layer (缓存损耗账单) — first V2.2 analysis estimateCacheChurnCost(): the $ spent RE-writing cache that a warm cache would have served cheaply, split by the two AVOIDABLE causes — a model switch (per- model cache flush) and an idle gap past the TTL (~60 min). Only counts a rewrite of a cache that existed on the previous same-session turn (read≈0, write>0), so a genuine big-new-context isn't blamed. Waste ≈ cacheCreation × (writeRate − readRate) per model, via getModelRatesPerMillion. IMPORTANT framing (per Carl): a switch can still be net-worth-it if the other model is cheaper per token — this is the churn cost, not "never switch". On real data (30d): ~$240, mostly from idle gaps (71×) not switches (5×) — so momentum within the warm window matters more than avoiding switches here. Data layer + 2 tests now; the styled Content-tab section + model-fit (C5) land next (with frontend-design). 93/93 pass. Co-Authored-By: Claude * fix(ci): add id-token: write to first-pass workflows (sync #58 to V2.2 branch) * feat(content): "Experimental insights" section — cache-churn bill (first card) The V2.2 analysis workstream, styled to the plugin's card language (theme vars, consistent with the costly-messages / cache-warmth panels) with a small "estimate" tag as the honest signature. - New `showInsights` setting (Optional features, default OFF) — these are our own heuristics, not standardized metrics, so opt-in + labelled. - Content tab gains an "Experimental insights" section. First card: the **cache-churn bill** — headline $ (last 30 days) spent re-writing cache a warm cache would have served cheaply, split into idle-gap vs model-switch pills, with the honest note that a switch can still be worth it (this is only the churn cost). Built on estimateCacheChurnCost; aggregate only, no prompts. C5 model right-sizing (next card) + the other insights come next. showInsights settings i18n (7 langs) folded into the pre-release i18n pass. 93/93 tests pass. Co-Authored-By: Claude * polish(content): insight card — separate the caveat onto its own line + crisper copy The cache-churn card read as a wall of text. Split it: a "💡" actionable takeaway line (recoverable — keep momentum in the warm window), then the "a switch can still be worth it" caveat as a smaller, top-bordered note on its own line. Pills clarified ($ · cause · count). Reads as an insight now, not a paragraph. Co-Authored-By: Claude * chore(ci): sync controlled-API first-pass bot to V2.2 branch (matches #64) * feat(content): cache-hit-rate-by-model insight + weekly throttle for cache analyses Second insight card (Carl's top pick — cache is the key cost lever): cache hit rate per model, with the model family's nominal provider and a volume bar. cacheStatsByModel() aggregates read ÷ input-side per model over 30 days. Real data shows opus-4-8 97% / deepseek-v4-pro 98% / fable 96% etc. — so you can see which models actually serve from cache. Tip: favour low-hit models for short, self-contained tasks. (True cross-PROVIDER comparison needs pooled cross-user data — reserved via the dataContribution scaffold.) Also: the cache analyses (TTL / churn / by-model) now recompute at most WEEKLY (analysisCache), not every render — Carl's call; they move slowly. 93/93 tests pass. Co-Authored-By: Claude * feat(content): cache card shows per-model warm window (TTL), not just hit rate Carl's call: the durable, cross-user-comparable signal is how long each model's cache actually stays warm — and it drifts over time — so make TTL the primary metric. cacheStatsByModel() now also buckets same-model idle gaps per model and infers a warm window; hit rate + volume ride along as secondary. Real data is telling: DeepSeek-v4-pro stays warm ~240 min, Opus 4.8 ~60 min, Fable 5 only ~2 min (bursty, rarely resumed after a gap). That per-model spread is exactly what you can't see from a single global number. Also fixes the bar/number ambiguity Carl flagged: the bar AND the bold number are now the same metric (TTL); "—" shown when a model has too few gaps to estimate. Hit% + token volume moved to a muted sub-line. 93/93 tests pass. Co-Authored-By: Claude * feat(bot): two-tier first-pass with controlled repo-file escalation + skimmable template When the cheap model (CCU_BOT_MODEL, default deepseek-v4-flash) says it can't answer from the docs alone, escalate to a stronger model (CCU_BOT_MODEL_PRO, default deepseek-v4-pro) with thinking on, and let it NAME a few repo source files. The SCRIPT reads them — repo-relative text files only, no "..", size- capped, must resolve inside the repo root — so there's still no agent tool and no way for a malicious issue to make it read or run anything arbitrary. If it still can't answer, its reply asks for the specific missing info/logs and suggests the issue template. Also gives every reply a skim-friendly shape: TL;DR/结论 first, then analysis, then suggested next steps. Thinking param is best-effort (retries without it if the endpoint rejects it), so it stays portable across third-party endpoints. Co-Authored-By: Claude * feat(sessions): read-only conversation viewer (opt-in) — re-read a session without spending context Carl's use case: after /clear or /compact he wants to RE-READ his prompts and the model's answers to jog his memory, but resume (`claude --resume`) reloads the whole session INTO context and costs tokens. This viewer is the zero-context path — it just reads the session .jsonl and shows it. - conversationLog.ts: pure, tested parser (.jsonl text -> ordered display turns). Keeps prompts + assistant text front and centre; carries thinking / tool_use / tool_result too. Skips meta + sidechain lines, dedupes by uuid, truncates per turn, supports "last N turns". 7 unit tests. - conversationViewerHtml.ts: frontend design per Carl's ask — user PROMPTS are the star (accent rail + tint + a top "jump to your prompts" nav), model TEXT is a readable card, thinking / tool traffic is present but quiet (collapsed
, muted). No scripts (webview enableScripts:false); VS Code theme vars only; a read-only badge makes the zero-context promise explicit. - webview.ts: an eye view action on Sessions rows (gated by the new setting) -> viewConversation message -> openConversationViewer() reads the file and opens a panel. settings.ts: showConversationViewer (features, default OFF). - i18n: viewConversation button tooltip in all 7 languages. (Viewer panel chrome is English-first like the other experimental surfaces; folded into the pre-release i18n pass.) 100/100 tests pass. Co-Authored-By: Claude * feat(viewer): chat-mode conversation viewer — rendered Markdown, filled answers, default-hidden tools Round of viewer feedback from Carl: - Model answers now render Markdown (new dependency-free miniMarkdown.ts, 8 tests) instead of showing raw source — much better to read inside the panel. - Assistant answers get their OWN filled card (was plain), so they stand apart from both prompts (accent) and the muted tool traffic. - Default is a clean CHAT view: thinking and tool activity are HIDDEN behind two pure-CSS toggles (checkbox hack, no scripts) — on open you see only prompts + answers. - Opens on the last ~10 rounds; earlier rounds collapse into a
. - Prompt nav items are now expandable to the full prompt text, each with a "jump ↓" link. Co-Authored-By: Claude * feat(sessions): add "Active" column — idle-capped hands-on time The Duration column is the first→last wall-clock span, which is misleading for long-lived sessions (one real session spans 957h but only ~12.5h was hands-on). The new Active column sums the gaps between turns with each idle gap capped at 5 min (activeDurationBySession), so it reflects time actually spent. Sortable, with a tooltip explaining the estimate; labelled in all 7 languages. Co-Authored-By: Claude * fix(ci): don't auto-review maintainer PRs; treat an empty model reply as a no-op Two first-pass bot fixes (also going to main via PR): - The PR first-pass now SKIPS pull requests authored by OWNER / MEMBER / COLLABORATOR. A maintainer's own finished PR doesn't need an auto first-pass (that review is for external contributors) — and #66 showed it firing on our own PR and failing on the big self-referential diff. - An empty model reply is now a soft no-op (exit 0) instead of a hard failure, so a benign blank response doesn't leave a red ✗ check. Co-Authored-By: Claude * feat(viewer): interim-answer toggle, Markdown tables, visible jump + latest marker More viewer feedback from Carl: - Assistant answers that aren't the round's FINAL output are marked "interim" and can be toggled off (default ON — intermediate results have value); the final answer of each round always stays. - Markdown tables now render (added GFM table support to miniMarkdown + 2 tests). - Prompt-nav jump link no longer clipped on long prompts — it sits outside the truncating text (flex), always visible; the caret still expands full text. - The most recent prompt gets a "latest" badge in the nav for quick jumping. Co-Authored-By: Claude * feat(sessions,insights): active-time tuning, merge session mutating actions, C5 right-sizing One round of Carl's feedback on sessions + insights: - Active column: idle-gap cap raised 5m → 90m (reading/reviewing a diff is real work the log doesn't capture; the old cap badly under-counted). Column moved BEFORE Duration; header + cells carry the estimate tooltip. Help text updated in all 7 languages. - Resume + Delete now share ONE opt-in (enableSessionActions, default off): both ACT on the user's Claude Code (reopen / trash a log), at odds with the extension being read-only, so they hide together. The read-only conversation viewer flips to default ON (showConversationViewer) — it only reads local logs. - C5 "Premium models on lightweight turns": the compute premium spent running a top-tier model (output ≥ $20/M) on small-output turns (≤800 out — the translation / quick-edit signature), vs Haiku-4.5 rates. Surfaces a per-switch churn cost so it says "batch simple tasks on the cheap model", not "flip every turn". Real data: ~$19.74 over 2339 turns, mostly Opus. Weekly-cached. 110/110 tests pass. Co-Authored-By: Claude * refactor(insights,viewer): defer C5 card; cap prompt-nav to the last 10 - C5 "premium models on lightweight turns" is no longer rendered. Output size is a weak proxy for task difficulty, and the advice ("use a cheaper model for simple tasks") isn't something users lack — in practice they stay on the top model and tack simple work onto long tasks. A worthwhile version needs a real difficulty judgement (sampling prompt text via the opt-in AI-advice API, which must be clearly labelled). modelRightsizing() stays as dormant scaffolding. - Viewer prompt-nav is capped to the last 10 prompts (matching the recent rounds shown) so the list is a consistent length and every jump lands in expanded content; a note flags any earlier prompts folded into "Show earlier". 110/110 tests pass. Co-Authored-By: Claude * fix(viewer): cap by rounds, not raw turns — long sessions now show 10+ prompts Bug (Carl): a long session with 79 prompts showed only the latest ~3. Cause: the viewer capped at the last 500 RAW turns, but one prompt is followed by dozens of tool turns, so 500 turns held only ~4 prompts. Fix: parseConversation now caps by ROUNDS (a round = a prompt + its replies) via maxRounds, with a raw-turn ceiling as a safety net. The viewer keeps the last 15 rounds (10 shown + a small "earlier" buffer). Parser also reports totalRounds; the header now reads "N prompts total · … · showing last 15 of N rounds". Verified on the real 79-prompt / 6798-turn session: nav shows the last 10 prompts, latest badge on the newest, ~0.6 MB html. 111/111 tests pass. Co-Authored-By: Claude * feat(insights): "Big one-shot turns" card — nudge toward more checkpoints Next insights-roadmap card (session-health), implementing the checkpoint habit Carl wanted surfaced: sessionHealth() measures how much of your output came from a few very large single responses (≥4k output tokens — a big one-shot generation with no human checkpoint mid-way). Real data: ~39% of output over 321 jumbo turns (largest ≈50k). The card frames it as a gentle nudge (smaller checkpoints = cheaper/faster review, catch a wrong direction early), not a verdict — a big single turn is sometimes exactly right. Weekly-cached, opt-in (showInsights). 111/111 tests pass. Co-Authored-By: Claude * feat: viewer refreshes one reusable panel; new "Your active hours" insight Two items from Carl's feedback: - Conversation viewer now (re)uses a SINGLE panel: each "view" click re-reads the session .jsonl fresh and refreshes that panel (revealing it), instead of piling up a new tab per click. An ongoing session shows its latest turns. - New insights card "Your active hours": activeHours() buckets non-cache work (input+output tokens) by hour-of-day in the user's timezone, with a 24-hour sparkline and the busiest contiguous 4-hour window + its share. Real data: peak 15:00–19:00, 35% of work. Tip ties it to protecting the peak window and keeping momentum in one warm session there. Weekly-cached, opt-in. 111/111 tests pass. Co-Authored-By: Claude * feat(insights): "Skill ROI" card — cost vs output per skill/plugin Next roadmap card. skillRoi() uses the authoritative _skill/_plugin attribution Claude Code ≥2.1 stamps on usage lines: per skill, the cost spent while it was active and the output it returned, with outPerUsd (output tokens per $) as the ROI angle. Real data separates lean skills (lit-review ~15k tok/$, paper-plotting ~13.5k) from context-heavy ones (claude-api ~1.9k, code-review ~2.1k) — so you can see which skills burn context for little output. Bar/number = cost; note flags it's a proxy (cost attributed to the active skill, output isn't "value"). Weekly-cached, opt-in. (Budget-pacing card skipped for V2.2 per Carl.) 111/111 tests pass. Co-Authored-By: Claude * fix(insights): Skill ROI shows output-tokens-per-$ as the bar AND number Carl: the right-hand figure was a bare dollar amount, unclear as "ROI" and not matching the bar. Now the bar and the number are the SAME metric — output tokens returned per $ (with a "/$" unit) — sorted by it so the bars rank; cost + turns move to the muted sub-line. * feat(settings): live-refresh delay dropdown (0/1/2/5/10/20/30s) + settings i18n sync - Replaces the fileWatching on/off toggle with a discrete "Live refresh delay" choice: Off / 1 / 2 / 5 / 10 / 20 / 30s (fileWatchSeconds, default 2s ≈ old 1.5s). The fs watcher uses it as the debounce; Off stops watching. Help makes clear this only re-reads LOCAL log files — no API call; the /usage quota fetch is throttled separately, so a short delay doesn't hammer the official API. - Settings i18n sync (Carl's release check): localized the setting label/help in all 6 non-English locales for fileWatchSeconds, plus showInsights and showConversationViewer (previously English-only), and renamed the enableSessionDelete labels to enableSessionActions to match the merged setting. 111/111 tests pass. Co-Authored-By: Claude * fix(viewer,charts): cache-write bars were invisible; viewer "show earlier" was confusing - Cache-write ("input cache miss") bars rendered transparent in every usage bar chart. Cause: .cache-creation-bar's gradient referenced var(--vscode-charts-pink), which VS Code doesn't define — an invalid color makes the whole gradient (and thus the background) invalid, so only the axis + value labels showed. Added a pink fallback so the gradient is valid. - Conversation viewer: dropped the "Show earlier conversation (N rounds)" collapse. It was confusing — the top prompt-nav didn't reflect it, so you couldn't tell whether the earlier prompts were shown, and toggling it only changed the body. Now every loaded round renders flat and the nav lists every loaded prompt (all jump targets exist). Default load is the last 10 rounds ("showing last 10 of N"). 111/111 tests pass. Co-Authored-By: Claude * docs(release): V2.2 CHANGELOG + What's New; drop unused @types/glob - CHANGELOG [2.2.0]: added the conversation viewer, Experimental insights suite (cache-churn / warmth-by-model / big one-shot / active hours / skill ROI), Sessions "Active" column, live-refresh delay control, Chinese share-card units, the enableSessionActions merge + timezone-aware bucketing (Changed), the cache-write invisible-bars fix (Fixed), and the @types/glob removal (Removed). - Refreshed the in-extension "What's New" 2.2 blurb to match. - Removed the unused @types/glob devDependency (folds in #63, thanks @zeyutang). Co-Authored-By: Claude * docs(architecture): add v2.2 modules + Experimental-insights methods ARCHITECTURE.md module table now lists sessionResume.ts, conversationLog.ts, conversationViewerHtml.ts and miniMarkdown.ts, and the data-layer notes cover the new insight methods (estimateCacheChurnCost, cacheStatsByModel, sessionHealth, activeHours, skillRoi, activeDurationBySession, modelRightsizing). Co-Authored-By: Claude * fix: timezone bucketing and cache-write cost accuracy (#62) * fix: timezone bucketing and cache-write cost accuracy * bucket Today/month/daily/hourly totals by the configured IANA zone * keep the loader zone in lockstep with display; recompute on change * split cache writes into 1h (2x) vs 5m (1.25x) when the log has the ttl * add tests for zone bucketing and the cache-write split * i18n: note timezone sets the daily cost boundary * add the day-boundary clause to the timezone help in all six locales * update the timezone description in README and the locale editions * fix: changelog minor touch-up * Maintainer edit: keep the cache-write TTL pricing; drop the timezone changes The timezone-bucketing half overlaps with the shared zone-aware day-key rework landing in V2.2, so this reduces the PR to just the cache-write TTL pricing (pricing.ts + types.ts + pricing.test.ts) to merge cleanly. zeyutang's original commits are preserved; the timezone work is welcome as a follow-up PR. * Remove stray sample-card SVGs accidentally swept into the maintainer edit --------- Co-authored-by: Carl <74130028+Carl723000@users.noreply.github.com> * docs(changelog): credit #62 cache-write TTL pricing (@zeyutang) * chore(release): bullet the 2.2 "What's New"; let #63 own the @types/glob removal - What's New for 2.2 is now newline bullets (• per feature) instead of one dense paragraph, so the toast is scannable. Copy unchanged (Carl approved it). - Restore @types/glob locally: #62-style, the removal now traces solely to zeyutang's merged #63 rather than a duplicate local edit. It's a devDependency (never packaged), so the vsix is unaffected; the merge to main drops it. --------- Co-authored-by: Claude Co-authored-by: Zeyu Tang --- ARCHITECTURE.md | 11 +- CHANGELOG.md | 146 +++- CLAUDE.md | 12 +- README-en.md | 2 +- README-ja.md | 2 +- README-ko.md | 2 +- README-zh-CN.md | 2 +- README-zh-TW.md | 2 +- README.md | 2 +- package.json | 27 +- src/conversationLog.ts | 275 ++++++ src/conversationViewerHtml.ts | 339 ++++++++ src/dataContribution.ts | 79 ++ src/dataLoader.ts | 896 ++++++++++++++++++- src/dateKeys.ts | 37 + src/extension.ts | 344 +++++++- src/heatmap.ts | 85 ++ src/heatmapSvg.ts | 223 +++++ src/i18n.ts | 207 ++++- src/miniMarkdown.ts | Bin 0 -> 6385 bytes src/pricing.ts | 4 + src/promptDedup.ts | 39 + src/settings.ts | 251 +++++- src/shareCard.ts | 269 ++++++ src/shareCardSvg.ts | 486 +++++++++++ src/statusBar.ts | 27 +- src/test/cacheChurn.test.ts | 48 + src/test/conversationLog.test.ts | 129 +++ src/test/dataContribution.test.ts | 46 + src/test/dateKeys.test.ts | 37 + src/test/format.test.ts | 58 ++ src/test/heatmap.test.ts | 58 ++ src/test/heatmapSvg.test.ts | 67 ++ src/test/i18nTimezone.test.ts | 25 + src/test/miniMarkdown.test.ts | 72 ++ src/test/promptDedup.test.ts | 49 ++ src/test/shareCard.test.ts | 114 +++ src/test/shareCardSvg.test.ts | 165 ++++ src/types.ts | 60 +- src/webview.ts | 1352 ++++++++++++++++++++++++++++- 40 files changed, 5861 insertions(+), 188 deletions(-) create mode 100644 src/conversationLog.ts create mode 100644 src/conversationViewerHtml.ts create mode 100644 src/dataContribution.ts create mode 100644 src/dateKeys.ts create mode 100644 src/heatmap.ts create mode 100644 src/heatmapSvg.ts create mode 100644 src/miniMarkdown.ts create mode 100644 src/promptDedup.ts create mode 100644 src/shareCard.ts create mode 100644 src/shareCardSvg.ts create mode 100644 src/test/cacheChurn.test.ts create mode 100644 src/test/conversationLog.test.ts create mode 100644 src/test/dataContribution.test.ts create mode 100644 src/test/dateKeys.test.ts create mode 100644 src/test/format.test.ts create mode 100644 src/test/heatmap.test.ts create mode 100644 src/test/heatmapSvg.test.ts create mode 100644 src/test/i18nTimezone.test.ts create mode 100644 src/test/miniMarkdown.test.ts create mode 100644 src/test/promptDedup.test.ts create mode 100644 src/test/shareCard.test.ts create mode 100644 src/test/shareCardSvg.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a211da3a..7e5ef593 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,6 +55,10 @@ token insight, attribution, or the advice experience are the right fit. | `promptDedup.ts` | Dedups api_error retry re-logs so message counts aren't inflated. | | `quotaFormat.ts` | Pure status-bar quota text formatter (kept clean; reset detail lives in the tooltip). | | `dataContribution.ts` | Opt-in, OFF-by-default SCAFFOLD for future authorized cross-user signals (e.g. cache-TTL by tier). No endpoint / no-op; the Observation type has no identifying field. | +| `sessionResume.ts` | **(v2.2)** Pure guards for the Sessions resume/copy actions: `isValidSessionId`, `isUsableCwd`, `buildResumeCommand`, `isUnderDir` (shell-injection-safe id/cwd validation). | +| `conversationLog.ts` | **(v2.2)** Pure parser for the read-only conversation viewer: a session `.jsonl` → ordered display turns (prompt / text / thinking / tool_use / tool_result). Skips meta/sidechain, dedups by uuid, caps by ROUNDS (`maxRounds`) so prompts aren't starved, truncates per turn. Unit-tested. | +| `conversationViewerHtml.ts` | **(v2.2)** Renders a `ParsedConversation` to a full read-only webview page (no scripts): prompts as the star, Markdown-rendered answers, thinking/tools behind pure-CSS toggles, a prompt jump-nav. | +| `miniMarkdown.ts` | **(v2.2)** Dependency-free Markdown→HTML (headings, lists, GFM tables, code, links, blockquotes) used by the conversation viewer; escapes before formatting, scheme-checks links. Unit-tested. | New data-layer methods on `dataLoader.ts`: `getCostliestMessages` (top-N single turns + prompt + skill + cost split + gap + prev-model, for the Content tab's @@ -63,7 +67,12 @@ estimate; measured ~60 min), `buildShareInput` (range today/week/last30/month/ year/`month:YYYY-MM` + scope all/project/session). Efficiency insights (cost/tokens per message, realised cache savings, "Cache warmth"), thinking-share "hidden" handling (Fable 5 / Opus omit CoT text), and timezone = full UTC-offset -coverage are all V2.2. CI: `@ccu-bot` first-pass automation (`claude.yml`, +coverage are all V2.2. The opt-in **Experimental insights** (`showInsights`) draw +on `estimateCacheChurnCost`, `cacheStatsByModel` (per-model warm window), +`sessionHealth` (big one-shot turns), `activeHours`, and `skillRoi`; plus +`activeDurationBySession` for the Sessions "Active" column and `modelRightsizing` +(computed but not shown — reserved for an AI-judged v2). All weekly-cached in the +webview. CI: `@ccu-bot` first-pass automation (`claude.yml`, `issue-first-pass.yml`, `pr-first-pass.yml`) — INERT unless `AUTOMATION_ENABLED`; official or third-party (`ANTHROPIC_BASE_URL`) key; reads this file. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3545a085..7f0ceacf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,151 @@ 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). -## [Unreleased] +## [2.2.0] — Unreleased + +### Added +- **`tokenDecimalPlaces`** (default 1, 0–2) — decimals for the *compact* token + display (`1.2M` / `345.6K`); full integer counts are unaffected. +- **Cache-hit-rate column** in the All-time (monthly) and This-month (daily) + breakdown tables — and in the expanded per-day / per-hour drill-downs — so the + cache efficiency is visible per row, not just in the summary card. +- **Token heatmap on the All tab** (opt-in, `showHeatmap`, default off) — a + GitHub-style yearly token heatmap (Claude orange) at the top of the All tab. + Inline SVG, so the per-day hover tooltips work in the dashboard. Mainly a + shareable view of data already shown elsewhere, hence off by default. +- **Export Token Heatmap (GitHub style)** — a command that writes a + self-contained, GitHub-contribution-style SVG of the trailing year's token + usage (Claude-orange scale, top-left summary, per-day tooltips, source + watermark) to a file, with a one-click "copy Markdown embed" — for pasting + into a GitHub profile README. Pure, unit-tested renderer (`heatmapSvg.ts`). +- **Token-composition drill-down** — clicking a month in the All-time *Token + composition* chart expands that month's per-day composition (alongside the + daily chart + table), so you can read the input / output / cache-write / + cache-read split day by day, not just at the month level. +- **Share-card + heatmap foundations** — tested pure logic (`src/shareCard.ts`, + `src/heatmap.ts`) for the upcoming Usage Share Card and Monthly token heatmap. + +- **Efficiency insights** (opt-in, `showEfficiency`, default off) — starts with a + **top-10 costliest conversations** panel on the Content tab: expandable rows + (native disclosure) showing each session's tokens, cache-hit rate, top model + and project, ranked by cost. (Cost-per-message + realised cache-savings chips + on Today/projects use the same toggle.) +- **"What's new" prompt after upgrades** — a single, dismissible notification + the first time you run a new major.minor version, pointing at the dashboard so + new (including opt-in, default-off) features are discoverable. Shown once per + version; skipped on a fresh install. +- **Usage Share Card** (opt-in, `enableShareCard`, default off) — a configurable + one-page SVG you can generate and export/share: pick a range (last 30 days / + week / month / year / a specific month), a scope (overall / a project / a + session), which metrics to show, and a **theme** — **Claude Classic** (orange, + default), **Claude Cream**, **Aurora Dark**, or **Auto**. Self-contained SVG; + optional GitHub **avatar + name**; deterministic; privacy by construction (no + prompts/paths/ids). Built on demand; config + preview survive a refresh. +- **Publish Token Heatmap to GitHub** — one-click publish of the heatmap SVG to + a repo (default: your profile repo) via VS Code's built-in GitHub auth (no + PAT). Shows a consent modal first. +- **Top-10 costliest *messages*** (opt-in, `showCostliestMessages`, default off, + Content tab) — ranks single turns by cost; expand for the triggering prompt, + model, skill, a **cost split** that distinguishes a **cache miss** from a long + answer, the **cache-hit rate**, and the **time since the last turn** (+ a + "model switch flushed the cache" / "idle past cache TTL" cause). (Reworked from + the earlier costliest-*conversations* panel.) +- **Cache warmth estimate** (`showEfficiency`) — infers how long your prompt + cache stays warm while idle from your own turns (measured **~60 min**, not 5). +- **Efficiency chips** — cost/message, **tokens/message**, realised cache savings + on Today / month / all-time; a **Cost/msg** column in the projects table. +- **Conversation viewer** (opt-in, `showConversationViewer`, **default on** — it's + read-only) — a "view" button on the Sessions tab opens a read-only reader for a + past conversation: your prompts up front, the model's answers rendered from + Markdown (tables included), with thinking and tool traffic behind toggles. Lets + you re-read a session to jog your memory *without* loading it back into the + model's context (unlike resume). Reads local logs only; refreshes each time you + open it; loads the last 10 rounds. +- **Experimental insights** (opt-in, `showInsights`, default off, Content tab) — + heuristic estimates from your local logs, labelled as estimates: a **cache-churn + bill** ($ spent re-writing cache after model switches / idle gaps), **cache + warmth by model** (how long each model keeps your cache warm), **big one-shot + turns** (a checkpoint nudge), **your active hours** (a 24-h token sparkline + + peak window), and **skill ROI** (output tokens returned per $ per skill/plugin). +- **Sessions "Active" column** — estimated hands-on time per session (gaps between + turns, each idle gap capped at 1.5 h), which is far more meaningful than the raw + first-to-last span for long-lived sessions. Sortable, with an explanatory tooltip. +- **Live-refresh delay control** (`fileWatchSeconds`: Off / 1 / 2 / 5 / 10 / 20 / + 30 s, default 2 s) replaces the on/off "live file watching" toggle. This only + re-reads your **local** log files — no API call; the `/usage` quota fetch is + throttled separately. +- **Chinese share-card units** — the share card uses 万/亿 (萬/億 in zh-TW) and its + text follows the UI language, so an English card is fully English and a Chinese + card fully Chinese. + +### Changed +- **`enableSessionActions`** (default off) gates the Sessions **resume _and_ + delete** buttons together — both *act* on your Claude Code (reopen / trash a + log), at odds with the extension being read-only, so they're opt-in as a pair. + (Replaces the earlier `enableSessionDelete`.) +- **Timezone-aware bucketing** — every Today / day / month / hour total now derives + its day boundary from the configured IANA zone (empty = system), kept in lockstep + with the display, so the aggregations agree with each other and with the console; + an invalid zone falls back to the system zone instead of breaking the dashboard. +- **Cache-write cost by TTL** — when a log carries the cache-creation TTL split, a + 1-hour cache write is priced at 2× base input (vs the 5-minute 1.25×), matching + Anthropic's billing; logs without the split are unchanged. (PR #62, @zeyutang.) +- **Timezone dropdown = full UTC-offset coverage** — common zones plus every UTC + offset (grouped Common / UTC offset), each labelled with its current offset; + IANA identifiers only (no editorialised place names). +- **`dashboardAutoRefresh`** (positive wording, default true) replaces the + double-negative `pauseDashboardRefresh`; existing values are migrated. +- Repository metadata (`repository` / `bugs` / `homepage`) now points at the + `ClaudeCodeUsage` organization. + +### Fixed +- **Thinking share reads "hidden", not a false 0%,** for models that omit their + reasoning text (Fable 5 / Opus 4.8 — `"thinking":""` + a signature). +- **Quota reset countdown** in the tooltip now reads `4d 12h` (the compact + status-bar form keeps `4.5d`); a recently-expired usage-anchored window no + longer shows a fabricated countdown while idle. +- **Auto-refresh no longer wipes** the generated share card or collapses expanded + Content rows (reset only on tab switch); the GitHub avatar renders (webview CSP + now allows `data:` images). +- Message counts exclude api_error retries and the compaction summary line. +- **Timezone is a validated dropdown** — the Timezone setting is now a picker of + valid IANA zones (`Intl.supportedValuesOf`) instead of free text, so an invalid + value can't be entered; a guard also rejects any old bad synced value. Fixes a + crash where a hand-typed zone made `Intl` throw and broke the whole dashboard. + (#51) +- **German (de-DE) now selectable** — the German translation (contributed by + @mxzinke) existed in the strings and `SupportedLanguage` but had never been + added to the `package.json` enum or the settings dropdown, so it couldn't be + chosen. Exposed it everywhere; verified the translation and fixed two English + leaks (`error`, popup `currentSession`). +- **pt-BR now selectable** — Brazilian Portuguese (added in 2.1.1) was missing + from the dashboard's language dropdown (`settings.ts` enum) and the README + language lists, even though the strings, `package.json` enum and + `SupportedLanguage` already had it. Wired it through everywhere. +- **Timezone-correct month / day bucketing** — the This-month and All-time + breakdowns now bucket every record's day *and* month in the configured + timezone (empty = system). Previously the month boundary was local while the + day key was UTC, so a record just after local midnight on the 1st showed up + under the previous month's last day. (`src/dateKeys.ts`, unit-tested.) +- **Breakdown table scroll** — number cells stay on one line, so the compact + (k/M) view fits the panel with no horizontal scroll while full integer numbers + overflow and scroll the table only; the chart keeps its own scroll. +- **API-error retries no longer inflate the Messages count** — when a request + errors, Claude Code retries it and re-logs the same user prompt; an identical + prompt re-appearing within a short window is now counted once (genuine + re-sends minutes/hours later still count). (`src/promptDedup.ts`, unit-tested.) +- **Compaction summary no longer counts as a message** — when a session is + auto-compacted, Claude Code injects the "This session is being continued…" + summary as a *user* message; it's now excluded from the Messages count (you + never typed it). Verified on real logs. +- **Cache-write ("input cache miss") bars render again** — in every usage bar + chart, selecting the cache-write metric showed only the axis and value labels: + the bar's gradient referenced a `--vscode-charts-pink` colour VS Code doesn't + define, which made the whole gradient invalid (transparent). Added a fallback. + +### Removed +- Dropped the unused `@types/glob` devDependency (clears a vulnerability + advisory). Thanks @zeyutang (#63). ### Fixed - **Sonnet 5 context window** — `contextWindowFor()` only recognised the 1M diff --git a/CLAUDE.md b/CLAUDE.md index aa006f82..6ffd4510 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,10 @@ billing tool, not a multi-provider monitor. rejects Node's TLS fingerprint. - **`advisor.ts`** + **`adviceDemoSample.ts`** — the opt-in AI advice feature and its localised static demo. -- **`i18n.ts`** — translations for en / de-DE / zh-TW / zh-CN / ja / ko. +- **`i18n.ts`** — translations for, and selectable UI languages (package.json + enum + settings dropdown), all seven: en / de-DE / zh-TW / zh-CN / ja / ko / + pt-BR. Keep the three in sync when adding a language: `SupportedLanguage` + (types.ts), the `package.json` enum, and `settings.ts` `enumValues`. - **`types.ts`** — shared interfaces. ## Build & Release @@ -71,8 +74,11 @@ npx @vscode/vsce package # build a .vsix of "fix the previous fix" commits. RC branches squash-merge to `main`. - **Tests:** a `node:test` suite runs against compiled output (`npm test`; see `src/test/` and CONTRIBUTING). Pure-logic modules (pricing, aggregation, - quota-window handling, i18n) are the high-value targets; `pricing.ts` has the - first coverage, the rest are still open. + timezone/date keys, dedup, share-card, heatmap, conversation-log, mini-markdown) + are the high-value targets and now carry most of the coverage. **One concern per + test file, named `camelCase.test.ts`** to match the module (e.g. `pricing.test.ts`, + `dateKeys.test.ts`) — don't add dotted `x.y.test.ts` files; fold new cases into + the existing file for that area. - **Data is read-only:** the extension never writes to `~/.claude/`. ## Documentation Maintenance diff --git a/README-en.md b/README-en.md index b2d6ecac..f316e924 100644 --- a/README-en.md +++ b/README-en.md @@ -50,7 +50,7 @@ Also on the [Open VSX Registry](https://open-vsx.org/extension/GrowthJack/claude Open Settings (`Ctrl+,`) and search for **`Claude Code Usage`**. All settings are optional. The most useful: -- `language` — UI language (`auto` / `en` / `zh-TW` / `zh-CN` / `ja` / `ko`). +- `language` — UI language (`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`). - `timezone` — IANA timezone for date display (e.g. `Asia/Hong_Kong`). - `usageLimitTracking` — show the real 5h / weekly quota indicator. - `showCost` / `showContext` — toggle the cost item and the context-window fill indicator (like `/context`) in the status bar. diff --git a/README-ja.md b/README-ja.md index 4843e046..fbbee143 100644 --- a/README-ja.md +++ b/README-ja.md @@ -50,7 +50,7 @@ Cursor / Windsurf 向けに [Open VSX Registry](https://open-vsx.org/extension/G 設定(`Ctrl+,`)を開き **`Claude Code Usage`** を検索。すべて任意です。よく使うもの: -- `language` — UI 言語(`auto` / `en` / `zh-TW` / `zh-CN` / `ja` / `ko`)。 +- `language` — UI 言語(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`)。 - `timezone` — 日付表示用の IANA タイムゾーン(例 `Asia/Tokyo`)。 - `usageLimitTracking` — 実際の 5 時間 / 週間クォータ表示。 - `showCost` / `showContext` — ステータスバーのコスト表示と、コンテキストウィンドウ使用率(`/context` 風)の切り替え。 diff --git a/README-ko.md b/README-ko.md index 253ed373..1d52e626 100644 --- a/README-ko.md +++ b/README-ko.md @@ -50,7 +50,7 @@ Cursor / Windsurf용으로 [Open VSX Registry](https://open-vsx.org/extension/Gr 설정(`Ctrl+,`)을 열고 **`Claude Code Usage`**를 검색하세요. 모두 선택 사항입니다. 가장 유용한 것: -- `language` — UI 언어(`auto` / `en` / `zh-TW` / `zh-CN` / `ja` / `ko`). +- `language` — UI 언어(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`). - `timezone` — 날짜 표시용 IANA 시간대(예: `Asia/Seoul`). - `usageLimitTracking` — 실제 5시간 / 주간 쿼터 표시. - `showCost` / `showContext` — 상태 표시줄의 비용 항목과 컨텍스트 윈도우 사용률(`/context` 유사) 표시 전환. diff --git a/README-zh-CN.md b/README-zh-CN.md index 29a1062e..7c0ca1ea 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -120,7 +120,7 @@ ext install GrowthJack.claude-code-usage | 设置 | 默认 | 作用 | |---|---|---| -| `language` | `"auto"` | 界面语言:`auto` / `en` / `zh-TW` / `zh-CN` / `ja` / `ko`。 | +| `language` | `"auto"` | 界面语言:`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`。 | | `dataDirectory` | `""` | 自定义 Claude 数据目录;留空 = 自动检测。 | | `advice.apiKey` | `""` | AI 建议 + 用量优化器的 API key(留空则 AI 建议会打开 demo)。 | diff --git a/README-zh-TW.md b/README-zh-TW.md index c279e4a4..63cb19fa 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -50,7 +50,7 @@ ext install GrowthJack.claude-code-usage 開啟設定(`Ctrl+,`)並搜尋 **`Claude Code Usage`**。所有設定皆為選用,最常用的: -- `language` — 介面語言(`auto` / `en` / `zh-TW` / `zh-CN` / `ja` / `ko`)。 +- `language` — 介面語言(`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`)。 - `timezone` — 日期顯示用的 IANA 時區(如 `Asia/Hong_Kong`)。 - `usageLimitTracking` — 顯示真實的 5 小時 / 每週配額指示器。 - `showCost` / `showContext` — 切換狀態列的成本項目與上下文視窗佔用指示器(類似 `/context`)。 diff --git a/README.md b/README.md index 5702365f..c299a921 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ for **`Claude Code Usage`**: | Setting | Default | What it does | |---|---|---| -| `language` | `"auto"` | UI language: `auto` / `en` / `zh-TW` / `zh-CN` / `ja` / `ko`. | +| `language` | `"auto"` | UI language: `auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR`. | | `dataDirectory` | `""` | Custom Claude data dir; empty = auto-detect. | | `advice.apiKey` | `""` | API key for AI advice + the Usage Optimizer (empty = advice opens a demo instead). | diff --git a/package.json b/package.json index 6147b02d..993a812d 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,12 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/jack21/ClaudeCodeUsage.git" + "url": "https://github.com/ClaudeCodeUsage/ClaudeCodeUsage.git" }, "bugs": { - "url": "https://github.com/jack21/ClaudeCodeUsage/issues" + "url": "https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues" }, - "homepage": "https://github.com/jack21/ClaudeCodeUsage", + "homepage": "https://github.com/ClaudeCodeUsage/ClaudeCodeUsage", "keywords": [ "claude", "claude-code", @@ -51,6 +51,7 @@ "enum": [ "auto", "en", + "de-DE", "zh-TW", "zh-CN", "ja", @@ -97,6 +98,26 @@ "command": "claudeCodeUsage.showLogs", "title": "Show Diagnostic Logs", "category": "Claude Code Usage" + }, + { + "command": "claudeCodeUsage.exportHeatmap", + "title": "Export Token Heatmap (GitHub style SVG)", + "category": "Claude Code Usage" + }, + { + "command": "claudeCodeUsage.exportShareCard", + "title": "Export Usage Share Card (SVG)", + "category": "Claude Code Usage" + }, + { + "command": "claudeCodeUsage.publishHeatmapToGitHub", + "title": "Publish Token Heatmap to GitHub", + "category": "Claude Code Usage" + }, + { + "command": "claudeCodeUsage.previewWhatsNew", + "title": "Preview What's New", + "category": "Claude Code Usage" } ] }, diff --git a/src/conversationLog.ts b/src/conversationLog.ts new file mode 100644 index 00000000..4d0bbb52 --- /dev/null +++ b/src/conversationLog.ts @@ -0,0 +1,275 @@ +// Pure parser for a Claude Code session .jsonl → a display-ready list of turns +// for the read-only conversation viewer. No vscode / fs imports: it takes the +// raw file text and returns plain data, so it is fully unit-testable. +// +// The viewer's whole point (Carl's ask) is to let a human RE-READ a past +// session to jog their memory WITHOUT loading it into the model context — so we +// keep the two things that matter, user PROMPTS and substantive model TEXT, +// front and centre, and de-emphasise the tool-call/tool-result noise. + +export type TurnRole = 'user' | 'assistant'; +export type TurnKind = 'prompt' | 'text' | 'thinking' | 'tool_use' | 'tool_result'; + +export interface ConversationTurn { + role: TurnRole; + kind: TurnKind; + /** ISO timestamp of the source line, when present. */ + ts?: string; + /** Display text (already trimmed; may be truncated — see `truncated`). */ + text: string; + /** Assistant model id, on assistant turns. */ + model?: string; + /** Tool name, on `tool_use` turns. */ + toolName?: string; + /** True if a tool_result carried is_error. */ + isError?: boolean; + /** True when `text` was cut to `maxCharsPerTurn`. */ + truncated?: boolean; +} + +export interface ParsedConversation { + turns: ConversationTurn[]; + /** Total qualifying turns before any `maxTurns` slice. */ + totalTurns: number; + /** Total rounds (a round = a user prompt + everything until the next prompt). */ + totalRounds: number; + /** Number of prompt turns (what the human typed). */ + promptCount: number; + /** Conversation title (custom > ai > summary), when the log carries one. */ + title?: string; + /** First / last timestamps seen. */ + firstTs?: string; + lastTs?: string; +} + +export interface ParseOptions { + /** Keep only the most recent N ROUNDS (a round = a prompt + its replies). This + * is the right cap for the viewer: capping raw turns starves prompts, because + * one prompt can be followed by dozens of tool turns. */ + maxRounds?: number; + /** Safety ceiling on raw turns kept (applied AFTER maxRounds). */ + maxTurns?: number; + /** Truncate each turn's text to this many chars (default 1200). */ + maxCharsPerTurn?: number; + /** Include assistant thinking blocks (default true). */ + includeThinking?: boolean; + /** Include tool_use / tool_result turns (default true; the UI collapses them). */ + includeTools?: boolean; +} + +interface Block { + type?: string; + text?: unknown; + thinking?: unknown; + name?: unknown; + input?: unknown; + content?: unknown; + is_error?: unknown; +} + +const asText = (v: unknown): string => (typeof v === 'string' ? v : ''); + +/** Flatten a tool_result's content (string, or array of text/blocks) to text. */ +function flattenToolResult(content: unknown): string { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + let out = ''; + for (const b of content) { + if (typeof b === 'string') { + out += b; + } else if (b && typeof b === 'object' && typeof (b as Block).text === 'string') { + out += (b as { text: string }).text; + } + } + return out; + } + return ''; +} + +/** Compact one-line summary of a tool_use input (so the chip is readable). */ +function summarizeInput(input: unknown): string { + if (input == null) { + return ''; + } + if (typeof input === 'string') { + return input; + } + if (typeof input === 'object') { + const o = input as Record; + // Prefer the fields users recognise, in priority order. + for (const k of ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt', 'description']) { + if (typeof o[k] === 'string' && o[k]) { + return String(o[k]); + } + } + try { + return JSON.stringify(o); + } catch { + return ''; + } + } + return ''; +} + +function clip(text: string, max: number): { text: string; truncated: boolean } { + const t = text.replace(/\s+$/g, ''); + if (t.length <= max) { + return { text: t, truncated: false }; + } + return { text: t.slice(0, max), truncated: true }; +} + +/** + * Parse a whole `.jsonl` session file into ordered display turns. + * Robust to malformed lines (skipped) and to the two content shapes + * (string | block[]). Deduplicates by `uuid`. Skips meta / sidechain lines so + * the reader sees the real conversation, not framework chatter. + */ +export function parseConversation(jsonlText: string, opts: ParseOptions = {}): ParsedConversation { + const maxChars = opts.maxCharsPerTurn ?? 1200; + const includeThinking = opts.includeThinking !== false; + const includeTools = opts.includeTools !== false; + + const all: ConversationTurn[] = []; + const seen = new Set(); + let customTitle: string | undefined; + let aiTitle: string | undefined; + let summary: string | undefined; + let firstTs: string | undefined; + let lastTs: string | undefined; + + const lines = jsonlText.split('\n'); + for (const raw of lines) { + const line = raw.trim(); + if (!line) { + continue; + } + let parsed: Record; + try { + parsed = JSON.parse(line) as Record; + } catch { + continue; + } + if (!parsed || typeof parsed !== 'object') { + continue; + } + + // Titles ride on their own line types. + if (parsed.type === 'custom-title' && typeof parsed.customTitle === 'string') { + customTitle = parsed.customTitle; + continue; + } + if (parsed.type === 'ai-title' && typeof parsed.aiTitle === 'string') { + aiTitle = parsed.aiTitle; + continue; + } + if (parsed.type === 'summary' && typeof parsed.summary === 'string') { + summary = parsed.summary; + continue; + } + + // Real conversation only — drop framework/meta and sub-agent sidechains. + if (parsed.isMeta === true || parsed.isSidechain === true) { + continue; + } + const uuid = typeof parsed.uuid === 'string' ? parsed.uuid : ''; + if (uuid) { + if (seen.has(uuid)) { + continue; + } + seen.add(uuid); + } + + const message = parsed.message as { role?: unknown; content?: unknown; model?: unknown } | undefined; + if (!message || typeof message !== 'object') { + continue; + } + const role = (message.role || parsed.type) as string; + if (role !== 'user' && role !== 'assistant') { + continue; + } + const ts = typeof parsed.timestamp === 'string' ? parsed.timestamp : undefined; + if (ts) { + if (!firstTs) { + firstTs = ts; + } + lastTs = ts; + } + const model = asText(message.model) || undefined; + const content = message.content; + + const push = (t: ConversationTurn): void => { + if (!t.text && t.kind !== 'tool_use') { + return; + } + all.push(t); + }; + + if (typeof content === 'string') { + if (content.trim()) { + const { text, truncated } = clip(content, maxChars); + push({ role: role as TurnRole, kind: role === 'user' ? 'prompt' : 'text', ts, text, model, truncated }); + } + continue; + } + if (!Array.isArray(content)) { + continue; + } + for (const b of content as Block[]) { + if (!b || typeof b !== 'object') { + continue; + } + if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) { + const { text, truncated } = clip(b.text, maxChars); + push({ role: role as TurnRole, kind: role === 'user' ? 'prompt' : 'text', ts, text, model, truncated }); + } else if (b.type === 'thinking' && includeThinking && typeof b.thinking === 'string' && b.thinking.trim()) { + const { text, truncated } = clip(b.thinking, maxChars); + push({ role: 'assistant', kind: 'thinking', ts, text, model, truncated }); + } else if (b.type === 'tool_use' && includeTools) { + const { text, truncated } = clip(summarizeInput(b.input), maxChars); + push({ role: 'assistant', kind: 'tool_use', ts, text, model, toolName: asText(b.name) || 'tool', truncated }); + } else if (b.type === 'tool_result' && includeTools) { + const { text, truncated } = clip(flattenToolResult(b.content), maxChars); + if (text) { + push({ role: 'user', kind: 'tool_result', ts, text, isError: b.is_error === true, truncated }); + } + } + } + } + + const totalTurns = all.length; + const promptCount = all.filter((t) => t.kind === 'prompt').length; + + // Round starts: each prompt begins a round; any leading non-prompt turns form + // an initial round. Capping by ROUNDS (not raw turns) is what keeps prompts + // from being starved when a single prompt is followed by dozens of tool turns. + const roundStarts: number[] = []; + all.forEach((t, idx) => { + if (t.kind === 'prompt') { + roundStarts.push(idx); + } else if (roundStarts.length === 0) { + roundStarts.push(idx); + } + }); + const totalRounds = roundStarts.length; + + let turns = all; + if (opts.maxRounds && roundStarts.length > opts.maxRounds) { + turns = all.slice(roundStarts[roundStarts.length - opts.maxRounds]); + } + if (opts.maxTurns && turns.length > opts.maxTurns) { + turns = turns.slice(turns.length - opts.maxTurns); + } + + return { + turns, + totalTurns, + totalRounds, + promptCount, + title: customTitle || aiTitle || summary, + firstTs, + lastTs, + }; +} diff --git a/src/conversationViewerHtml.ts b/src/conversationViewerHtml.ts new file mode 100644 index 00000000..55bc0556 --- /dev/null +++ b/src/conversationViewerHtml.ts @@ -0,0 +1,339 @@ +// Read-only conversation viewer — renders a ParsedConversation to a full HTML +// document for a VS Code webview. No scripts: collapsibles use
, the +// "show thinking / tools" switches are a pure-CSS checkbox toggle, navigation +// uses in-page anchors. +// +// Frontend goal (Carl's asks): a user-friendly CHAT view. By default it shows +// only what he reads — his PROMPTS (accent card) and the model's TEXT answers +// (filled card, Markdown-rendered) — with thinking and tool traffic hidden +// behind toggles. It opens on the last ~10 rounds; earlier ones collapse. + +import { ConversationTurn, ParsedConversation } from './conversationLog'; +import { renderMarkdown } from './miniMarkdown'; + +export interface ViewerOptions { + sessionId: string; + timezone?: string; +} + +function esc(s: string): string { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function shortModel(model?: string): string { + if (!model) { + return 'assistant'; + } + return model.replace(/^claude-/, '').replace(/-\d{8}$/, ''); +} + +function fmtTime(ts: string | undefined, timezone?: string): string { + if (!ts) { + return ''; + } + const d = new Date(ts); + if (isNaN(d.getTime())) { + return ''; + } + try { + return new Intl.DateTimeFormat(undefined, { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: timezone || undefined, + }).format(d); + } catch { + return d.toISOString().slice(5, 16).replace('T', ' '); + } +} + +/** One turn → its HTML block, styled by kind. `idx` anchors prompts for the nav. + * `intermediate` marks an assistant answer that isn't the round's final one. */ +function renderTurn(t: ConversationTurn, idx: number, timezone?: string, intermediate = false): string { + const time = fmtTime(t.ts, timezone); + const timeTag = time ? `${esc(time)}` : ''; + const trunc = t.truncated ? ' … (truncated)' : ''; + + if (t.kind === 'prompt') { + return ( + `
` + + `
👤 You${timeTag}
` + + `
${esc(t.text)}${trunc}
` + + `
` + ); + } + if (t.kind === 'text') { + // Model output is Markdown — render it (renderMarkdown escapes internally). + const interTag = intermediate ? ' interim' : ''; + return ( + `
` + + `
🤖 ${esc(shortModel(t.model))}${interTag}${timeTag}
` + + `
${renderMarkdown(t.text)}${trunc}
` + + `
` + ); + } + if (t.kind === 'thinking') { + const preview = t.text.replace(/\s+/g, ' ').slice(0, 70); + return ( + `
` + + `💭 thinking ${esc(preview)}…${timeTag}` + + `
${esc(t.text)}${trunc}
` + + `
` + ); + } + if (t.kind === 'tool_use') { + return ( + `
` + + `🔧 ${esc(t.toolName || 'tool')}` + + `${esc(t.text)}${trunc}${timeTag}` + + `
` + ); + } + const cls = t.isError ? 'toolres err' : 'toolres'; + const preview = t.text.replace(/\s+/g, ' ').slice(0, 60); + return ( + `
` + + `${t.isError ? '⚠ result' : '↩ result'} ${esc(preview)}…${timeTag}` + + `
${esc(t.text)}${trunc}
` + + `
` + ); +} + +export function renderConversationViewer(parsed: ParsedConversation, opts: ViewerOptions): string { + + // Group turns into rounds: a new round begins at each user prompt (anything + // before the first prompt forms an initial round). Keep the turn + its global + // index so the round's FINAL answer can be told from its intermediate ones. + const rounds: { t: ConversationTurn; i: number }[][] = []; + let cur: { t: ConversationTurn; i: number }[] | null = null; + parsed.turns.forEach((t, i) => { + if (t.kind === 'prompt' || !cur) { + cur = []; + rounds.push(cur); + } + cur.push({ t, i }); + }); + + // Within a round, the last assistant-text turn is the "final answer"; earlier + // text turns are intermediate narration between tool calls. + const intermediate = new Set(); + for (const r of rounds) { + const textIdx = r.filter((x) => x.t.kind === 'text').map((x) => x.i); + for (let k = 0; k < textIdx.length - 1; k++) { + intermediate.add(textIdx[k]); + } + } + const renderRound = (r: { t: ConversationTurn; i: number }[]): string => + r.map(({ t, i }) => renderTurn(t, i, opts.timezone, intermediate.has(i))).join(''); + + // Render every loaded round flat (the parser already capped how many rounds + // are loaded). No "show earlier" collapse — it was confusing: the nav didn't + // reflect it, so you couldn't tell whether the earlier prompts were shown. + const allRoundsHtml = rounds.map(renderRound).join('\n'); + + // Top nav: every prompt in the loaded conversation, expandable to full text, + // each with a jump link. Because all loaded rounds are rendered, every jump + // target exists and scrolls. The header notes if older rounds weren't loaded. + const allPrompts = parsed.turns + .map((t, i) => (t.kind === 'prompt' ? { i, text: t.text } : null)) + .filter((x): x is { i: number; text: string } => x != null); + const promptNav = allPrompts + .map((p, n) => { + const oneLine = p.text.replace(/\s+/g, ' '); + const short = oneLine.slice(0, 80); + const needsExpand = oneLine.length > 80; + const latest = n === allPrompts.length - 1 ? 'latest' : ''; + return ( + `` + ); + }) + .join(''); + + const range = + fmtTime(parsed.firstTs, opts.timezone) + + (parsed.lastTs && parsed.lastTs !== parsed.firstTs ? ' – ' + fmtTime(parsed.lastTs, opts.timezone) : ''); + const roundWord = rounds.length === 1 ? 'round' : 'rounds'; + // Report in ROUNDS (prompts), which is what the cap now works in. + const more = parsed.totalRounds > rounds.length ? ` · showing last ${rounds.length} of ${parsed.totalRounds} ${roundWord}` : ''; + + return ` + + + + + + + + + + +
+
+

${esc(parsed.title || 'Conversation')}

+
${esc(range)} · ${parsed.totalRounds} prompt${parsed.totalRounds === 1 ? '' : 's'} total · ${parsed.totalTurns} turns${esc(more)}
+
+ 📖 Read-only — nothing here is loaded back into the model's context + + + +
+
+ ${promptNav ? `` : ''} +
+ ${allRoundsHtml || '

No readable turns in this session log.

'} +
+
+ +`; +} diff --git a/src/dataContribution.ts b/src/dataContribution.ts new file mode 100644 index 00000000..8f7968ae --- /dev/null +++ b/src/dataContribution.ts @@ -0,0 +1,79 @@ +// Opt-in data-contribution interface (SCAFFOLD — nothing is sent yet). +// +// Some future features are only possible with signals pooled across many users +// — e.g. estimating the prompt-cache TTL per subscription tier / task type / +// time of day, or how durable a plan's quota feels in practice. Those in turn +// sharpen the AI advice for everyone. This module reserves the interface for +// that now, so the shape is designed up front and privacy is enforced by types: +// +// • OFF by default. Nothing leaves the machine unless the user explicitly +// opts in AND an endpoint is configured. There is no endpoint today. +// • Aggregate + coarse ONLY. An Observation has no field for a prompt, file +// path, session id, username, repo, or exact timestamp — so they cannot be +// contributed by accident. Time is a coarse 'YYYY-MM' bucket; the model is a +// family; the tier is only present if the user chose to share it. +// • Local-first. Callers build Observations from already-aggregated numbers +// (e.g. ClaudeDataLoader.estimateCacheTtl), never from raw records. +// +// When contribution ships, `contributeObservations` gains a real transport +// behind the same gate; call sites and the Observation shape won't change. + +export type ObservationKind = 'cache-ttl'; + +/** A single privacy-safe, aggregate observation. No identifying fields exist. */ +export interface UsageObservation { + kind: ObservationKind; + /** Model FAMILY only (e.g. "opus", "sonnet") — never a full/versioned id. */ + modelFamily?: string; + /** Estimated minutes the cache stays warm (from estimateCacheTtl). */ + cacheWarmMinutes?: number; + /** How many turns the estimate is based on (confidence, not identity). */ + sampleN?: number; + /** Coarse month bucket 'YYYY-MM' — never an exact timestamp. */ + monthBucket?: string; + /** Only present if the user explicitly chose to share their plan tier. */ + subscriptionTier?: string; +} + +export interface ContributionConfig { + /** Master opt-in. Default false; nothing is sent while false. */ + enabled: boolean; + /** Where to send. Absent = local-only (the current, only supported state). */ + endpoint?: string; + /** The user opted into sharing their plan tier alongside observations. */ + shareTier?: boolean; +} + +export const DEFAULT_CONTRIBUTION_CONFIG: ContributionConfig = { enabled: false }; + +/** Would the given observations actually be sent? Only when opted in AND an + * endpoint exists. Kept pure so it's trivially testable and audit-able. */ +export function willContribute(config: ContributionConfig): boolean { + return !!(config.enabled && config.endpoint); +} + +/** Strip anything the user didn't consent to share (currently just the tier). + * A defensive pass so a caller can't over-share even by mistake. */ +export function redactObservation(obs: UsageObservation, config: ContributionConfig): UsageObservation { + const out: UsageObservation = { ...obs }; + if (!config.shareTier) { + delete out.subscriptionTier; + } + return out; +} + +/** Contribute observations — a NO-OP today (no endpoint is wired). Returns the + * count that *would* be sent, so call sites and tests are already correct for + * when a transport lands. It never sends while `willContribute` is false. */ +export async function contributeObservations( + observations: UsageObservation[], + config: ContributionConfig +): Promise<{ sent: number }> { + if (!willContribute(config) || observations.length === 0) { + return { sent: 0 }; + } + // Intentionally not implemented: no data leaves the machine yet. When a + // transport is added it goes here, sending redactObservation()-ed payloads + // to config.endpoint. Until then this stays a no-op even when "enabled". + return { sent: 0 }; +} diff --git a/src/dataLoader.ts b/src/dataLoader.ts index 89c239a9..b3f982a0 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -4,12 +4,18 @@ import * as os from 'os'; import * as path from 'path'; // Removed tinyglobby dependency - using native fs instead // Removed zod dependency - using native validation instead -import { calculateCostBreakdown } from './pricing'; +import { calculateCostBreakdown, getModelRatesPerMillion } from './pricing'; +import { isRetryDuplicatePrompt } from './promptDedup'; +import { dayKeyInZone, monthKeyInZone } from './dateKeys'; +import { I18n } from './i18n'; +import { DayUsage } from './heatmap'; +import { ShareInput, ShareRange, rangeLabel as shareRangeLabel } from './shareCard'; import { AttributionEntry, AttributionScope, BranchUsage, ClaudeUsageRecord, + CostlyMessage, ContentAnalysis, ContextWindowInfo, ContentSlice, @@ -157,14 +163,14 @@ const TRIVIAL_COMMANDS = new Set([ '/mcp', '/agents', '/export', '/rewind', '/init', '/add-dir', '/ide', ]); -// Local "YYYY-MM-DD" key for a log line's timestamp ('' when unparsable). +// "YYYY-MM-DD" key for a log line's timestamp, in the user's configured +// timezone (empty = system zone), so day/month bucketing is consistent +// (see dateKeys.ts). '' when unparsable. function localDayKey(timestamp: unknown): string { - const ts = typeof timestamp === 'string' ? new Date(timestamp) : null; - if (!ts || isNaN(ts.getTime())) { + if (typeof timestamp !== 'string') { return ''; } - const pad = (n: number): string => String(n).padStart(2, '0'); - return `${ts.getFullYear()}-${pad(ts.getMonth() + 1)}-${pad(ts.getDate())}`; + return dayKeyInZone(new Date(timestamp), I18n.getTimezone()); } // Collect an actual user prompt (capped + truncated) for the AI-advice feature. @@ -294,7 +300,7 @@ function analyzeLine(parsed: any, acc: AnalysisAcc, isSubagentFile = false, sess if (role === 'assistant') { // Thinking share: estimated thinking tokens vs. all assistant output // (text + thinking + tool-call JSON), per session and per local day. - const trackThinking = (thinkingTokens: number, totalTokens: number): void => { + const trackThinking = (thinkingTokens: number, totalTokens: number, hidden = false): void => { if (totalTokens <= 0) { return; } @@ -307,6 +313,9 @@ function analyzeLine(parsed: any, acc: AnalysisAcc, isSubagentFile = false, sess } map[key].thinking += thinkingTokens; map[key].assistantTotal += totalTokens; + if (hidden) { + map[key].hiddenThinking = true; + } }; add(acc.thinkingBySession, sessionId); add(acc.thinkingByDay, localDayKey(parsed.timestamp)); @@ -314,6 +323,7 @@ function analyzeLine(parsed: any, acc: AnalysisAcc, isSubagentFile = false, sess if (Array.isArray(content)) { let thinkingTokens = 0; let totalTokens = 0; + let hiddenThinking = false; for (const block of content) { if (!block || typeof block !== 'object') { continue; @@ -326,6 +336,12 @@ function analyzeLine(parsed: any, acc: AnalysisAcc, isSubagentFile = false, sess const est = estimateTokens(block.thinking); thinkingTokens += est; totalTokens += est; + // Thinking happened but the raw text isn't exposed (Fable 5 / Opus 4.8 + // omit it, leaving "" + an encrypted signature). Flag it so the UI can + // say "hidden" instead of an untrue 0%. + if (est === 0 && typeof (block as { signature?: unknown }).signature === 'string') { + hiddenThinking = true; + } } else if (block.type === 'tool_use') { if (typeof block.id === 'string' && typeof block.name === 'string') { acc.toolIdToName[block.id] = block.name; @@ -349,7 +365,7 @@ function analyzeLine(parsed: any, acc: AnalysisAcc, isSubagentFile = false, sess totalTokens += estimateTokens(inputJson); } } - trackThinking(thinkingTokens, totalTokens); + trackThinking(thinkingTokens, totalTokens, hiddenThinking); } else if (typeof content === 'string') { addToBucket(acc.cat, 'assistantText', content); trackThinking(0, estimateTokens(content)); @@ -600,6 +616,10 @@ export class ClaudeDataLoader { } } + // Per-session (per-file) map of prompt text → last-counted epoch ms, + // to drop API-error retry re-logs of the same prompt from the Messages + // count (see promptDedup.ts). + const recentPrompts = new Map(); for (const line of lines) { stats.linesScanned += 1; try { @@ -671,10 +691,19 @@ export class ClaudeDataLoader { .join('') : ''; if (text.trim().length > 0 && !this.isSyntheticUserText(text)) { + // Skip API-error retry re-logs: the same prompt re-appearing + // within a short window is one message, not several. + if (isRetryDuplicatePrompt(text.trim(), Date.parse(lineAny.timestamp), recentPrompts)) { + continue; + } const prompt: ClaudeUsageRecord = { timestamp: lineAny.timestamp, message: { usage: { input_tokens: 0, output_tokens: 0 } }, _isUserPrompt: true, + // Kept generous so the "costliest messages" panel can show + // the whole triggering prompt (scrollable); giant pastes are + // capped so a few huge prompts can't bloat memory. + _promptText: text.trim().slice(0, 4000), _sessionId: sessionInfo.sessionId, _projectDirEncoded: sessionInfo.projectPath, }; @@ -909,6 +938,12 @@ export class ClaudeDataLoader { if (/^\[Request interrupted/i.test(t)) { return true; } + // Compaction continuation: when a session is auto-compacted, Claude Code + // injects the summary as a *user* message — the user never typed it, so it + // must not count towards "Messages". + if (/^This session is being continued from a previous conversation/i.test(t)) { + return true; + } // Slash-command echo blocks wrap the invocation/output in these tags. if ( t.startsWith('') || @@ -1257,34 +1292,66 @@ export class ClaudeDataLoader { return this.calculateUsageData(todayRecords); } - static getThisMonthData(records: ClaudeUsageRecord[]): UsageData { - const now = new Date(); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + /** Per-day usage keyed by 'YYYY-MM-DD' (in the configured timezone) for the + * heatmap: tokens (all four token types), cost, and distinct sessions. + * Skips synthetic / API-error records, mirroring the dashboard totals. */ + static getDailyUsageMap(records: ClaudeUsageRecord[], tz: string): Record { + const daily: Record = {}; + const sessionsByDay: Record> = {}; + for (const r of records) { + const u = r.message.usage; + const model = r.message.model; + if (!u || !model || model === '' || r.isApiErrorMessage) { + continue; + } + const tokens = + u.input_tokens + u.output_tokens + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0); + if (tokens <= 0) { + continue; + } + const key = dayKeyInZone(new Date(r.timestamp), tz); + if (!key) { + continue; + } + const cb = calculateCostBreakdown(u, model); + const d = daily[key] ?? (daily[key] = { tokens: 0, cost: 0, sessions: 0 }); + d.tokens += tokens; + d.cost += cb.input + cb.output + cb.cacheWrite + cb.cacheRead; + if (r._sessionId) { + (sessionsByDay[key] ?? (sessionsByDay[key] = new Set())).add(r._sessionId); + } + } + for (const key of Object.keys(daily)) { + daily[key].sessions = sessionsByDay[key]?.size ?? 0; + } + return daily; + } - const monthRecords = records.filter((record) => { - const recordDate = new Date(record.timestamp); - return recordDate >= monthStart; - }); + static getThisMonthData(records: ClaudeUsageRecord[]): UsageData { + const tz = I18n.getTimezone(); + const thisMonth = monthKeyInZone(new Date(), tz); + const monthRecords = records.filter( + (record) => monthKeyInZone(new Date(record.timestamp), tz) === thisMonth + ); return this.calculateUsageData(monthRecords); } static getDailyDataForMonth(records: ClaudeUsageRecord[]): { date: string; data: UsageData }[] { - const now = new Date(); - const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const tz = I18n.getTimezone(); + const thisMonth = monthKeyInZone(new Date(), tz); - const monthRecords = records.filter((record) => { - const recordDate = new Date(record.timestamp); - return recordDate >= monthStart; - }); - - // Group records by date + // Group records by their calendar day in the configured timezone, keeping + // only days that belong to the current month in that same zone (so a record + // just after local midnight on the 1st isn't filed under the previous + // month's last day). const recordsByDate: Record = {}; - monthRecords.forEach((record) => { - const recordDate = new Date(record.timestamp); - const dateKey = recordDate.toISOString().split('T')[0]; // YYYY-MM-DD - + records.forEach((record) => { + const dateKey = dayKeyInZone(new Date(record.timestamp), tz); + if (!dateKey || dateKey.slice(0, 7) !== thisMonth) { + return; + } if (!recordsByDate[dateKey]) { recordsByDate[dateKey] = []; } @@ -1306,6 +1373,748 @@ export class ClaudeDataLoader { return this.calculateUsageData(records); } + /** The costliest individual assistant turns (single billed responses), each + * with the user prompt that triggered it and the skill/plugin/model in play. + * Walks each session in timestamp order to attach the preceding prompt; keeps + * only the top `limit` by cost (bounded insertion, no full-array sort). */ + static getCostliestMessages(records: ClaudeUsageRecord[], limit: number = 10): CostlyMessage[] { + const bySession: Record = {}; + for (const r of records) { + const sid = r._sessionId || 'unknown'; + (bySession[sid] ?? (bySession[sid] = [])).push(r); + } + + const top: CostlyMessage[] = []; + const consider = (m: CostlyMessage): void => { + if (top.length < limit) { + top.push(m); + top.sort((a, b) => b.cost - a.cost); + } else if (m.cost > top[top.length - 1].cost) { + top[top.length - 1] = m; + top.sort((a, b) => b.cost - a.cost); + } + }; + + for (const sid of Object.keys(bySession)) { + const recs = bySession[sid] + .slice() + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + let lastPrompt: string | undefined; + let prevTurnMs: number | undefined; // previous billable turn's timestamp + let prevModel: string | undefined; // previous billable turn's model + for (const r of recs) { + if (r._isUserPrompt) { + if (r._promptText) { + lastPrompt = r._promptText; + } + continue; + } + const u = r.message.usage; + const model = r.message.model; + if (!u || !model || model === '' || r.isApiErrorMessage) { + continue; + } + const cb = calculateCostBreakdown(u, model); + const cost = cb.input + cb.output + cb.cacheWrite + cb.cacheRead; + const nowMs = Date.parse(r.timestamp); + const gapMs = prevTurnMs !== undefined && !isNaN(nowMs) ? nowMs - prevTurnMs : undefined; + const priorModel = prevModel; + if (!isNaN(nowMs)) { + prevTurnMs = nowMs; + } + prevModel = model; + if (cost <= 0) { + continue; + } + consider({ + timestamp: r.timestamp, + cost, + gapMs, + prevModel: priorModel, + inputTokens: u.input_tokens, + outputTokens: u.output_tokens, + cacheCreationTokens: u.cache_creation_input_tokens || 0, + cacheReadTokens: u.cache_read_input_tokens || 0, + costInput: cb.input, + costOutput: cb.output, + costCacheWrite: cb.cacheWrite, + costCacheRead: cb.cacheRead, + model, + skill: r._skill, + plugin: r._plugin, + prompt: lastPrompt, + projectName: r._projectName || '', + sessionId: sid, + }); + } + } + return top.sort((a, b) => b.cost - a.cost); + } + + /** Estimate how long the prompt cache stays warm while idle, inferred from the + * user's own turns (the TTL is platform-side and may be dynamic, so we measure + * it rather than assume "5 min"). Method: for consecutive SAME-model turns + * where a cache already existed, a turn that mostly READS cache = still warm; + * one that only WRITES cache (0 reads) = went cold. The boundary between the + * longest warm gap and the shortest cold gap ≈ the TTL. Returns null when there + * aren't enough clean samples. */ + static estimateCacheTtl( + records: ClaudeUsageRecord[] + ): { estimateMin: number; coldFromMin: number; sampleN: number } | null { + // Bucket same-model consecutive turns by idle gap and measure the hit rate + // per bucket. The cache is warm while the hit rate stays high; the bucket + // where it drops marks the TTL. (A big new context is a rare miss at small + // gaps — it doesn't move the small buckets, which stay ~100%.) + const edges = [0, 1, 2, 5, 10, 15, 30, 60, 120, 240, Infinity]; // minutes + const warm = new Array(edges.length - 1).fill(0); + const cold = new Array(edges.length - 1).fill(0); + + const bySession: Record = {}; + for (const r of records) { + const sid = r._sessionId || 'unknown'; + (bySession[sid] ?? (bySession[sid] = [])).push(r); + } + for (const sid of Object.keys(bySession)) { + const recs = bySession[sid] + .filter((r) => { + const u = r.message.usage; + const m = r.message.model; + return u && m && m !== '' && !r.isApiErrorMessage; + }) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + for (let i = 1; i < recs.length; i++) { + const prev = recs[i - 1]; + const cur = recs[i]; + if (prev.message.model !== cur.message.model) { + continue; // model switch flushes the cache — not an idle-TTL sample + } + const pu = prev.message.usage; + if ((pu.cache_creation_input_tokens || 0) + (pu.cache_read_input_tokens || 0) < 1000) { + continue; // no meaningful cache existed to keep warm + } + const gapMin = (Date.parse(cur.timestamp) - Date.parse(prev.timestamp)) / 60000; + if (!(gapMin >= 0)) { + continue; + } + let bi = edges.findIndex((_, idx) => idx < edges.length - 1 && gapMin >= edges[idx] && gapMin < edges[idx + 1]); + if (bi < 0) { + bi = edges.length - 2; + } + const cu = cur.message.usage; + if ((cu.cache_read_input_tokens || 0) > 0) { + warm[bi]++; // read the cache → warm + } else if ((cu.cache_creation_input_tokens || 0) > 0) { + cold[bi]++; // had to rewrite → cold + } + } + } + + // Walk buckets ascending; the cache is warm while hit rate stays ≥ 85% in + // buckets with enough samples. The first qualifying drop marks the TTL. + let warmUpTo = 0; + let coldFrom = Infinity; + let total = 0; + for (let i = 0; i < edges.length - 1; i++) { + const n = warm[i] + cold[i]; + total += n; + if (n < 8) { + continue; + } + const hit = warm[i] / n; + if (hit >= 0.85) { + warmUpTo = edges[i + 1] === Infinity ? edges[i] : edges[i + 1]; + } else if (coldFrom === Infinity) { + coldFrom = edges[i]; + } + } + if (total < 40 || coldFrom === Infinity || warmUpTo === 0) { + return null; // not a clean enough boundary to claim a number + } + return { estimateMin: warmUpTo, coldFromMin: coldFrom, sampleN: total }; + } + + /** "Cache-churn bill" (缓存损耗账单): estimate the $ spent RE-writing cache that + * a warm cache would have served cheaply, split by the two AVOIDABLE causes — + * a model switch (per-model cache flushed) and an idle gap past the cache TTL. + * A turn counts only if the previous same-session turn had a real cache AND + * this turn had to rewrite it (read≈0, write>0), so a genuine big-new-context + * (which still reads the reused prefix) isn't blamed. Waste per turn ≈ + * cacheCreationTokens × (writeRate − readRate). NOTE for the caller: a switch + * can still be net-worth-it if the other model is cheaper per token — this is + * the churn cost, not a "never switch" verdict. `ttlMin` defaults to 60 + * (the measured warm window). Returns null when there isn't enough signal. */ + static estimateCacheChurnCost( + records: ClaudeUsageRecord[], + windowDays = 30, + ttlMin = 60 + ): { wastedUsd: number; switchUsd: number; idleUsd: number; switchCount: number; idleCount: number } | null { + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const bySession: Record = {}; + for (const r of records) { + if (new Date(r.timestamp).getTime() < cutoff) { + continue; + } + const sid = r._sessionId || 'unknown'; + (bySession[sid] ?? (bySession[sid] = [])).push(r); + } + + let switchUsd = 0; + let idleUsd = 0; + let switchCount = 0; + let idleCount = 0; + let considered = 0; + for (const sid of Object.keys(bySession)) { + const recs = bySession[sid] + .filter((r) => { + const u = r.message.usage; + const m = r.message.model; + return u && m && m !== '' && !r.isApiErrorMessage; + }) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + for (let i = 1; i < recs.length; i++) { + const prev = recs[i - 1]; + const cur = recs[i]; + const pu = prev.message.usage; + const cu = cur.message.usage; + const model = cur.message.model as string; + const prevCache = (pu.cache_creation_input_tokens || 0) + (pu.cache_read_input_tokens || 0); + const write = cu.cache_creation_input_tokens || 0; + const read = cu.cache_read_input_tokens || 0; + // A rewrite of a cache that existed just before, with ~no reads = churn. + if (prevCache < 1000 || write <= 0 || read > write * 0.1) { + continue; + } + const switched = prev.message.model !== model; + const gapMin = (Date.parse(cur.timestamp) - Date.parse(prev.timestamp)) / 60000; + const idled = gapMin > ttlMin; + if (!switched && !idled) { + continue; // rewrite for some other reason — don't blame churn + } + const rates = getModelRatesPerMillion(model); + if (!rates) { + continue; + } + const waste = (write * Math.max(0, rates.cacheWrite - rates.cacheRead)) / 1_000_000; + considered++; + // Attribute to model switch first (it's the harder flush), else idle. + if (switched) { + switchUsd += waste; + switchCount++; + } else { + idleUsd += waste; + idleCount++; + } + } + } + if (considered < 3) { + return null; + } + return { wastedUsd: switchUsd + idleUsd, switchUsd, idleUsd, switchCount, idleCount }; + } + + /** Per-MODEL cache stats over a window. Primary metric is `ttlMin` — how long + * that model's cache stays warm (the idle gap at which reads stop and rewrites + * begin), measured the same way as estimateCacheTtl but grouped by model. This + * is the durable, cross-user-comparable signal: how long each model/provider's + * cache actually lives (and it drifts over time, so it's worth tracking). We + * also carry `hitRate` (read ÷ input-side) and `tokens` (volume) as secondary. + * The serving provider isn't logged, so `provider` is the family's nominal + * vendor; a true cross-provider verdict needs pooled data — see + * dataContribution.ts. `ttlMin` is null when a model lacks enough idle-gap + * samples to claim a boundary. Sorted by token volume, top 8. */ + static cacheStatsByModel( + records: ClaudeUsageRecord[], + windowDays = 30 + ): { model: string; provider: string; ttlMin: number | null; hitRate: number; tokens: number; turns: number }[] { + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const edges = [0, 1, 2, 5, 10, 15, 30, 60, 120, 240, Infinity]; // minutes + type Stat = { + read: number; inputSide: number; total: number; turns: number; + warm: number[]; cold: number[]; + }; + const agg: Record = {}; + const ensure = (m: string): Stat => + agg[m] ?? (agg[m] = { + read: 0, inputSide: 0, total: 0, turns: 0, + warm: new Array(edges.length - 1).fill(0), + cold: new Array(edges.length - 1).fill(0), + }); + + // Volume + hit-rate aggregate, and collect per-session series for TTL. + const bySession: Record = {}; + for (const r of records) { + const u = r.message.usage; + const m = r.message.model; + if (!u || !m || m === '' || r.isApiErrorMessage) { + continue; + } + if (new Date(r.timestamp).getTime() < cutoff) { + continue; + } + const read = u.cache_read_input_tokens || 0; + const write = u.cache_creation_input_tokens || 0; + const inputSide = u.input_tokens + write + read; + const total = inputSide + u.output_tokens; + if (total <= 0) { + continue; + } + const a = ensure(m); + a.read += read; + a.inputSide += inputSide; + a.total += total; + a.turns += 1; + const sid = r._sessionId || 'unknown'; + (bySession[sid] ?? (bySession[sid] = [])).push(r); + } + + // TTL buckets: same-model consecutive turns, bucketed by idle gap; warm if + // the next turn read the cache, cold if it had to rewrite it. + for (const sid of Object.keys(bySession)) { + const recs = bySession[sid].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)); + for (let i = 1; i < recs.length; i++) { + const prev = recs[i - 1]; + const cur = recs[i]; + if (prev.message.model !== cur.message.model) { + continue; // model switch flushes the cache — not an idle-TTL sample + } + const pu = prev.message.usage; + if ((pu.cache_creation_input_tokens || 0) + (pu.cache_read_input_tokens || 0) < 1000) { + continue; + } + const gapMin = (Date.parse(cur.timestamp) - Date.parse(prev.timestamp)) / 60000; + if (!(gapMin >= 0)) { + continue; + } + let bi = edges.findIndex((_, idx) => idx < edges.length - 1 && gapMin >= edges[idx] && gapMin < edges[idx + 1]); + if (bi < 0) { + bi = edges.length - 2; + } + const cu = cur.message.usage; + const a = ensure(cur.message.model as string); + if ((cu.cache_read_input_tokens || 0) > 0) { + a.warm[bi]++; + } else if ((cu.cache_creation_input_tokens || 0) > 0) { + a.cold[bi]++; + } + } + } + + const ttlOf = (warm: number[], cold: number[]): number | null => { + let warmUpTo = 0; + let total = 0; + for (let i = 0; i < edges.length - 1; i++) { + const n = warm[i] + cold[i]; + total += n; + if (n < 5) { + continue; + } + if (warm[i] / n >= 0.85) { + warmUpTo = edges[i + 1] === Infinity ? edges[i] : edges[i + 1]; + } + } + return total >= 20 && warmUpTo > 0 ? warmUpTo : null; + }; + + return Object.entries(agg) + .filter(([, a]) => a.turns >= 3 && a.inputSide > 0) + .map(([model, a]) => ({ + model, + provider: ClaudeDataLoader.providerOf(model), + ttlMin: ttlOf(a.warm, a.cold), + hitRate: a.read / a.inputSide, + tokens: a.total, + turns: a.turns, + })) + .sort((x, y) => y.tokens - x.tokens) + .slice(0, 8); + } + + /** Estimated hands-on ("active") time per session, in ms. Sums the gaps + * between consecutive turns of a session, but caps each gap at `idleCapMin` + * (default 90) so a long break doesn't count as work. The cap is generous on + * purpose: much real work — reading the answer, reviewing a diff, thinking — + * never writes to the log, so a small cap badly under-counts. This is closer + * to time actually spent than the first→last wall-clock span (which the + * Duration column shows). Keyed by sessionId. */ + static activeDurationBySession( + records: ClaudeUsageRecord[], + idleCapMin = 90 + ): Record { + const capMs = idleCapMin * 60000; + const bySession: Record = {}; + for (const r of records) { + const sid = r._sessionId || 'unknown'; + (bySession[sid] ?? (bySession[sid] = [])).push(r); + } + const out: Record = {}; + for (const sid of Object.keys(bySession)) { + const ts = bySession[sid] + .map((r) => Date.parse(r.timestamp)) + .filter((n) => !isNaN(n)) + .sort((a, b) => a - b); + let active = 0; + for (let i = 1; i < ts.length; i++) { + const gap = ts[i] - ts[i - 1]; + if (gap > 0) { + active += Math.min(gap, capMs); + } + } + out[sid] = active; + } + return out; + } + + /** "Model right-sizing" (C5): the compute premium spent running a PREMIUM model + * (output ≥ $20/M — Opus / Fable tier) on LIGHTWEIGHT turns (small output, the + * signature of a simple task: a translation, a format, a quick edit / answer). + * For each such turn we compare its input+output cost at its own rates vs. a + * cheap reference model (Haiku 4.5), and sum the difference — that's roughly + * what routing simple tasks to a cheaper model would reclaim. We deliberately + * compare COMPUTE cost only (input+output), not cache: cache read is cheap on + * both, and rewriting it is the SWITCH cost, which we surface separately + * (`switchCostPer`) so the caller can warn that per-turn flip-flopping between + * models flushes the cache — you realise the saving by BATCHING simple tasks + * on the cheap model, not by switching back and forth. Returns null when + * there isn't enough signal. */ + static modelRightsizing( + records: ClaudeUsageRecord[], + windowDays = 30 + ): { grossUsd: number; count: number; topModel: string; topUsd: number; switchCostPer: number } | null { + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const cheap = + getModelRatesPerMillion('claude-haiku-4-5') || { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 }; + let grossUsd = 0; + let count = 0; + let cacheReadSum = 0; + const perModel: Record = {}; + for (const r of records) { + const u = r.message.usage; + const m = r.message.model; + if (!u || !m || m === '' || r.isApiErrorMessage) { + continue; + } + if (new Date(r.timestamp).getTime() < cutoff) { + continue; + } + const rates = getModelRatesPerMillion(m); + if (!rates || rates.output < 20) { + continue; // only premium (Opus / Fable tier) models + } + const out = u.output_tokens || 0; + if (out < 20 || out > 800) { + continue; // lightweight turns only: small output = simple task + } + const inp = u.input_tokens || 0; + const curCost = (inp * rates.input + out * rates.output) / 1_000_000; + const cheapCost = (inp * cheap.input + out * cheap.output) / 1_000_000; + const saving = curCost - cheapCost; + if (saving <= 0) { + continue; + } + grossUsd += saving; + perModel[m] = (perModel[m] || 0) + saving; + cacheReadSum += u.cache_read_input_tokens || 0; + count++; + } + if (count < 5 || grossUsd < 0.5) { + return null; + } + const [topModel, topUsd] = Object.entries(perModel).sort((a, b) => b[1] - a[1])[0]; + // Per-switch churn ballpark: rewriting an average cached context on the cheap + // model. This is the cost to WEIGH the saving against (don't flip per turn). + const avgCache = cacheReadSum / count; + const switchCostPer = (avgCache * cheap.cacheWrite) / 1_000_000; + return { grossUsd, count, topModel, topUsd, switchCostPer }; + } + + /** "Big one-shot turns" (session-health / checkpoint signal): how much of your + * output came from a few very large single responses. A turn that emits + * `jumboOut`+ output tokens (default 4000) is a big one-shot generation with no + * human checkpoint in the middle — harder and costlier to review, and a wrong + * direction is caught late. This surfaces the habit so the user can decide to + * checkpoint more. Not a verdict — a big generation is sometimes exactly right. + * Returns null when there isn't enough signal to bother. */ + static sessionHealth( + records: ClaudeUsageRecord[], + windowDays = 30, + jumboOut = 4000 + ): { jumboSharePct: number; jumboCount: number; jumboTokens: number; biggestOut: number; totalOutput: number } | null { + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + let totalOutput = 0; + let jumboTokens = 0; + let jumboCount = 0; + let biggestOut = 0; + for (const r of records) { + const u = r.message.usage; + const m = r.message.model; + if (!u || !m || m === '' || r.isApiErrorMessage) { + continue; + } + if (new Date(r.timestamp).getTime() < cutoff) { + continue; + } + const out = u.output_tokens || 0; + if (out <= 0) { + continue; + } + totalOutput += out; + if (out > biggestOut) { + biggestOut = out; + } + if (out >= jumboOut) { + jumboTokens += out; + jumboCount++; + } + } + if (totalOutput <= 0 || jumboCount < 5) { + return null; + } + const jumboSharePct = (jumboTokens / totalOutput) * 100; + if (jumboSharePct < 15) { + return null; // not concentrated enough to be worth a nudge + } + return { jumboSharePct, jumboCount, jumboTokens, biggestOut, totalOutput }; + } + + /** Activity by hour-of-day over a window, in the given timezone. Weight is the + * non-cache work of each turn (input + output tokens), so it reflects when you + * actually drive work, not idle cache reads. Returns the 24-hour profile plus + * the busiest contiguous 4-hour window (wraps past midnight) and its share, so + * the card can say "you do X% of your work between 21:00–01:00". Null when + * there's too little to bother. */ + static activeHours( + records: ClaudeUsageRecord[], + windowDays = 30, + timeZone?: string + ): { hours: number[]; peakStart: number; peakShare: number; total: number } | null { + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const hours = new Array(24).fill(0); + let total = 0; + const fmt = timeZone + ? new Intl.DateTimeFormat('en-US', { hour: '2-digit', hour12: false, timeZone }) + : null; + for (const r of records) { + const u = r.message.usage; + const m = r.message.model; + if (!u || !m || m === '' || r.isApiErrorMessage) { + continue; + } + const d = new Date(r.timestamp); + if (isNaN(d.getTime()) || d.getTime() < cutoff) { + continue; + } + let h: number; + if (fmt) { + const part = fmt.formatToParts(d).find((p) => p.type === 'hour'); + h = (part ? parseInt(part.value, 10) : d.getHours()) % 24; + } else { + h = d.getHours(); + } + const w = (u.output_tokens || 0) + (u.input_tokens || 0); + hours[h] += w; + total += w; + } + if (total <= 0) { + return null; + } + // Busiest contiguous 4-hour window (wrapping around midnight). + let peakStart = 0; + let peakSum = -1; + for (let s = 0; s < 24; s++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += hours[(s + k) % 24]; + } + if (sum > peakSum) { + peakSum = sum; + peakStart = s; + } + } + return { hours, peakStart, peakShare: (peakSum / total) * 100, total }; + } + + /** "Skill ROI": per skill / plugin, the cost you spend while it's active and + * the output it produces, over a window. Uses the authoritative attribution + * Claude Code ≥2.1 stamps on usage lines (`_skill` / `_plugin`) — not the + * heuristic — so it only covers turns where a skill was actually tagged. The + * ROI angle is `outPerUsd` (output tokens per $): a lean skill returns lots of + * output per dollar; an expensive one burns context for little. Cost is + * attributed to the active skill, and output tokens aren't "value", so it's a + * proxy, not a verdict. Sorted by cost, top 8. Null when too little is tagged. */ + static skillRoi( + records: ClaudeUsageRecord[], + windowDays = 30 + ): { name: string; kind: 'skill' | 'plugin'; costUsd: number; outputTokens: number; turns: number; outPerUsd: number }[] | null { + const cutoff = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const agg: Record = {}; + let tagged = 0; + for (const r of records) { + const u = r.message.usage; + const m = r.message.model; + if (!u || !m || m === '' || r.isApiErrorMessage) { + continue; + } + if (new Date(r.timestamp).getTime() < cutoff) { + continue; + } + const name = r._skill || r._plugin; + if (!name) { + continue; + } + const kind: 'skill' | 'plugin' = r._skill ? 'skill' : 'plugin'; + const cb = calculateCostBreakdown(u, m); + const cost = cb.input + cb.output + cb.cacheWrite + cb.cacheRead; + const a = agg[name] ?? (agg[name] = { kind, costUsd: 0, outputTokens: 0, turns: 0 }); + a.costUsd += cost; + a.outputTokens += u.output_tokens || 0; + a.turns += 1; + tagged++; + } + if (tagged < 10) { + return null; + } + return Object.entries(agg) + .filter(([, a]) => a.turns >= 3 && a.costUsd > 0.05) + .map(([name, a]) => ({ + name, + kind: a.kind, + costUsd: a.costUsd, + outputTokens: a.outputTokens, + turns: a.turns, + outPerUsd: a.outputTokens / a.costUsd, + })) + .sort((x, y) => y.costUsd - x.costUsd) + .slice(0, 8); + } + + /** Nominal vendor of a model id (the logs don't record the serving endpoint). */ + private static providerOf(model: string): string { + const s = model.toLowerCase(); + if (/opus|sonnet|haiku|fable|mythos|claude/.test(s)) return 'Anthropic'; + if (/deepseek/.test(s)) return 'DeepSeek'; + if (/gpt|openai|o1|o3|o4/.test(s)) return 'OpenAI'; + if (/gemini/.test(s)) return 'Google'; + if (/glm|zhipu/.test(s)) return 'Zhipu'; + if (/qwen/.test(s)) return 'Qwen'; + if (/kimi|moonshot/.test(s)) return 'Moonshot'; + return 'Other'; + } + + /** Assemble the aggregate inputs for a share card. `range` is a preset + * (today / week / month / year) or `month:YYYY-MM` for a specific calendar + * month. `scope` narrows to one project (`project:`) or session + * (`session:`); default all. Everything is bucketed in the configured + * timezone so it matches the dashboard. Aggregate numbers only — no records. */ + static buildShareInput( + records: ClaudeUsageRecord[], + range: ShareRange, + scope: string = 'all' + ): ShareInput { + const tz = I18n.getTimezone(); + + // Scope filter first (project path or session id). + let scoped = records; + let projectName: string | undefined; + if (scope.startsWith('project:')) { + const path = scope.slice('project:'.length); + scoped = records.filter((r) => r._projectPath === path || r._projectDirEncoded === path); + projectName = scoped.find((r) => r._projectName)?._projectName; + } else if (scope.startsWith('session:')) { + const sid = scope.slice('session:'.length); + scoped = records.filter((r) => r._sessionId === sid); + } + + // Range filter. + let inRange: ClaudeUsageRecord[]; + if (range === 'today') { + const todayKey = dayKeyInZone(new Date(), tz); + inRange = scoped.filter((r) => dayKeyInZone(new Date(r.timestamp), tz) === todayKey); + } else if (range === 'week') { + const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; + inRange = scoped.filter((r) => new Date(r.timestamp).getTime() >= cutoff); + } else if (range === 'last30') { + const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000; + inRange = scoped.filter((r) => new Date(r.timestamp).getTime() >= cutoff); + } else if (range === 'year') { + const cutoff = Date.now() - 365 * 24 * 60 * 60 * 1000; + inRange = scoped.filter((r) => new Date(r.timestamp).getTime() >= cutoff); + } else if (range.startsWith('month:')) { + const monthKey = range.slice('month:'.length); // 'YYYY-MM' + inRange = scoped.filter((r) => monthKeyInZone(new Date(r.timestamp), tz) === monthKey); + } else { + const monthKey = monthKeyInZone(new Date(), tz); + inRange = scoped.filter((r) => monthKeyInZone(new Date(r.timestamp), tz) === monthKey); + } + + const rangeData = this.calculateUsageData(inRange); + + // Rhythm: hourly for a single day (so "today" isn't one lonely bar), else + // per-day token totals, oldest → newest (+ parallel date/hour labels). + let daily: number[]; + let dailyDates: string[]; + if (range === 'today') { + const hourFmt = new Intl.DateTimeFormat('en-US', { + timeZone: tz || undefined, + hour: '2-digit', + hour12: false, + }); + const hours = new Array(24).fill(0); + for (const r of inRange) { + const u = r.message.usage; + const model = r.message.model; + if (!u || !model || model === '' || r.isApiErrorMessage) { + continue; + } + const tokens = + u.input_tokens + u.output_tokens + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0); + if (tokens <= 0) { + continue; + } + const h = parseInt(hourFmt.format(new Date(r.timestamp)), 10) % 24; + hours[h] += tokens; + } + daily = hours; + dailyDates = hours.map((_, h) => String(h).padStart(2, '0') + ':00'); + } else { + const dayMap = this.getDailyUsageMap(inRange, tz); + dailyDates = Object.keys(dayMap).sort(); + daily = dailyDates.map((k) => dayMap[k].tokens); + } + + // Distinct billable sessions in range (mirrors the getDailyUsageMap filter). + const sessions = new Set(); + for (const r of inRange) { + const u = r.message.usage; + const model = r.message.model; + if (!u || !model || model === '' || r.isApiErrorMessage || !r._sessionId) { + continue; + } + sessions.add(r._sessionId); + } + + // Top model by total tokens (reduced to a family/name at export time). + let topModel: string | undefined; + let best = -1; + for (const [m, mb] of Object.entries(rangeData.modelBreakdown)) { + const t = mb.inputTokens + mb.outputTokens + mb.cacheCreationTokens + mb.cacheReadTokens; + if (t > best) { + best = t; + topModel = m; + } + } + + return { + range, + rangeData, + daily, + dailyDates, + sessionCount: sessions.size, + topModel, + projectName, + rangeLabel: shareRangeLabel(range), + }; + } + /** * Group records by their source session (.jsonl file) and aggregate usage per session. * Returns sessions with billable usage, sorted by most recent activity first. @@ -2063,23 +2872,20 @@ export class ClaudeDataLoader { } static getDailyDataForSpecificMonth(records: ClaudeUsageRecord[], monthDateString: string): { date: string; data: UsageData }[] { - // monthDateString format: YYYY-MM-01 (first day of the month) - const monthDate = new Date(monthDateString); - const monthStart = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); - const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0); // Last day of the month - - const monthRecords = records.filter((record) => { - const recordDate = new Date(record.timestamp); - return recordDate >= monthStart && recordDate <= monthEnd; - }); + // monthDateString is a "YYYY-MM-..." key already produced in the configured + // zone (see getDailyDataForAllTime), so take its month directly and bucket + // by day in the same zone — no local/UTC boundary mismatch. + const tz = I18n.getTimezone(); + const targetMonth = monthDateString.slice(0, 7); // Group records by date const recordsByDate: Record = {}; - monthRecords.forEach((record) => { - const recordDate = new Date(record.timestamp); - const dateKey = recordDate.toISOString().split('T')[0]; // YYYY-MM-DD - + records.forEach((record) => { + const dateKey = dayKeyInZone(new Date(record.timestamp), tz); + if (!dateKey || dateKey.slice(0, 7) !== targetMonth) { + return; + } if (!recordsByDate[dateKey]) { recordsByDate[dateKey] = []; } @@ -2096,13 +2902,15 @@ export class ClaudeDataLoader { } static getDailyDataForAllTime(records: ClaudeUsageRecord[]): { date: string; data: UsageData }[] { - // Group all records by month for all-time view + // Group all records by month (in the configured timezone) for all-time view + const tz = I18n.getTimezone(); const recordsByMonth: Record = {}; records.forEach((record) => { - const recordDate = new Date(record.timestamp); - const monthKey = `${recordDate.getFullYear()}-${String(recordDate.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM - + const monthKey = monthKeyInZone(new Date(record.timestamp), tz); // YYYY-MM + if (!monthKey) { + return; + } if (!recordsByMonth[monthKey]) { recordsByMonth[monthKey] = []; } diff --git a/src/dateKeys.ts b/src/dateKeys.ts new file mode 100644 index 00000000..0c8bcf0c --- /dev/null +++ b/src/dateKeys.ts @@ -0,0 +1,37 @@ +// Calendar-date keys for usage bucketing, in a chosen IANA timezone +// (empty string = the system zone). +// +// Day and month bucketing MUST agree on ONE zone. The old loader mixed a UTC +// day key (toISOString()) with a *local* month boundary, so a record just after +// local midnight on the 1st (UTC still on the 30th) landed under a "30th" row +// inside the new month's view. Deriving both the day and the month key from the +// same zone here removes that split. + +function partsInZone(date: Date, timeZone: string): { y: string; m: string; d: string } { + const base: Intl.DateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit' }; + let fmt: Intl.DateTimeFormat; + try { + // en-CA formats as YYYY-MM-DD; an invalid user-typed zone throws → fall back. + fmt = new Intl.DateTimeFormat('en-CA', timeZone ? { ...base, timeZone } : base); + } catch { + fmt = new Intl.DateTimeFormat('en-CA', base); + } + const parts = fmt.formatToParts(date); + const get = (t: string): string => parts.find((p) => p.type === t)?.value ?? ''; + return { y: get('year'), m: get('month'), d: get('day') }; +} + +/** "YYYY-MM-DD" for a timestamp in `timeZone`; '' when the date is invalid. */ +export function dayKeyInZone(date: Date, timeZone: string): string { + if (isNaN(date.getTime())) { + return ''; + } + const { y, m, d } = partsInZone(date, timeZone); + return y && m && d ? `${y}-${m}-${d}` : ''; +} + +/** "YYYY-MM" for a timestamp in `timeZone`; '' when the date is invalid. */ +export function monthKeyInZone(date: Date, timeZone: string): string { + const key = dayKeyInZone(date, timeZone); + return key ? key.slice(0, 7) : ''; +} diff --git a/src/extension.ts b/src/extension.ts index 01ee4e8b..5e08f534 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,10 @@ import * as fs from 'fs'; +import * as https from 'https'; import * as path from 'path'; +import * as os from 'os'; +import { renderHeatmapSvg } from './heatmapSvg'; +import { DEFAULT_SECTIONS, ShareRange, buildShareCardData, shareCardFilename } from './shareCard'; +import { renderShareCardSvg } from './shareCardSvg'; import * as vscode from 'vscode'; import { ClaudeDataLoader } from './dataLoader'; import { StatusBarManager } from './statusBar'; @@ -18,6 +23,21 @@ import { getDemoBody } from './adviceDemoSample'; import { ClaudeApiUsageResponse, ContentAnalysis, ExtensionConfig } from './types'; import { SettingsStore } from './settings'; +// One-line "what's new" per major.minor, shown once after an upgrade (see +// maybeAnnounceWhatsNew) so users discover new — including opt-in — features. +// Keep it short and point at the dashboard / ⚙ Settings. +const WHATS_NEW: Record = { + '2.2': + 'what’s new —\n' + + '• Conversation viewer — re-read a past session read-only, without spending model context\n' + + '• Shareable usage card (themed)\n' + + '• Token heatmap you can publish to your GitHub profile\n' + + '• Sessions “Active time” column\n' + + '• Live-refresh delay control\n' + + '• Experimental insights — cache-churn bill, cache warmth by model, big one-shot turns, your active hours, skill ROI\n' + + 'Most are opt-in — turn them on in ⚙ Settings (they show up on the Content / Sessions tabs).', +}; + export class ClaudeCodeUsageExtension { private statusBar: StatusBarManager; private webviewProvider: UsageWebviewProvider; @@ -86,6 +106,9 @@ export class ClaudeCodeUsageExtension { // Migrate any pre-2.1 settings.json values for the keys that have moved out // of the VS Code Settings UI into the dashboard-managed store. Runs once. void this.settings.migrateOnce(); + // V2.2: convert the old pauseDashboardRefresh to the positive + // dashboardAutoRefresh (inverted). Runs once. + void this.settings.migrateDashboardAutoRefresh(); // 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. @@ -102,14 +125,60 @@ export class ClaudeCodeUsageExtension { this.startAutoRefresh(); this.refreshData().then(() => this.startFileWatching()); this.startCredentialsWatching(); + this.maybeAnnounceWhatsNew(); console.log('Claude Code Usage Extension: Initialization complete'); } + /** After an upgrade, show a single "what's new" notification pointing at the + * dashboard — so users discover new features (including opt-in, default-off + * ones they'd never find otherwise). Shown once per version; skipped on a + * fresh install (no nagging new users). */ + private maybeAnnounceWhatsNew(): void { + const current = (this.context.extension?.packageJSON?.version as string) || ''; + const last = this.context.globalState.get('ccu.lastSeenVersion'); + if (!current || last === current) { + return; + } + void this.context.globalState.update('ccu.lastSeenVersion', current); + if (!last) { + return; // fresh install — don't interrupt + } + // Keyed by major.minor so patch releases don't re-nag. + const mm = current.split('.').slice(0, 2).join('.'); + this.showWhatsNew(mm); + } + + /** Show the what's-new toast for a given major.minor, if one exists. */ + private showWhatsNew(mm: string): void { + const news = WHATS_NEW[mm]; + if (!news) { + return; + } + const open = I18n.t.popup.title; // "Show details" entry point label + void vscode.window.showInformationMessage(`Claude Code Usage ${mm}: ${news}`, open).then((pick) => { + if (pick === open) { + vscode.commands.executeCommand('claudeCodeUsage.showDetails'); + } + }); + } + + /** Force-show the newest what's-new entry, ignoring the once-per-version + * guard — for re-reading the announcement or testing it during development + * (a fresh F5 install otherwise just sets the baseline and shows nothing). */ + private previewWhatsNew(): void { + const latest = Object.keys(WHATS_NEW).sort().pop(); + if (latest) { + this.showWhatsNew(latest); + } else { + void vscode.window.showInformationMessage('No what’s-new entry yet.'); + } + } + private setupCommands(): void { const commands = [ vscode.commands.registerCommand('claudeCodeUsage.refresh', () => { // Manual refresh always updates the dashboard even when - // pauseDashboardRefresh is on. + // dashboardAutoRefresh is off. this.refreshData(true); }), vscode.commands.registerCommand('claudeCodeUsage.showDetails', () => { @@ -126,6 +195,18 @@ export class ClaudeCodeUsageExtension { }), vscode.commands.registerCommand('claudeCodeUsage.showLogs', () => { this.outputChannel.show(); + }), + vscode.commands.registerCommand('claudeCodeUsage.exportHeatmap', () => { + this.exportHeatmap(); + }), + vscode.commands.registerCommand('claudeCodeUsage.publishHeatmapToGitHub', () => { + this.publishHeatmapToGitHub(); + }), + vscode.commands.registerCommand('claudeCodeUsage.exportShareCard', () => { + this.exportShareCard(); + }), + vscode.commands.registerCommand('claudeCodeUsage.previewWhatsNew', () => { + this.previewWhatsNew(); }) ]; @@ -145,6 +226,235 @@ export class ClaudeCodeUsageExtension { } } + /** Export the token heatmap as a GitHub-profile-ready SVG (a "contribution + * graph" of the trailing year, Claude orange). Offers to copy the Markdown + * embed snippet. */ + private async exportHeatmap(): Promise { + const records = this.cache.records; + if (!records || records.length === 0) { + vscode.window.showWarningMessage(I18n.t.popup.noDataMessage); + return; + } + const daily = ClaudeDataLoader.getDailyUsageMap(records, I18n.getTimezone()); + const svg = renderHeatmapSvg(daily); + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), 'claude-code-heatmap.svg')), + filters: { 'SVG image': ['svg'] }, + saveLabel: 'Export heatmap', + }); + if (!uri) { + return; + } + try { + await vscode.workspace.fs.writeFile(uri, Buffer.from(svg, 'utf8')); + } catch (e) { + vscode.window.showErrorMessage(`Heatmap export failed: ${(e as Error).message}`); + return; + } + const copy = 'Copy Markdown embed'; + const pick = await vscode.window.showInformationMessage('Token heatmap exported.', copy); + if (pick === copy) { + await vscode.env.clipboard.writeText(`![Claude Code token heatmap](${path.basename(uri.fsPath)})`); + } + } + + /** Minimal GitHub REST call over https (the codebase avoids fetch for + * older-runtime safety). Returns the status + raw body. */ + private githubApi( + method: string, + apiPath: string, + token: string, + body?: unknown + ): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : undefined; + const req = https.request( + { + hostname: 'api.github.com', + path: apiPath, + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'ClaudeCodeUsage-VSCode', + 'X-GitHub-Api-Version': '2022-11-28', + ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}), + }, + timeout: 20000, + }, + (res) => { + let b = ''; + res.on('data', (c) => (b += c)); + res.on('end', () => resolve({ status: res.statusCode || 0, body: b })); + } + ); + req.on('error', reject); + req.on('timeout', () => req.destroy(new Error('GitHub request timed out'))); + if (data) { + req.write(data); + } + req.end(); + }); + } + + /** Publish the token heatmap SVG to a GitHub repo (e.g. the user's profile + * repo, so it shows on their GitHub home) using VS Code's built-in GitHub + * auth — no PAT handling. Creates or updates the file via the Contents API. */ + private async publishHeatmapToGitHub(): Promise { + const records = this.cache.records; + if (!records || records.length === 0) { + vscode.window.showWarningMessage(I18n.t.popup.noDataMessage); + return; + } + + // This is an authorization + write action, not a local export — make that + // explicit and get consent before touching GitHub. + const proceed = 'Sign in & publish'; + const ok = await vscode.window.showWarningMessage( + 'Publish the token heatmap to GitHub?', + { + modal: true, + detail: + 'This signs you in to GitHub (VS Code asks once) and commits a single SVG image to a repo you choose (default: your profile repo, so it shows on your GitHub home). Only the aggregate heatmap is uploaded — never prompts, file paths, or session ids. You can delete it from the repo anytime.', + }, + proceed + ); + if (ok !== proceed) { + return; + } + + let session: vscode.AuthenticationSession | undefined; + try { + // 'repo' so private profile repos work too; VS Code shows its own consent. + session = await vscode.authentication.getSession('github', ['repo'], { createIfNone: true }); + } catch { + vscode.window.showErrorMessage('GitHub sign-in was cancelled.'); + return; + } + if (!session) { + return; + } + const token = session.accessToken; + const login = session.account.label.split(/\s/)[0]; + + const repo = await vscode.window.showInputBox({ + prompt: 'Target repo (owner/name). Your profile repo (name = username) shows the heatmap on your GitHub home page.', + value: this.context.globalState.get('ccu.heatmapRepo') || `${login}/${login}`, + validateInput: (v) => (/^[^/\s]+\/[^/\s]+$/.test(v.trim()) ? undefined : 'Use the form owner/name'), + }); + if (!repo) { + return; + } + const filePath = + (await vscode.window.showInputBox({ + prompt: 'File path in the repo', + value: this.context.globalState.get('ccu.heatmapPath') || 'claude-code-heatmap.svg', + })) || ''; + if (!filePath) { + return; + } + await this.context.globalState.update('ccu.heatmapRepo', repo.trim()); + await this.context.globalState.update('ccu.heatmapPath', filePath.trim()); + + const [owner, name] = repo.trim().split('/'); + const svg = renderHeatmapSvg(ClaudeDataLoader.getDailyUsageMap(records, I18n.getTimezone())); + const contentB64 = Buffer.from(svg, 'utf8').toString('base64'); + const apiPath = `/repos/${owner}/${name}/contents/${filePath.trim().split('/').map(encodeURIComponent).join('/')}`; + + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Publishing heatmap to GitHub…' }, + async () => { + // Look up the current sha (needed to update an existing file). + let sha: string | undefined; + const getRes = await this.githubApi('GET', apiPath, token); + if (getRes.status === 200) { + sha = JSON.parse(getRes.body).sha as string; + } else if (getRes.status !== 404) { + throw new Error(this.githubError(getRes)); + } + const putRes = await this.githubApi('PUT', apiPath, token, { + message: 'Update Claude Code usage heatmap', + content: contentB64, + sha, + }); + if (putRes.status !== 200 && putRes.status !== 201) { + throw new Error(this.githubError(putRes)); + } + } + ); + } catch (e) { + vscode.window.showErrorMessage(`Heatmap publish failed: ${(e as Error).message}`); + return; + } + + const view = 'View on GitHub'; + const pick = await vscode.window.showInformationMessage( + `Heatmap published to ${repo.trim()}.`, + view + ); + if (pick === view) { + void vscode.env.openExternal(vscode.Uri.parse(`https://github.com/${owner}/${name}/blob/HEAD/${filePath.trim()}`)); + } + } + + /** Pull a human message out of a GitHub error response. */ + private githubError(res: { status: number; body: string }): string { + try { + const msg = JSON.parse(res.body).message; + return `GitHub ${res.status}: ${msg || res.body.slice(0, 120)}`; + } catch { + return `GitHub ${res.status}`; + } + } + + /** Export a one-page usage share card as a self-contained SVG. Only aggregate, + * non-identifying metrics are drawn (privacy is enforced by ShareCardData's + * shape). The user picks the range; the card opens for review after saving. */ + private async exportShareCard(): Promise { + const records = this.cache.records; + if (!records || records.length === 0) { + vscode.window.showWarningMessage(I18n.t.popup.noDataMessage); + return; + } + const ranges: (vscode.QuickPickItem & { range: ShareRange })[] = [ + { label: 'This month', range: 'month' }, + { label: 'Last 7 days', range: 'week' }, + { label: 'Today', range: 'today' }, + ]; + const picked = await vscode.window.showQuickPick(ranges, { + placeHolder: 'Share card range', + }); + if (!picked) { + return; + } + const input = ClaudeDataLoader.buildShareInput(records, picked.range); + 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 defaultName = shareCardFilename(picked.range).replace(/\.png$/, '.svg'); + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), + filters: { 'SVG image': ['svg'] }, + saveLabel: 'Export share card', + }); + if (!uri) { + return; + } + try { + await vscode.workspace.fs.writeFile(uri, Buffer.from(svg, 'utf8')); + } catch (e) { + vscode.window.showErrorMessage(`Share card export failed: ${(e as Error).message}`); + return; + } + const open = 'Open'; + const pick = await vscode.window.showInformationMessage('Usage share card exported.', open); + if (pick === open) { + await vscode.commands.executeCommand('vscode.open', uri); + } + } + private async getAdvice(): Promise { const config = this.getConfiguration(); // The subscription backend needs no API key (it reuses the Claude Code @@ -361,6 +671,7 @@ export class ClaudeCodeUsageExtension { const config = this.getConfiguration(); I18n.setLanguage(config.language as any); I18n.setDecimalPlaces(config.decimalPlaces); + I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); I18n.setCompactNumbers(config.compactNumbers); I18n.setTimezone(config.timezone); this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar); @@ -395,6 +706,7 @@ export class ClaudeCodeUsageExtension { dataDirectory: s.get('dataDirectory'), language: s.get('language'), decimalPlaces: s.get('decimalPlaces'), + tokenDecimalPlaces: s.get('tokenDecimalPlaces'), compactNumbers: s.get('compactNumbers'), timezone: s.get('timezone'), showCost: s.get('showCost'), @@ -402,9 +714,9 @@ export class ClaudeCodeUsageExtension { contextWindowOverride: s.get('contextWindowOverride'), statusBarMetric: s.get<'cost' | 'monthly-cost' | 'tokens'>('statusBarMetric'), showOpusWeekly: s.get('showOpusWeekly'), - usageLimitTracking: s.get('usageLimitTracking'), - quotaFiveHourOnly: s.get('quotaFiveHourOnly'), showResetInStatusBar: s.get('showResetInStatusBar'), + quotaFiveHourOnly: s.get('quotaFiveHourOnly'), + usageLimitTracking: s.get('usageLimitTracking'), adviceApiKey: s.get('advice.apiKey'), adviceApiUrl: s.get('advice.apiUrl'), adviceModel: s.get('advice.model'), @@ -419,8 +731,8 @@ export class ClaudeCodeUsageExtension { advicePromptWindowDays: s.get('advice.promptWindowDays'), enableContentAnalysis: s.get('enableContentAnalysis'), projectGroupingMode: s.get<'git' | 'folder' | 'flat'>('projectGroupingMode'), - fileWatching: s.get('fileWatching'), - pauseDashboardRefresh: s.get('pauseDashboardRefresh') + fileWatchSeconds: Number(s.get('fileWatchSeconds') ?? '2') || 0, + dashboardAutoRefresh: s.get('dashboardAutoRefresh') }; } @@ -448,6 +760,7 @@ export class ClaudeCodeUsageExtension { const config = this.getConfiguration(); I18n.setLanguage(config.language as any); I18n.setDecimalPlaces(config.decimalPlaces); + I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); I18n.setCompactNumbers(config.compactNumbers); I18n.setTimezone(config.timezone); this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar); @@ -475,8 +788,8 @@ export class ClaudeCodeUsageExtension { */ private async startFileWatching(): Promise { const config = this.getConfiguration(); - if (!config.fileWatching) { - this.stopFileWatching(); + if (!(config.fileWatchSeconds > 0)) { + this.stopFileWatching(); // "Off" return; } const dataDirectory = await ClaudeDataLoader.findClaudeDataDirectory(config.dataDirectory || undefined); @@ -504,7 +817,7 @@ export class ClaudeCodeUsageExtension { } this.watchDebounceTimer = setTimeout(() => { this.refreshData(); - }, 1500); + }, config.fileWatchSeconds * 1000); }); this.watchedDir = projectsDir; } catch { @@ -715,11 +1028,11 @@ export class ClaudeCodeUsageExtension { this.isRefreshing = true; try { const config = this.getConfiguration(); - // When the user has paused dashboard refresh, auto-triggers (timer + - // fs.watch) skip the webview update entirely; the status bar still - // refreshes so today's cost / quota stay live. Manual command always - // refreshes everything so the user can force-update on demand. - const updateWebview = manualTrigger || !config.pauseDashboardRefresh; + // When dashboard auto-refresh is off, auto-triggers (timer + fs.watch) + // skip the webview update entirely; the status bar still refreshes so + // today's cost / quota stay live. Manual command always refreshes + // everything so the user can force-update on demand. + const updateWebview = manualTrigger || config.dashboardAutoRefresh; // Quota is account-level, decoupled from local data. Fire it without // awaiting so a slow/cold OAuth fetch (curl can take seconds, or fail @@ -835,6 +1148,7 @@ export class ClaudeCodeUsageExtension { const projectBreakdown = ClaudeDataLoader.getProjectBreakdown(records, undefined, config.projectGroupingMode); const branchBreakdown = ClaudeDataLoader.getBranchBreakdown(records); const workflowBreakdown = ClaudeDataLoader.getWorkflowBreakdown(records); + const costliestMessages = ClaudeDataLoader.getCostliestMessages(records); // Update UI. Quota is pushed asynchronously by the fire-and-forget fetch // above; passing undefined leaves the quota item untouched here. @@ -843,7 +1157,7 @@ export class ClaudeCodeUsageExtension { ClaudeDataLoader.getCurrentContextInfo(records, workspacePath, config.contextWindowOverride) ); if (updateWebview) { - this.webviewProvider.updateData(sessionData, todayData, monthData, allTimeData, dailyDataForMonth, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown); + this.webviewProvider.updateData(sessionData, todayData, monthData, allTimeData, dailyDataForMonth, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages); } } catch (error) { @@ -852,7 +1166,7 @@ export class ClaudeCodeUsageExtension { this.statusBar.updateUsageData(null, null, errorMessage); this.statusBar.updateContext(null); - if (manualTrigger || !this.getConfiguration().pauseDashboardRefresh) { + if (manualTrigger || this.getConfiguration().dashboardAutoRefresh) { this.webviewProvider.updateData(null, null, null, null, [], [], [], errorMessage, null); } } finally { diff --git a/src/heatmap.ts b/src/heatmap.ts new file mode 100644 index 00000000..71b0ae5a --- /dev/null +++ b/src/heatmap.ts @@ -0,0 +1,85 @@ +// Token heatmap (Monthly tab) — pure, dependency-free logic for the V2.2 +// GitHub-contribution-style month grid. The rendering (themed cells + tooltip) +// lives in the webview; the day bucketing lives here so it's unit-tested and +// reused by the share card's rhythm strip. + +export type HeatMetric = 'tokens' | 'cost' | 'sessions'; + +export interface DayUsage { + tokens: number; + cost: number; + sessions: number; +} + +export interface HeatCell { + day: number; // 1..daysInMonth + dateISO: string; // YYYY-MM-DD + tokens: number; + cost: number; + sessions: number; + value: number; // the selected metric + bucket: number; // 0 (no usage) .. 4 (most) +} + +export interface MonthHeatmap { + year: number; + month: number; // 1..12 + metric: HeatMetric; + cells: HeatCell[]; + max: number; // max value across the month (for the legend) +} + +/** Number of days in a month (month is 1..12). */ +export function daysInMonth(year: number, month: number): number { + return new Date(year, month, 0).getDate(); +} + +/** Intensity bucket 0..4. 0 = no usage; otherwise a quartile of the month's max + * (a tiny non-zero day still reads as bucket 1, the max day as 4). */ +export function intensityBucket(value: number, max: number): number { + if (value <= 0 || max <= 0) { + return 0; + } + return Math.min(4, Math.max(1, Math.ceil((value / max) * 4))); +} + +/** + * Build the month grid. `daily` is keyed by 'YYYY-MM-DD'; missing days are + * zero-filled so every calendar day has a cell. The metric selects which value + * drives the intensity (default tokens). + */ +export function buildMonthHeatmap( + daily: Record, + year: number, + month: number, + metric: HeatMetric = 'tokens' +): MonthHeatmap { + const n = daysInMonth(year, month); + const mm = String(month).padStart(2, '0'); + const valueOf = (u: DayUsage): number => + metric === 'cost' ? u.cost : metric === 'sessions' ? u.sessions : u.tokens; + + const rows: { day: number; dateISO: string; u: DayUsage; value: number }[] = []; + let max = 0; + for (let day = 1; day <= n; day++) { + const dateISO = `${year}-${mm}-${String(day).padStart(2, '0')}`; + const u = daily[dateISO] ?? { tokens: 0, cost: 0, sessions: 0 }; + const value = valueOf(u); + if (value > max) { + max = value; + } + rows.push({ day, dateISO, u, value }); + } + + const cells: HeatCell[] = rows.map((r) => ({ + day: r.day, + dateISO: r.dateISO, + tokens: r.u.tokens, + cost: r.u.cost, + sessions: r.u.sessions, + value: r.value, + bucket: intensityBucket(r.value, max), + })); + + return { year, month, metric, cells, max }; +} diff --git a/src/heatmapSvg.ts b/src/heatmapSvg.ts new file mode 100644 index 00000000..be8aec97 --- /dev/null +++ b/src/heatmapSvg.ts @@ -0,0 +1,223 @@ +// GitHub-contribution-style heatmap as a standalone SVG string — for pasting +// into a GitHub profile README (the "local trial" of the token heatmap). +// +// Pure and dependency-free (no vscode, no DOM) so it unit-tests and can be +// generated headlessly. GitHub's own graph is green; this uses a Claude-orange +// ramp instead. Like GitHub's default view it shows the trailing ~year ending +// today (future days are not drawn). Light-theme palette on purpose — a profile +// README renders on a white/light canvas. + +import { DayUsage, HeatMetric, intensityBucket } from './heatmap'; + +/** Claude-orange 5-step intensity ramp (bucket 0..4). 0 = empty cell. */ +export const CLAUDE_ORANGE_SCALE = ['#ebedf0', '#fadcc9', '#f0aa82', '#e07d4f', '#c85a2b']; + +export interface HeatmapSvgOptions { + metric?: HeatMetric; // default 'tokens' + weeks?: number; // trailing weeks to show (default 53, GitHub-like) + endDateISO?: string; // last day to show (default today) + title?: string; // override the auto summary heading + watermark?: string; // bottom-left source note (default "Made with Claude Code Usage") + scale?: string[]; // 5 colours, empty→max (default CLAUDE_ORANGE_SCALE) +} + +function valueOf(u: DayUsage, metric: HeatMetric): number { + return metric === 'cost' ? u.cost : metric === 'sessions' ? u.sessions : u.tokens; +} + +/** Add `days` to a YYYY-MM-DD key (UTC arithmetic, zone-stable). */ +function addDays(dateISO: string, days: number): string { + const d = new Date(dateISO + 'T00:00:00Z'); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +function weekdayOf(dateISO: string): number { + return new Date(dateISO + 'T00:00:00Z').getUTCDay(); // 0=Sun..6=Sat +} + +function daysBetween(aISO: string, bISO: string): number { + return Math.round((Date.parse(bISO + 'T00:00:00Z') - Date.parse(aISO + 'T00:00:00Z')) / 86_400_000); +} + +/** Compact number: 5.3B / 1.2M / 345K / 42. */ +function compactNum(n: number): string { + const abs = Math.abs(n); + const trim = (x: number): string => x.toFixed(1).replace(/\.0$/, ''); + if (abs >= 1e9) return trim(n / 1e9) + 'B'; + if (abs >= 1e6) return trim(n / 1e6) + 'M'; + if (abs >= 1e3) return trim(n / 1e3) + 'K'; + return String(Math.round(n)); +} + +/** English ordinal: 1st, 2nd, 3rd, 4th, 18th, 21st, 31st. */ +function ordinal(d: number): string { + const v = d % 100; + const suffix = v >= 11 && v <= 13 ? 'th' : ['th', 'st', 'nd', 'rd'][Math.min(d % 10, 4)] || 'th'; + return d + suffix; +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const MONTHS_FULL = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +/** "June 18th" for a YYYY-MM-DD key. */ +function longDate(dateISO: string): string { + const month = MONTHS_FULL[Number(dateISO.slice(5, 7)) - 1]; + return `${month} ${ordinal(Number(dateISO.slice(8, 10)))}`; +} + +const esc = (s: string): string => s.replace(/&/g, '&').replace(//g, '>'); + +export interface HeatGridCell { + dateISO: string; + col: number; // week column (0-based) + row: number; // weekday row 0..6 (Sun..Sat) + value: number; + bucket: number; +} + +export interface HeatGrid { + cells: HeatGridCell[]; // only days in [startISO, endISO] + columns: number; + max: number; + total: number; + startISO: string; + endISO: string; +} + +/** Lay days in [startISO, endISO] into a Sun-started week grid (GitHub layout). + * Days outside the range (the first week's lead-in, and everything after + * endISO) are simply absent — no future padding. */ +export function buildContributionGrid( + daily: Record, + startISO: string, + endISO: string, + metric: HeatMetric +): HeatGrid { + const gridStart = addDays(startISO, -weekdayOf(startISO)); // Sunday on/before start + const span = daysBetween(gridStart, endISO) + 1; + const columns = Math.max(0, Math.ceil(span / 7)); + + const cells: HeatGridCell[] = []; + let max = 0; + let total = 0; + for (let i = 0; i < span; i++) { + const dateISO = addDays(gridStart, i); + if (dateISO < startISO || dateISO > endISO) { + continue; // lead-in / future — not drawn + } + const u = daily[dateISO] ?? { tokens: 0, cost: 0, sessions: 0 }; + const value = valueOf(u, metric); + if (value > max) { + max = value; + } + total += value; + cells.push({ dateISO, col: Math.floor(i / 7), row: i % 7, value, bucket: 0 }); + } + for (const c of cells) { + c.bucket = intensityBucket(c.value, max); + } + return { cells, columns, max, total, startISO, endISO }; +} + +/** Render the trailing-year token heatmap as a self-contained SVG string. */ +export function renderHeatmapSvg(daily: Record, opts: HeatmapSvgOptions = {}): string { + const metric = opts.metric ?? 'tokens'; + const weeks = Math.max(1, Math.min(53, opts.weeks ?? 53)); + const today = opts.endDateISO ?? new Date().toISOString().slice(0, 10); + const scale = opts.scale && opts.scale.length === 5 ? opts.scale : CLAUDE_ORANGE_SCALE; + const watermark = opts.watermark ?? 'Made with Claude Code Usage'; + + // Trailing window: full weeks ending on the Saturday of today's week, cells + // only up to today (no future) — GitHub's default contribution view. + const gridEndSat = addDays(today, 6 - weekdayOf(today)); + const startISO = addDays(gridEndSat, -(weeks * 7 - 1)); // a Sunday, `weeks` back + const grid = buildContributionGrid(daily, startISO, today, metric); + + // Auto summary heading, e.g. "5.3B tokens in Claude Code · 2026". + const year = Number(today.slice(0, 4)); + const summary = + opts.title ?? + (metric === 'cost' + ? `$${compactNum(grid.total)} in Claude Code · ${year}` + : metric === 'sessions' + ? `${compactNum(grid.total)} sessions in Claude Code · ${year}` + : `${compactNum(grid.total)} tokens in Claude Code · ${year}`); + const noun = metric === 'sessions' ? 'sessions' : metric === 'cost' ? '' : 'tokens'; + + const cell = 12; + const gap = 3; + const step = cell + gap; + const padL = 38; // weekday labels + const titleH = 24; + const monthH = 18; + const padT = titleH + monthH; + const gridW = grid.columns * step; + const gridH = 7 * step; + const footerH = 30; + const width = padL + gridW + 10; + const height = padT + gridH + footerH; + + const parts: string[] = []; + parts.push( + `` + ); + parts.push(``); + parts.push(`${esc(summary)}`); + + // Cells with GitHub-style tooltips: "1.2M tokens on June 18th". + for (const c of grid.cells) { + const x = padL + c.col * step; + const y = padT + c.row * step; + const when = longDate(c.dateISO); + const tip = + c.value <= 0 + ? `No ${noun || 'usage'} on ${when}` + : metric === 'cost' + ? `$${compactNum(c.value)} on ${when}` + : `${compactNum(c.value)} ${noun} on ${when}`; + parts.push( + `${esc(tip)}` + ); + } + + // Month labels above the first column that starts a new month. + let lastMonth = -1; + for (let col = 0; col < grid.columns; col++) { + const first = grid.cells.find((c) => c.col === col); + if (!first) { + continue; + } + const m = Number(first.dateISO.slice(5, 7)) - 1; + if (m !== lastMonth) { + lastMonth = m; + parts.push(`${MONTHS[m]}`); + } + } + + // Weekday labels (Mon / Wed / Fri). + for (const [row, label] of [[1, 'Mon'], [3, 'Wed'], [5, 'Fri']] as [number, string][]) { + parts.push(`${label}`); + } + + const footY = padT + gridH + 18; + + // Watermark, bottom-left (an orange dot + source, to point back at the tool). + parts.push(``); + parts.push(`${esc(watermark)}`); + + // Legend, bottom-right: Less [][][][][] More. + let lx = padL + gridW - (5 * step + 56); + parts.push(`Less`); + lx += 26; + for (let b = 0; b < 5; b++) { + parts.push(``); + } + parts.push(`More`); + + parts.push(''); + return parts.join('\n'); +} diff --git a/src/i18n.ts b/src/i18n.ts index e264ab2a..23600346 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -22,6 +22,7 @@ export interface Translations { settingsIntro: string; settingsResetAll: string; settingsGroupGeneral: string; + settingsGroupFeatures: string; settingsGroupStatusBar: string; settingsGroupData: string; settingsGroupAdvice: string; @@ -41,6 +42,8 @@ export interface Translations { project: string; startTime: string; duration: string; + activeDuration: string; + activeDurationHelp: string; hour: string; projects: string; projectBreakdown: string; @@ -99,6 +102,8 @@ export interface Translations { commonTaskPrefix: string; thinkingShare: string; effortHint: string; + thinkingHidden: string; + thinkingHiddenShort: string; quotaWarnBanner: string; dismiss: string; attribution: string; @@ -135,6 +140,7 @@ export interface Translations { copySessionId: string; copyPath: string; resumeSession: string; + viewConversation: string; resumeInvalid: string; sessionFilterCurrent: string; sessionFilterAll: string; @@ -213,6 +219,7 @@ const translations: Record = { 'Settings live here now. Only language, data directory and API key remain in VS Code Settings (so they sync). Changes apply immediately.', settingsResetAll: 'Reset all to defaults', settingsGroupGeneral: 'General', + settingsGroupFeatures: 'Optional features', settingsGroupStatusBar: 'Status bar', settingsGroupData: 'Data & refresh', settingsGroupAdvice: 'AI advice & Optimizer', @@ -232,6 +239,8 @@ const translations: Record = { project: 'Project', startTime: 'Start Time', duration: 'Duration', + activeDuration: 'Active', + activeDurationHelp: 'Estimated hands-on time — the gaps between turns summed, with each idle gap capped at 1.5 h so long breaks don\'t inflate it, while reading / reviewing time still counts. (Duration is the full first-to-last span.)', hour: 'Hour', projects: 'Projects', projectBreakdown: 'Project Usage', @@ -293,6 +302,8 @@ const translations: Record = { commonTaskPrefix: 'Shared task text', thinkingShare: 'Thinking %', effortHint: 'High thinking share — consider /effort high instead of xhigh for tasks like this.', + thinkingHidden: 'Thinking was on, but this model (e.g. Fable 5 / Opus 4.8) does not expose its reasoning text, so the share can’t be measured — the real value is higher than shown.', + thinkingHiddenShort: 'hidden', quotaWarnBanner: 'Only {remaining}% of your 5-hour window is left. A workflow run can consume a large share of it — consider waiting for the reset: interrupted runs lose their prompt cache and re-run ~40% more expensive.', dismiss: 'Dismiss', @@ -332,8 +343,9 @@ const translations: Record = { sessionTitle: 'Session', sessionActions: 'Actions', copySessionId: 'Copy session ID', + viewConversation: 'View this conversation (read-only) — re-read your prompts and the model\'s answers without loading them back into context.', copyPath: 'Copy path', - resumeSession: 'Resume session', + resumeSession: 'Resume this conversation — reopens it in Claude Code (same project) or a terminal via "claude --resume", so you can continue where you left off.', resumeInvalid: 'Invalid session id — cannot resume.', sessionFilterCurrent: 'Current project', sessionFilterAll: 'All', @@ -410,12 +422,12 @@ const translations: Record = { loading: "Lädt...", noData: "Keine Claude Code Daten", notRunning: "Claude Code nicht erreichbar", - error: "Error", + error: "Fehler", currentSession: "Session", }, popup: { title: "Claude Code Nutzung", - currentSession: "Current Session", + currentSession: "Aktuelle Sitzung", today: "Heute", thisMonth: "Diesen Monat", allTime: "Seit Aufzeichnungsbeginn", @@ -428,6 +440,7 @@ const translations: Record = { "Die Einstellungen sind jetzt hier. Nur Sprache, Datenverzeichnis und API-Schlüssel bleiben in den VS-Code-Einstellungen (damit sie synchronisiert werden). Änderungen wirken sofort.", settingsResetAll: "Alle zurücksetzen", settingsGroupGeneral: "Allgemein", + settingsGroupFeatures: "Optionale Funktionen", settingsGroupStatusBar: "Statusleiste", settingsGroupData: "Daten & Aktualisierung", settingsGroupAdvice: "KI-Beratung & Optimizer", @@ -447,6 +460,8 @@ const translations: Record = { project: "Projekt", startTime: "Startzeit", duration: "Dauer", + activeDuration: 'Aktiv', + activeDurationHelp: 'Geschätzte aktive Zeit — die Abstände zwischen den Zügen summiert, jede Leerlauflücke auf 1,5 Std. begrenzt, damit lange Pausen sie nicht aufblähen, Lese-/Prüfzeit aber mitzählt. (Dauer ist die gesamte Spanne.)', hour: "Stunde", projects: "Projekte", projectBreakdown: "Nutzung nach Projekt", @@ -508,6 +523,8 @@ const translations: Record = { commonTaskPrefix: "Gemeinsamer Aufgabentext", thinkingShare: "Denkanteil", effortHint: "Hoher Denkanteil — für solche Aufgaben /effort high statt xhigh erwägen.", + thinkingHidden: 'Denkmodus war aktiv, aber dieses Modell (z. B. Fable 5 / Opus 4.8) gibt den Denktext nicht preis, daher ist der Anteil nicht messbar — der echte Wert ist höher als angezeigt.', + thinkingHiddenShort: 'verborgen', quotaWarnBanner: "Nur noch {remaining}% des 5-Stunden-Fensters übrig. Ein Workflow-Lauf kann einen großen Teil davon verbrauchen — besser auf den Reset warten: unterbrochene Läufe verlieren ihren Prompt-Cache und kosten beim Neustart ~40% mehr.", dismiss: "Ausblenden", @@ -547,8 +564,9 @@ const translations: Record = { sessionTitle: "Sitzung", sessionActions: 'Aktionen', copySessionId: 'Sitzungs-ID kopieren', + viewConversation: 'Dieses Gespräch ansehen (schreibgeschützt) — lies deine Prompts und die Antworten des Modells erneut, ohne sie zurück in den Kontext zu laden.', copyPath: 'Pfad kopieren', - resumeSession: 'Sitzung fortsetzen', + resumeSession: 'Dieses Gespräch fortsetzen — öffnet es erneut in Claude Code (gleiches Projekt) oder in einem Terminal via "claude --resume", um dort weiterzumachen, wo du aufgehört hast.', resumeInvalid: 'Ungültige Sitzungs-ID — Fortsetzen nicht möglich.', sessionFilterCurrent: 'Aktuelles Projekt', sessionFilterAll: 'Alle', @@ -646,6 +664,7 @@ const translations: Record = { '設定現在都在這裡。只有語言、資料目錄與 API 金鑰仍留在 VS Code 設定中(以便同步)。變更會立即生效。', settingsResetAll: '全部還原為預設', settingsGroupGeneral: '一般', + settingsGroupFeatures: '選用功能', settingsGroupStatusBar: '狀態列', settingsGroupData: '資料與重新整理', settingsGroupAdvice: 'AI 建議與最佳化工具', @@ -665,6 +684,8 @@ const translations: Record = { project: '專案', startTime: '開始時間', duration: '時長', + activeDuration: '活躍時長', + activeDurationHelp: '估算的實際操作時間——把各輪之間的間隔加總,每段閒置間隔上限 1.5 小時,避免長時間中斷灌水,同時把閱讀/審閱時間也算進去。(時長是首尾完整跨度。)', hour: '小時', projects: '專案', projectBreakdown: '各專案使用量', @@ -726,6 +747,8 @@ const translations: Record = { commonTaskPrefix: '共同任務文字', thinkingShare: '思考佔比', effortHint: '思考佔比偏高——此類任務可考慮用 /effort high 取代 xhigh。', + thinkingHidden: '思考已開啟,但此模型(如 Fable 5 / Opus 4.8)不會輸出思考文字,因此無法計算佔比——實際值高於此處顯示。', + thinkingHiddenShort: '隱藏', quotaWarnBanner: '5 小時窗口僅剩 {remaining}%。一次工作流運行可能消耗其中很大一部分——建議等待重置後再啟動:中斷的運行會遺失提示快取,重跑成本約高 40%。', dismiss: '關閉', @@ -761,8 +784,9 @@ const translations: Record = { sessionTitle: '會話', sessionActions: '操作', copySessionId: '複製會話 ID', + viewConversation: '檢視這個對話(唯讀)— 重新閱讀你的提示與模型回覆,而不會重新載入到上下文中。', copyPath: '複製路徑', - resumeSession: '恢復會話', + resumeSession: '恢復此對話 —— 在 Claude Code(同一專案)中重新開啟,或透過終端機 “claude --resume” 繼續先前中斷的會話。', resumeInvalid: '無效的會話 ID,無法恢復。', sessionFilterCurrent: '目前專案', sessionFilterAll: '全部', @@ -857,6 +881,7 @@ const translations: Record = { '设置现在都在这里。只有语言、数据目录和 API key 仍留在 VS Code 设置中(便于同步)。更改即时生效。', settingsResetAll: '全部恢复默认', settingsGroupGeneral: '常规', + settingsGroupFeatures: '可选功能', settingsGroupStatusBar: '状态栏', settingsGroupData: '数据与刷新', settingsGroupAdvice: 'AI 建议与优化器', @@ -876,6 +901,8 @@ const translations: Record = { project: '项目', startTime: '开始时间', duration: '时长', + activeDuration: '活跃时长', + activeDurationHelp: '估算的实际操作时间——把各轮之间的间隔加总,每段空闲间隔上限 1.5 小时,避免长时间中断灌水,同时把阅读/审阅时间也算进去。(时长是首尾完整跨度。)', hour: '小时', projects: '项目', projectBreakdown: '各项目使用量', @@ -937,6 +964,8 @@ const translations: Record = { commonTaskPrefix: '共同任务文字', thinkingShare: '思考占比', effortHint: '思考占比偏高——此类任务可考虑用 /effort high 取代 xhigh。', + thinkingHidden: '思考已开启,但此模型(如 Fable 5 / Opus 4.8)不会输出思考文字,因此无法计算占比——实际值高于此处显示。', + thinkingHiddenShort: '隐藏', quotaWarnBanner: '5 小时窗口仅剩 {remaining}%。一次工作流运行可能消耗其中很大一部分——建议等待重置后再启动:中断的运行会丢失提示缓存,重跑成本约高 40%。', dismiss: '关闭', @@ -972,8 +1001,9 @@ const translations: Record = { sessionTitle: '会话', sessionActions: '操作', copySessionId: '复制会话 ID', + viewConversation: '查看这个对话(只读)— 重新阅读你的提示词和模型回复,而不会重新载入到上下文中。', copyPath: '复制路径', - resumeSession: '恢复会话', + resumeSession: '恢复此对话 —— 在 Claude Code(同一项目)中重新打开,或通过终端 “claude --resume” 继续之前中断的会话。', resumeInvalid: '无效的会话 ID,无法恢复。', sessionFilterCurrent: '当前工程', sessionFilterAll: '全部', @@ -1068,6 +1098,7 @@ const translations: Record = { '設定はここにまとまりました。言語・データディレクトリ・API キーのみ VS Code 設定に残ります(同期のため)。変更は即時反映されます。', settingsResetAll: 'すべて既定値に戻す', settingsGroupGeneral: '一般', + settingsGroupFeatures: 'オプション機能', settingsGroupStatusBar: 'ステータスバー', settingsGroupData: 'データと更新', settingsGroupAdvice: 'AI アドバイス & オプティマイザー', @@ -1087,6 +1118,8 @@ const translations: Record = { project: 'プロジェクト', startTime: '開始時刻', duration: '期間', + activeDuration: 'アクティブ', + activeDurationHelp: '実作業時間の推定——各ターン間の間隔を合計し、アイドルは1回あたり1.5時間で上限を設けて長い中断で膨らまないようにしつつ、読む/レビューの時間も算入します。(期間は最初から最後までの全体です。)', hour: '時刻', projects: 'プロジェクト', projectBreakdown: 'プロジェクト別使用量', @@ -1148,6 +1181,8 @@ const translations: Record = { commonTaskPrefix: '共通タスクテキスト', thinkingShare: '思考割合', effortHint: '思考割合が高め — このようなタスクでは xhigh ではなく /effort high の利用を検討してください。', + thinkingHidden: '思考は有効でしたが、このモデル(Fable 5 / Opus 4.8 など)は思考テキストを出力しないため割合を測定できません — 実際の値は表示より高くなります。', + thinkingHiddenShort: '非表示', quotaWarnBanner: '5 時間ウィンドウの残りは {remaining}% のみです。ワークフロー実行はその大部分を消費する可能性があります — リセットを待つことを検討してください。中断された実行はプロンプトキャッシュを失い、再実行は約 40% 高くなります。', dismiss: '閉じる', @@ -1187,8 +1222,9 @@ const translations: Record = { sessionTitle: 'セッション', sessionActions: '操作', copySessionId: 'セッションIDをコピー', + viewConversation: 'この会話を表示(読み取り専用)— プロンプトとモデルの回答を、コンテキストに再読み込みせずに読み返せます。', copyPath: 'パスをコピー', - resumeSession: 'セッションを再開', + resumeSession: 'この会話を再開 — Claude Code(同じプロジェクト)で開き直すか、ターミナルで "claude --resume" を実行し、中断したところから続けます。', resumeInvalid: '無効なセッションIDのため再開できません。', sessionFilterCurrent: '現在のプロジェクト', sessionFilterAll: 'すべて', @@ -1284,6 +1320,7 @@ const translations: Record = { '설정이 이제 여기로 모였습니다. 언어, 데이터 디렉터리, API 키만 VS Code 설정에 남습니다(동기화를 위해). 변경은 즉시 적용됩니다.', settingsResetAll: '모두 기본값으로', settingsGroupGeneral: '일반', + settingsGroupFeatures: '선택 기능', settingsGroupStatusBar: '상태 표시줄', settingsGroupData: '데이터 및 새로고침', settingsGroupAdvice: 'AI 조언 & 옵티마이저', @@ -1303,6 +1340,8 @@ const translations: Record = { project: '프로젝트', startTime: '시작 시간', duration: '사용 시간', + activeDuration: '활성 시간', + activeDurationHelp: '실제 작업 시간 추정 — 턴 사이 간격을 합산하되, 유휴 간격은 1.5시간으로 상한을 두어 긴 중단이 부풀리지 않게 하면서 읽기/검토 시간은 포함합니다. (사용 시간은 처음부터 끝까지 전체 구간입니다.)', hour: '시각', projects: '프로젝트', projectBreakdown: '프로젝트별 사용량', @@ -1364,6 +1403,8 @@ const translations: Record = { commonTaskPrefix: '공통 작업 텍스트', thinkingShare: '사고 비율', effortHint: '사고 비율이 높습니다 — 이런 작업에는 xhigh 대신 /effort high를 고려하세요.', + thinkingHidden: '사고가 켜져 있었지만 이 모델(예: Fable 5 / Opus 4.8)은 사고 텍스트를 노출하지 않아 비율을 측정할 수 없습니다 — 실제 값은 표시된 것보다 높습니다.', + thinkingHiddenShort: '숨김', quotaWarnBanner: '5시간 윈도우가 {remaining}%만 남았습니다. 워크플로 실행은 그중 큰 부분을 소비할 수 있습니다 — 리셋을 기다리는 것을 고려하세요. 중단된 실행은 프롬프트 캐시를 잃어 재실행 비용이 약 40% 더 듭니다.', dismiss: '닫기', @@ -1403,8 +1444,9 @@ const translations: Record = { sessionTitle: '세션', sessionActions: '작업', copySessionId: '세션 ID 복사', + viewConversation: '이 대화 보기(읽기 전용) — 프롬프트와 모델 답변을 컨텍스트에 다시 불러오지 않고 다시 읽어볼 수 있습니다.', copyPath: '경로 복사', - resumeSession: '세션 재개', + resumeSession: '이 대화 재개 — Claude Code(같은 프로젝트)에서 다시 열거나 터미널에서 "claude --resume"으로 중단한 지점부터 이어갑니다.', resumeInvalid: '잘못된 세션 ID입니다 — 재개할 수 없습니다.', sessionFilterCurrent: '현재 프로젝트', sessionFilterAll: '전체', @@ -1500,6 +1542,7 @@ const translations: Record = { 'As configurações agora ficam aqui. Apenas idioma, diretório de dados e chave de API permanecem nas Configurações do VS Code (para sincronizar). As alterações são aplicadas imediatamente.', settingsResetAll: 'Restaurar tudo para os padrões', settingsGroupGeneral: 'Geral', + settingsGroupFeatures: 'Recursos opcionais', settingsGroupStatusBar: 'Barra de status', settingsGroupData: 'Dados e atualização', settingsGroupAdvice: 'Conselho de IA e Optimizer', @@ -1519,6 +1562,8 @@ const translations: Record = { project: 'Projeto', startTime: 'Início', duration: 'Duração', + activeDuration: 'Ativo', + activeDurationHelp: 'Tempo de uso estimado — a soma dos intervalos entre turnos, com cada intervalo ocioso limitado a 1,5 h para que pausas longas não o inflem, mas o tempo de leitura / revisão ainda conta. (Duração é o intervalo completo do início ao fim.)', hour: 'Hora', projects: 'Projetos', projectBreakdown: 'Uso por projeto', @@ -1580,6 +1625,8 @@ const translations: Record = { commonTaskPrefix: 'Texto de tarefa compartilhado', thinkingShare: '% de raciocínio', effortHint: 'Alta proporção de raciocínio — considere /effort high em vez de xhigh para tarefas como esta.', + thinkingHidden: 'O raciocínio estava ativo, mas este modelo (por ex. Fable 5 / Opus 4.8) não expõe o texto do raciocínio, então a proporção não pode ser medida — o valor real é maior que o exibido.', + thinkingHiddenShort: 'oculto', quotaWarnBanner: 'Resta apenas {remaining}% da sua janela de 5 horas. Uma execução de workflow pode consumir grande parte dela — considere esperar o reset: execuções interrompidas perdem o cache de prompt e refazem ~40% mais caro.', dismiss: 'Dispensar', @@ -1619,8 +1666,9 @@ const translations: Record = { sessionTitle: 'Sessão', sessionActions: 'Ações', copySessionId: 'Copiar ID da sessão', + viewConversation: 'Ver esta conversa (somente leitura) — releia seus prompts e as respostas do modelo sem recarregá-los no contexto.', copyPath: 'Copiar caminho', - resumeSession: 'Retomar sessão', + resumeSession: 'Retomar esta conversa — reabre no Claude Code (mesmo projeto) ou num terminal via "claude --resume", para continuar de onde parou.', resumeInvalid: 'ID de sessão inválido — não é possível retomar.', sessionFilterCurrent: 'Projeto atual', sessionFilterAll: 'Todos', @@ -1703,22 +1751,30 @@ const SETTINGS_I18N: Partial= 0 && places <= 2) { + this.tokenDecimalPlaces = Math.floor(places); + } + } + /** Toggle compact number formatting, e.g. 1.2M / 345K (claudeCodeUsage.compactNumbers). */ static setCompactNumbers(enabled: boolean): void { this.compactNumbers = !!enabled; @@ -1987,7 +2107,7 @@ export class I18n { /** Always-compact token count (k / M / B) honouring the user's decimal * places — used by the status-bar "tokens" metric so it stays short. */ static formatTokensCompact(num: number): string { - const p = this.currentDecimalPlaces; + const p = this.tokenDecimalPlaces; const abs = Math.abs(num); if (abs >= 1_000_000_000) { return (num / 1_000_000_000).toFixed(p) + 'B'; @@ -2003,15 +2123,18 @@ export class I18n { static formatNumber(num: number): string { if (this.compactNumbers) { + // Compact token display honours tokenDecimalPlaces (0–2); parseFloat trims + // trailing zeros so 1.20M reads as 1.2M. + const p = this.tokenDecimalPlaces; const abs = Math.abs(num); if (abs >= 1_000_000_000) { - return parseFloat((num / 1_000_000_000).toFixed(2)) + 'B'; + return parseFloat((num / 1_000_000_000).toFixed(p)) + 'B'; } if (abs >= 1_000_000) { - return parseFloat((num / 1_000_000).toFixed(2)) + 'M'; + return parseFloat((num / 1_000_000).toFixed(p)) + 'M'; } if (abs >= 1_000) { - return parseFloat((num / 1_000).toFixed(1)) + 'K'; + return parseFloat((num / 1_000).toFixed(p)) + 'K'; } } // Use the user's selected locale so the thousands separator etc. match diff --git a/src/miniMarkdown.ts b/src/miniMarkdown.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bab7ca76e4ff214cdd95e009e93fd65e2b2ccce GIT binary patch literal 6385 zcmb7I+iu&+70t81V#jqOQr4wTfj%gbjHJhD&Oy=yae$(b6pJE9;v7>XI~>MQEd_n* z2NeCn^Cew-hNP&Aoiso!lCx*uX6@T_yYxxMs}=%V9f7PBVA?Tf_ZiD15rr-Yk zAA0lY-CI(;u7sj_qDY5={3QM&ROV@!#PmgqJE1x!-7dXL0ufP?>onISvx?OVv+GXG zn3hx?XLKiZNO3}$uVkvT7G(+Di9!@f&FnIvAf}zkK#(J2!$f@5BF-S!B^hU0cmXAI z%0ii@GG35Jzg$x!XUbEn4qf|6Dxe56gJyCjl&3|sqEitk`648pKq6RpF?m@gGwsD1 zFBuGikxSUCy;-z6?NJCa2*_H@YYuOTfX<;pBxCkRAX+p_qM$3a7fHXHX(exk4i$@O zL8JHjxq)2t?2_oLD%8O3d+933xmdOVP$(|CUMM_$XQM znZmQ0d@pX`@Zw*EuZ^hA$;mvAeRI}U>Kna`oUBJ|WV~?82f9BYxKrmUrVNin9VODp z^M%uW)?Kuy@yuJMFBiW+wKV1D(xVKlJ ztAG%LtcCbsl;P|Q%sSNUZqLX@tL5($FgbJrpB0euK>>w0=o*k|E)|>tSV*0furSbJ zV9axc3}8~h($iW59Wb(P9?04S}kZ#f8Q zq>(};^Z64l3%?!+8# znI=s>5MNUi8iZDnWo-nPb=b+2e>{F|*dZ~XWDMxqWMihL!N8`tOKLQL4198HXjS9* z(pWu?8*V2{BdMJRKXbTPo+#m^$h|<8kn{)RA4@rp5TI{S*YMquS}bcBphbz|#v$mT zHYSnqVkF@&i43+u&WZM^*;YhunOMuvW}R#nEp^l003*V&O=(H02FcE6eMrJS1&#kkF9#Eg2J)@^a*f?vRb#37*)vxLw^I|d9;ni}lu9E=~djB`<< zsC4?P$U{%W>RNBxdcS9A&B&U&aGXb3zS(W!_V_yO`#-ie>v7Zk-0)u%i5~aduflZ_Ud# zFiju+K$JWZ8r{!0zCGR<0=vea$nf3`jcp#T7@#o*giEI$4Vjv!x5L9DuC{`0fDo8- zfYdf_{rwP(IHPB~{+*$HMT3OCsLZ`4sqXvR8MT||lQk;gxWfp0%=aau0U39iJ$qY9 zx7fqlD=>hiZ814GzIRxUD0Xmg$WHHL%!wEX(ev5KGxL@L5JRQYt5m$esF`7i^+P67 zaF$0ghFd1E2xOhC)|lL~_qGy3(%mE75jfK;ZKg?YfoB!s!2 zhhKDFhQMjJ+tj6AyWOi{O=h2%*){v#F?9!1Q7!na=La(mJs9A z0)yBgA%^^miHq8c&Y?b?Y)_Yyqu;S0_#19DoJP;KQcO}pKp7(3qhv=&ie^x8$&I+9 z@iB-C#a)f!S0xvr<~Oq#iMe@c6zvYWqskab=L34aYuauXP;2K{yGMeiEan{<`%#YB z%4w_{ZqXL()vkj8+V%v8p0Bh$n(FL%_yQ9;I?Af^1vtD#nWk~C!RJ-JZb$pUuqcm& zfyuUNXZMB{ew-pwSCJT;^4Qe29E#P=DNj(1#$)Bnr}f%o>RK1L>uJCjcS!??Hc!L8 zR>;v7g9U@iDDT2{ zD^i@q?HPA+EsXDR8-r@@7Qwy?h9j8fudCkBcLD7?1c!~^cd3VPvVpx+@t zXI?9JCMb5+YmL{VbPu!^JG?-1kiYku^aJ|uH=+AFA5XK!CEF^*zTK@eRft#poGnW= zOr*er*=SO$y%>3@bBxfz6iDmOxFH61(xT1ByyX^+YYlANP)i5{dwqAJrvU#p95kIM1Acdo7vIv^W;Oo>}{uUM%<&UqEdF}k7 zSe>GzuDPHhC6={z*Sy1XFKrwlO@LPqHw=8E0h5c_zHYH&5CSqTpL=t0iOQnB1Yaiy q6~^qgebV|8e(7DdF|llV{OlM{)xWssV?(t!q*~z<&Q*Zp=H!1LpM+ch literal 0 HcmV?d00001 diff --git a/src/pricing.ts b/src/pricing.ts index cf3c2eb2..43e4d7d4 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -248,6 +248,10 @@ const MODEL_PRICING: Record = { // Claude Opus 4 (2025-05-14) 'claude-opus-4-20250514': OPUS_LEGACY, + // Claude Sonnet 5 (same $3 / $15 Sonnet tier). Family inference already maps + // any "sonnet" model to SONNET, so this is an explicit anchor for clarity. + 'claude-sonnet-5': SONNET, + // Claude Sonnet 4.6 'claude-sonnet-4-6': SONNET, diff --git a/src/promptDedup.ts b/src/promptDedup.ts new file mode 100644 index 00000000..aafdab9f --- /dev/null +++ b/src/promptDedup.ts @@ -0,0 +1,39 @@ +// Retry re-log dedup for the "Messages" count. +// +// When an API request errors, Claude Code retries it and re-logs the *same* +// user prompt, so one prompt can appear several times just seconds apart in a +// session's .jsonl (observed: an identical prompt logged twice ~30 s apart +// around a `system:api_error` record). Those are one message the user sent, not +// several — counting each inflates the "Messages" figure. +// +// A genuine re-send of the same text (the user types "继续" again, or a routine +// fires the same prompt) happens minutes / hours / days later, well outside the +// retry window, and still counts. So we only collapse identical prompts that +// land within a short window of the previous occurrence. + +/** Prompts with identical text within this many ms of the previous occurrence + * are treated as an API-error retry re-log and counted once. Retries are rapid + * (seconds); genuine repeats are far apart. */ +export const PROMPT_RETRY_WINDOW_MS = 120_000; + +/** + * Decide whether a user prompt is a retry re-log of one already counted, and + * record its time. Mutates `lastSeen` (key → last-seen epoch ms). + * + * Returns true when it should be SKIPPED (an identical prompt occurred within + * `windowMs`). Returns false when it should be counted — including when the + * timestamp is unusable (we never drop a message we can't reason about). + */ +export function isRetryDuplicatePrompt( + key: string, + tsMs: number, + lastSeen: Map, + windowMs: number = PROMPT_RETRY_WINDOW_MS +): boolean { + if (!Number.isFinite(tsMs)) { + return false; + } + const prev = lastSeen.get(key); + lastSeen.set(key, tsMs); + return prev !== undefined && tsMs - prev >= 0 && tsMs - prev <= windowMs; +} diff --git a/src/settings.ts b/src/settings.ts index 634b9c6c..73a7f4e4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -18,7 +18,7 @@ import * as vscode from 'vscode'; export type SettingType = 'boolean' | 'number' | 'enum' | 'string'; export type SettingStorage = 'config' | 'state'; -export type SettingGroup = 'general' | 'statusBar' | 'data' | 'advice'; +export type SettingGroup = 'general' | 'features' | 'statusBar' | 'data' | 'advice'; export interface SettingDef { key: string; // dotted config key, e.g. 'advice.backend' @@ -28,8 +28,11 @@ export interface SettingDef { group: SettingGroup; label: string; // short English label shown in the panel help?: string; // one-line English help - enumValues?: string[]; // for type 'enum' + enumValues?: string[]; // for type 'enum' (the full set of valid values) enumLabels?: string[]; // optional display labels (defaults to enumValues) + // Optional structure for a long enum (e.g. timezone: Common vs All + // zones). Purely presentational — enumValues stays the flat validation set. + enumGroups?: { label: string; values: string[]; labels: string[] }[]; min?: number; max?: number; secret?: boolean; // mask the input (apiKey) @@ -40,6 +43,107 @@ export interface SettingDef { // with other globalState entries (consent flags, dismissals, …). const STATE_PREFIX = 'ccu.setting.'; const MIGRATION_FLAG = 'ccu.settingsMigrated.v1'; +// V2.2: one-shot conversion of the old double-negative pauseDashboardRefresh to +// the positive dashboardAutoRefresh. Its own flag so it runs even for users who +// already passed the v1 migration. +const AUTOREFRESH_MIGRATION_FLAG = 'ccu.migrated.dashboardAutoRefresh'; + +// Timezone dropdown ('' = system default). A dropdown (not free text) means an +// invalid value can never be entered (#51). Rather than dump all ~400 IANA +// zones, we curate the common ones — like a typical app's timezone picker — and +// label each with its current UTC offset so it's easy to find (#: "地点 + 时区 +// 括号"). An exotic zone set earlier still stays selectable (the settings UI +// injects the stored value if it's not in this list). +// +// Ordered roughly west → east. Offsets are computed live (DST-aware) at load. +// Labels use the canonical IANA time-zone database identifier (the official, +// apolitical standard) — its last path segment as a friendly city name — so no +// editorialised country groupings that could read as taking a side. +const CURATED_ZONES: { zone: string; city: string }[] = [ + { zone: 'Pacific/Honolulu', city: 'Honolulu' }, + { zone: 'America/Anchorage', city: 'Anchorage' }, + { zone: 'America/Los_Angeles', city: 'Los Angeles' }, + { zone: 'America/Denver', city: 'Denver' }, + { zone: 'America/Chicago', city: 'Chicago' }, + { zone: 'America/New_York', city: 'New York' }, + { zone: 'America/Sao_Paulo', city: 'São Paulo' }, + { zone: 'UTC', city: 'UTC' }, + { zone: 'Europe/London', city: 'London' }, + { zone: 'Europe/Paris', city: 'Paris' }, + { zone: 'Europe/Berlin', city: 'Berlin' }, + { zone: 'Europe/Athens', city: 'Athens' }, + { zone: 'Europe/Moscow', city: 'Moscow' }, + { zone: 'Asia/Dubai', city: 'Dubai' }, + { zone: 'Asia/Karachi', city: 'Karachi' }, + { zone: 'Asia/Kolkata', city: 'Kolkata' }, + { zone: 'Asia/Dhaka', city: 'Dhaka' }, + { zone: 'Asia/Bangkok', city: 'Bangkok' }, + { zone: 'Asia/Shanghai', city: 'Shanghai' }, + { zone: 'Asia/Hong_Kong', city: 'Hong Kong' }, + { zone: 'Asia/Singapore', city: 'Singapore' }, + { zone: 'Asia/Tokyo', city: 'Tokyo' }, + { zone: 'Asia/Seoul', city: 'Seoul' }, + { zone: 'Australia/Sydney', city: 'Sydney' }, + { zone: 'Pacific/Auckland', city: 'Auckland' }, +]; + +/** Current UTC offset of a zone as "UTC+08:00" (DST-aware), or '' if unknown. */ +function utcOffsetLabel(zone: string): string { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + timeZoneName: 'longOffset', + }).formatToParts(new Date()); + const raw = parts.find((p) => p.type === 'timeZoneName')?.value ?? ''; + // "GMT+08:00" → "UTC+08:00"; bare "GMT" (UTC) → "UTC+00:00". + const norm = raw.replace('GMT', 'UTC'); + return norm === 'UTC' ? 'UTC+00:00' : norm; + } catch { + return ''; + } +} + +// Full UTC-offset coverage (Carl: it's the *offsets* that must be complete, not +// every city). Whole hours use fixed-offset Etc/GMT zones (POSIX-inverted sign, +// no DST); the real fractional offsets use a representative zone. A region hint +// aids recognition. Labels are computed live so they always match the actual +// bucketing. +// Pure UTC offsets — no place/country names at all (offset is neutral). The +// backing IANA zone is an implementation detail; the label shows only the offset. +const OFFSET_ZONES: { zone: string }[] = [ + { zone: 'Etc/GMT+12' }, { zone: 'Etc/GMT+11' }, { zone: 'Etc/GMT+10' }, + { zone: 'Pacific/Marquesas' }, { zone: 'Etc/GMT+9' }, { zone: 'Etc/GMT+8' }, + { zone: 'Etc/GMT+7' }, { zone: 'Etc/GMT+6' }, { zone: 'Etc/GMT+5' }, { zone: 'Etc/GMT+4' }, + { zone: 'America/St_Johns' }, { zone: 'Etc/GMT+3' }, { zone: 'Etc/GMT+2' }, + { zone: 'Etc/GMT+1' }, { zone: 'UTC' }, { zone: 'Etc/GMT-1' }, { zone: 'Etc/GMT-2' }, { zone: 'Etc/GMT-3' }, + { zone: 'Asia/Tehran' }, { zone: 'Etc/GMT-4' }, { zone: 'Asia/Kabul' }, + { zone: 'Etc/GMT-5' }, { zone: 'Asia/Kolkata' }, { zone: 'Asia/Kathmandu' }, + { zone: 'Etc/GMT-6' }, { zone: 'Asia/Yangon' }, { zone: 'Etc/GMT-7' }, { zone: 'Etc/GMT-8' }, + { zone: 'Australia/Eucla' }, { zone: 'Etc/GMT-9' }, { zone: 'Australia/Darwin' }, + { zone: 'Etc/GMT-10' }, { zone: 'Australia/Lord_Howe' }, { zone: 'Etc/GMT-11' }, + { zone: 'Etc/GMT-12' }, { zone: 'Pacific/Chatham' }, { zone: 'Etc/GMT-13' }, { zone: 'Etc/GMT-14' }, +]; + +const offLabel = (zone: string, name: string): string => { + const off = utcOffsetLabel(zone); + return off ? `(${off}) ${name}` : name; +}; +const offsetOnly = (zone: string): string => `(${utcOffsetLabel(zone) || 'UTC'})`; + +// Flat set (validation accepts any listed zone) + labels; the dropdown groups them. +const TIMEZONE_VALUES: string[] = ['', ...CURATED_ZONES.map((z) => z.zone), ...OFFSET_ZONES.map((z) => z.zone)]; +const TIMEZONE_LABELS: string[] = [ + 'System default', + ...CURATED_ZONES.map((z) => offLabel(z.zone, z.city)), + ...OFFSET_ZONES.map((z) => offsetOnly(z.zone)), +]; +// Common named zones (IANA cities) stay handy at the top; every UTC offset is +// under "UTC offset". +const TIMEZONE_GROUPS = [ + { label: 'System', values: [''], labels: ['System default'] }, + { label: 'Common', values: CURATED_ZONES.map((z) => z.zone), labels: CURATED_ZONES.map((z) => offLabel(z.zone, z.city)) }, + { label: 'UTC offset', values: OFFSET_ZONES.map((z) => z.zone), labels: OFFSET_ZONES.map((z) => offsetOnly(z.zone)) }, +]; export const SETTINGS: SettingDef[] = [ // --- General --- @@ -51,7 +155,7 @@ export const SETTINGS: SettingDef[] = [ group: 'general', label: 'Display language', help: 'UI language. "auto" follows VS Code.', - enumValues: ['auto', 'en', 'zh-TW', 'zh-CN', 'ja', 'ko'], + enumValues: ['auto', 'en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR'], }, { key: 'decimalPlaces', @@ -63,6 +167,17 @@ export const SETTINGS: SettingDef[] = [ min: 0, max: 4, }, + { + key: 'tokenDecimalPlaces', + type: 'number', + default: 1, + storage: 'state', + group: 'general', + label: 'Token decimal places', + help: 'Decimals for compact token display (1.2M / 345.6K). Full integer counts are unaffected.', + min: 0, + max: 2, + }, { key: 'compactNumbers', type: 'boolean', @@ -72,14 +187,80 @@ export const SETTINGS: SettingDef[] = [ label: 'Compact token counts', help: 'Show 1.2M / 345K instead of full numbers.', }, + { + key: 'showHeatmap', + type: 'boolean', + default: false, + storage: 'state', + group: 'features', + label: 'Show token heatmap (All-time tab)', + help: 'Show a GitHub-style yearly token heatmap on the All tab. Off by default — mainly a shareable view of what you can already see elsewhere. Use "Export Token Heatmap" for a GitHub-profile SVG.', + }, + { + key: 'showEfficiency', + type: 'boolean', + default: false, + storage: 'state', + group: 'features', + label: 'Show efficiency insights', + help: 'Off by default (not everyone wants these). Adds cost/message, tokens/message and realised cache savings to Today / month / all-time and the projects table. (The "top 10 costliest messages" panel has its own toggle below.)', + }, + { + key: 'enableShareCard', + type: 'boolean', + default: false, + storage: 'state', + group: 'features', + label: 'Enable usage share card', + help: 'Off by default. When on, the All tab gets a configurable "Share card" — pick a range (week / month / year / a specific month), a scope (overall / a project / a session) and which metrics to show, then generate a one-page SVG to share. Only aggregate numbers leave your machine, and only when you export.', + }, + { + key: 'showCostliestMessages', + type: 'boolean', + default: false, + storage: 'state', + group: 'features', + label: 'Show "top 10 costliest messages"', + help: 'Off by default. Adds a panel on the Content tab that ranks your single most expensive turns and, on expand, shows the prompt that triggered each (plus a cost split so you can tell a cache miss from a long answer). It reads and displays your own prompt text, so it stays opt-in.', + }, + { + key: 'showInsights', + type: 'boolean', + default: false, + storage: 'state', + group: 'features', + label: 'Show experimental insights', + help: 'Off by default. Adds an "Experimental insights" section to the Content tab — our own estimates from your local logs (e.g. a cache-churn bill: $ spent re-writing cache after model switches / idle gaps). These are computed heuristics, not standardized metrics, so they stay opt-in and are labelled as estimates.', + }, + { + key: 'enableSessionActions', + type: 'boolean', + default: false, + storage: 'state', + group: 'features', + label: 'Enable session resume & delete actions', + help: 'Off by default. When on, the Sessions tab shows Resume and Delete buttons. Both ACT on your Claude Code — resume reopens a conversation (and reloads it into context), delete moves its log file to the OS trash — which is at odds with this extension being read-only, so they stay opt-in together.', + }, + { + key: 'showConversationViewer', + type: 'boolean', + default: true, + storage: 'state', + group: 'features', + label: 'Enable conversation viewer', + help: 'On by default. The Sessions tab shows a "view" button that opens a read-only reader for a past conversation — so you can re-read your prompts and the model\'s answers to jog your memory WITHOUT loading them back into the model\'s context (unlike resume). It only reads your local log files (read-only), so it is on by default; turn it off to hide the button.', + }, { key: 'timezone', - type: 'string', + type: 'enum', default: '', storage: 'state', group: 'general', label: 'Timezone for dates', - help: 'IANA zone (e.g. Asia/Hong_Kong). Empty = system.', + help: 'Pick a common zone or a UTC offset (every offset is covered), or the system default. Labels show the current UTC offset.', + enumValues: TIMEZONE_VALUES, + enumLabels: TIMEZONE_LABELS, + enumGroups: TIMEZONE_GROUPS, }, { key: 'projectGroupingMode', @@ -158,8 +339,8 @@ export const SETTINGS: SettingDef[] = [ default: false, storage: 'state', group: 'statusBar', - label: 'Show only the 5-hour quota', - help: 'Hide the weekly (and Opus) windows; show just the 5-hour utilisation.', + label: 'Quota: 5-hour window only', + help: 'Show only the 5-hour quota in the status bar, hiding the weekly figure (reset details stay in the tooltip).', }, { // Append the 5h / weekly reset countdown to the status-bar quota item. @@ -168,8 +349,8 @@ export const SETTINGS: SettingDef[] = [ default: false, storage: 'state', group: 'statusBar', - label: 'Show quota reset time in status bar', - help: 'Append the reset countdown (e.g. "5h:50%:2.3h") to the status-bar quota item.', + label: 'Quota: show reset countdown', + help: 'Append a compact reset countdown in the status bar (5h 6% ↻4.8h). Off keeps it clean (5h 6% · wk 1%); the tooltip always shows full reset times.', }, { key: 'workflowQuotaWarnPercent', @@ -204,22 +385,26 @@ export const SETTINGS: SettingDef[] = [ max: 3600, }, { - key: 'fileWatching', - type: 'boolean', - default: true, + key: 'fileWatchSeconds', + type: 'enum', + default: '2', storage: 'state', group: 'data', - label: 'Live file watching', - help: 'Refresh ~1.5s after each new message.', + label: 'Live refresh delay', + help: 'How soon the dashboard refreshes after new activity. This only re-reads your LOCAL log files (no API call — the quota fetch is throttled separately). "Off" disables live watching; a longer delay is lighter on CPU.', + enumValues: ['0', '1', '2', '5', '10', '20', '30'], + enumLabels: ['Off', '1s', '2s', '5s', '10s', '20s', '30s'], }, { - key: 'pauseDashboardRefresh', + // V2.2: positive wording, replacing the old double-negative + // `pauseDashboardRefresh` (migrated by SettingsStore.migrateDashboardAutoRefresh). + key: 'dashboardAutoRefresh', type: 'boolean', - default: false, + default: true, storage: 'state', group: 'data', - label: 'Pause dashboard auto-refresh', - help: 'Status bar still updates; dashboard only on manual refresh.', + label: 'Dashboard auto-refresh', + help: 'Auto-refresh the dashboard as new usage lands. Off = manual refresh only (the status bar still updates).', }, { key: 'enableContentAnalysis', @@ -445,4 +630,34 @@ export class SettingsStore { } await this.context.globalState.update(MIGRATION_FLAG, true); } + + /** + * One-shot V2.2 migration: the old `pauseDashboardRefresh` (double negative) + * becomes `dashboardAutoRefresh` (positive), inverted: + * pauseDashboardRefresh === true → dashboardAutoRefresh = false + * false / undefined → dashboardAutoRefresh = true (the default) + * Reads the old value from globalState (2.1) or settings.json (pre-2.1). + */ + async migrateDashboardAutoRefresh(): Promise { + if (this.context.globalState.get(AUTOREFRESH_MIGRATION_FLAG, false)) { + return; + } + const newKey = STATE_PREFIX + 'dashboardAutoRefresh'; + if (this.context.globalState.get(newKey) === undefined) { + let oldPause = this.context.globalState.get(STATE_PREFIX + 'pauseDashboardRefresh'); + if (oldPause === undefined) { + const info = this.cfg().inspect('pauseDashboardRefresh'); + const v = info?.globalValue ?? info?.workspaceFolderValue ?? info?.workspaceValue; + if (typeof v === 'boolean') { + oldPause = v; + } + } + // Only write when the user had actually set the old flag; otherwise leave + // dashboardAutoRefresh at its catalog default (true). + if (oldPause !== undefined) { + await this.context.globalState.update(newKey, !oldPause); + } + } + await this.context.globalState.update(AUTOREFRESH_MIGRATION_FLAG, true); + } } diff --git a/src/shareCard.ts b/src/shareCard.ts new file mode 100644 index 00000000..d6169241 --- /dev/null +++ b/src/shareCard.ts @@ -0,0 +1,269 @@ +// Usage Share Card (用量卡片) — pure, dependency-free logic for the V2.2 export +// feature. The DOM rendering + PNG export live in the webview; everything that +// must be *correct* and *private* lives here so it can be unit-tested. +// +// Privacy is enforced structurally: ShareCardData only ever carries aggregate, +// non-identifying fields. It never has room for prompt text, file paths, session +// ids, usernames, or absolute directories — so they cannot leak by accident. + +import { UsageData } from './types'; + +// Preset ranges + `month:YYYY-MM` for a specific calendar month. The `& {}` +// keeps literal autocomplete while allowing the specific-month string. +export type ShareRange = 'today' | 'week' | 'month' | 'year' | (string & {}); + +/** What the user can toggle in the preview. Carl's default: est. cost, top + * model and cache-hit rate on; everything else (sessions, messages, rhythm, + * composition, badge …) is optional. */ +export interface ShareSections { + totalTokens: boolean; + estimatedCost: boolean; + sessions: boolean; + messages: boolean; + topModel: boolean; + cacheEfficiency: boolean; + rhythm: boolean; + badge: boolean; + watermark: boolean; + tokenComposition: boolean; + // default OFF + projectName: boolean; + workflowShare: boolean; + peakContext: boolean; +} + +// Defaults follow the GPT card design: hero + 4 tiles (cost / cache / model / +// sessions) + token mix + daily pulse + badge. Everything else opt-in. +export const DEFAULT_SECTIONS: ShareSections = { + totalTokens: true, // the hero metric + estimatedCost: true, + cacheEfficiency: true, + topModel: true, + sessions: true, // the 4th default tile + tokenComposition: true, // the token-mix bar + rhythm: true, // the daily pulse + badge: true, + watermark: true, + // opt-in + messages: false, + projectName: false, + workflowShare: false, + peakContext: false, +}; + +/** Aggregate inputs for a range — all already computed by dataLoader (no raw + * records, no identifying strings beyond an optional project NAME). */ +export interface ShareInput { + range: ShareRange; + rangeData: UsageData; + daily: number[]; // per-day token totals across the range (rhythm / heat strip) + dailyDates?: string[]; // 'YYYY-MM-DD' parallel to daily (for first/last axis labels) + sessionCount: number; + topModel?: string; // raw model id; reduced to a family before export + workflowShare?: number; // 0..1 + peakContextTokens?: number; + projectName?: string; // redacted unless sections.projectName; also the scope label + rangeLabel?: string; // human header, e.g. "June 2026" for a specific month + now?: number; // for the date label / filename (defaults to Date.now()) +} + +/** Render-ready, already-redacted card data. Absent fields = hidden. */ +export interface ShareCardData { + range: ShareRange; + rangeLabel?: string; + totalTokens?: number; + estimatedCost?: number; + sessions?: number; + messages?: number; + topModelFamily?: string; + topModelName?: string; // pretty full name, e.g. "Opus 4.8" (Carl: show the model, not just "Opus") + cacheSharePct?: number; + rhythm?: number[]; + rhythmStart?: string; // first day label on the rhythm axis + rhythmEnd?: string; // last day label on the rhythm axis + // Token composition (the four billed token types) — off by default. + composition?: { input: number; output: number; cacheCreate: number; cacheRead: number }; + badge?: { id: string; label: string }; + workflowSharePct?: number; + peakContextTokens?: number; + projectName?: string; + watermark: boolean; +} + +/** Total tokens across the four buckets. */ +export function totalTokens(u: UsageData): number { + return ( + u.totalInputTokens + u.totalOutputTokens + u.totalCacheCreationTokens + u.totalCacheReadTokens + ); +} + +/** Cache read as a share of the input side (read / (input + write + read)), 0–100. */ +export function cacheSharePct(u: UsageData): number { + const inputSide = u.totalInputTokens + u.totalCacheCreationTokens + u.totalCacheReadTokens; + return inputSide > 0 ? Math.round((u.totalCacheReadTokens / inputSide) * 100) : 0; +} + +/** A pretty full model name from a raw id, e.g. "claude-opus-4-8" → "Opus 4.8", + * "claude-fable-5" → "Fable 5". Third-party ids are kept verbatim. */ +export function prettyModelName(model: string | undefined): string | undefined { + if (!model) { + return undefined; + } + const s = model.toLowerCase(); + const fam = (s.match(/opus|sonnet|haiku|fable|mythos/) || [])[0]; + if (!fam) { + return model; + } + const pair = s.match(/(\d+)[-.](\d+)/); + const single = s.match(/-(\d+)(?!\d)/); + const ver = pair ? `${pair[1]}.${pair[2]}` : single ? single[1] : ''; + const Fam = fam[0].toUpperCase() + fam.slice(1); + return ver ? `${Fam} ${ver}` : Fam; +} + +/** Reduce a model id to a family name (no version exposed); third-party kept. */ +export function modelFamily(model: string | undefined): string | undefined { + if (!model) { + return undefined; + } + const s = model.toLowerCase(); + if (/fable|mythos/.test(s)) { + return 'Fable'; + } + if (/opus/.test(s)) { + return 'Opus'; + } + if (/sonnet/.test(s)) { + return 'Sonnet'; + } + if (/haiku/.test(s)) { + return 'Haiku'; + } + return model; +} + +/** Deterministic, local, rule-based badge. First match wins, most distinctive + * first; Steady Builder is the always-valid fallback. */ +export function selectShareBadge(input: ShareInput): { id: string; label: string } { + const tokens = totalTokens(input.rangeData); + const cache = cacheSharePct(input.rangeData); + const peak = input.peakContextTokens ?? 0; + const maxDay = input.daily.length ? Math.max(...input.daily) : 0; + const activeDays = input.daily.filter((d) => d > 0).length; + + if (peak >= 500_000) { + return { id: 'context-marathoner', label: 'Context Marathoner' }; + } + if (cache >= 60) { + return { id: 'cache-saver', label: 'Cache Saver' }; + } + if (maxDay >= 5_000_000) { + return { id: 'token-sprinter', label: 'Token Sprinter' }; + } + if (input.sessionCount >= 30) { + return { id: 'workflow-pilot', label: 'Workflow Pilot' }; + } + // Balanced across several active days (no single day dominates) → steady. + if (activeDays >= 4 && maxDay > 0 && maxDay <= tokens * 0.5) { + return { id: 'steady-builder', label: 'Steady Builder' }; + } + return { id: 'steady-builder', label: 'Steady Builder' }; +} + +/** Build the redacted, render-ready card data from aggregate inputs + the chosen + * sections. Anything off (or privacy-gated) is simply omitted. */ +export function buildShareCardData(input: ShareInput, sections: ShareSections): ShareCardData { + const u = input.rangeData; + const out: ShareCardData = { range: input.range, watermark: sections.watermark }; + if (input.rangeLabel) { + out.rangeLabel = input.rangeLabel; + } + if (sections.totalTokens) { + out.totalTokens = totalTokens(u); + } + if (sections.estimatedCost) { + out.estimatedCost = u.totalCost; + } + if (sections.sessions) { + out.sessions = input.sessionCount; + } + if (sections.messages) { + out.messages = u.messageCount; + } + if (sections.topModel) { + out.topModelFamily = modelFamily(input.topModel); + out.topModelName = prettyModelName(input.topModel); + } + if (sections.cacheEfficiency) { + out.cacheSharePct = cacheSharePct(u); + } + if (sections.rhythm) { + out.rhythm = input.daily.slice(); + if (input.dailyDates && input.dailyDates.length > 0) { + out.rhythmStart = input.dailyDates[0]; + out.rhythmEnd = input.dailyDates[input.dailyDates.length - 1]; + } + } + if (sections.tokenComposition) { + out.composition = { + input: u.totalInputTokens, + output: u.totalOutputTokens, + cacheCreate: u.totalCacheCreationTokens, + cacheRead: u.totalCacheReadTokens, + }; + } + if (sections.badge) { + out.badge = selectShareBadge(input); + } + // Default-off / privacy-gated fields. + if (sections.workflowShare && typeof input.workflowShare === 'number') { + out.workflowSharePct = Math.round(input.workflowShare * 100); + } + if (sections.peakContext && typeof input.peakContextTokens === 'number') { + out.peakContextTokens = input.peakContextTokens; + } + if (sections.projectName && input.projectName) { + out.projectName = input.projectName; + } + return out; +} + +/** Default export file name, e.g. "claude-code-usage-2026-06-week.png". */ +export function shareCardFilename(range: ShareRange, date: Date = new Date()): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + let stamp: string; + if (range.startsWith('month:')) { + stamp = range.slice('month:'.length); // already YYYY-MM + } else if (range === 'today') { + stamp = `${y}-${m}-${d}-day`; + } else { + stamp = `${y}-${m}-${range}`; + } + return `claude-code-usage-${stamp}.png`; +} + +/** Human header for a range, e.g. "this month", "the last 7 days", "June 2026". */ +export function rangeLabel(range: ShareRange): string { + if (range.startsWith('month:')) { + const [yy, mm] = range.slice('month:'.length).split('-'); + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const idx = Number(mm) - 1; + return idx >= 0 && idx < 12 ? `${months[idx]} ${yy}` : range; + } + switch (range) { + case 'today': + return 'today'; + case 'week': + return 'the last 7 days'; + case 'last30': + return 'the last 30 days'; + case 'month': + return 'this month'; + case 'year': + return 'the last 12 months'; + default: + return String(range); + } +} diff --git a/src/shareCardSvg.ts b/src/shareCardSvg.ts new file mode 100644 index 00000000..3b29cf40 --- /dev/null +++ b/src/shareCardSvg.ts @@ -0,0 +1,486 @@ +// Usage Share Card renderer — turns the redacted ShareCardData into a +// self-contained SVG (no DOM, no html-to-image, no network, no web fonts). +// Pure & deterministic: same data + options ⇒ same SVG. Privacy is enforced by +// ShareCardData's shape; this only draws what's present. +// +// Visual direction: "Code Pulse · Aurora Console" (GPT design, docs/ +// V2.2-ShareCard-GPT-design.md). Two themes share ONE renderer via a token set: +// • claudeCream (default) — warm, editorial, Claude-like. +// • auroraDark — technical, high-contrast, social-share ready. +// Landscape 1200×680 only for now (square/portrait/story = a later patch). + +import { ShareCardData, rangeLabel } from './shareCard'; + +const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI","Inter","Microsoft YaHei","PingFang SC",sans-serif'; + +const esc = (s: string): string => + String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +const truncate = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + '…' : s); + +// Compact token count. Chinese cards use 万 / 亿 (万 / 億 for Traditional) to fit +// the language; others use K / M / B. `keepDecimal` forces one decimal (the hero +// keeps it even for round values, e.g. "5.0亿"). +function compact(n: number, lang: 'en' | 'zh-CN' | 'zh-TW' = 'en', keepDecimal = false): string { + const a = Math.abs(n); + const t = (x: number): string => (keepDecimal ? x.toFixed(1) : x.toFixed(1).replace(/\.0$/, '')); + if (lang === 'zh-CN' || lang === 'zh-TW') { + const yi = lang === 'zh-TW' ? '億' : '亿'; + const wan = lang === 'zh-TW' ? '萬' : '万'; + if (a >= 1e8) return t(n / 1e8) + yi; + if (a >= 1e4) return t(n / 1e4) + wan; + return String(Math.round(n)); + } + if (a >= 1e9) return t(n / 1e9) + 'B'; + if (a >= 1e6) return t(n / 1e6) + 'M'; + if (a >= 1e3) return t(n / 1e3) + 'K'; + return String(Math.round(n)); +} + +function money(n: number): string { + return n >= 100 ? '$' + Math.round(n).toLocaleString('en-US') : '$' + n.toFixed(2); +} + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +function shortDay(iso: string | undefined): string { + if (!iso) return ''; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + return m ? `${MONTHS[Number(m[2]) - 1] || m[2]} ${Number(m[3])}` : iso; +} + +// ---- Theme tokens ----------------------------------------------------------- + +export type ShareCardTheme = 'auto' | 'claudeCream' | 'claudeClassic' | 'auroraDark'; +type ConcreteTheme = 'claudeCream' | 'claudeClassic' | 'auroraDark'; + +interface ThemeTokens { + bgTop: string; + bgBottom: string; + blobWarm: string; + blobCool: string; + primaryAccent: string; + primaryAccentBright: string; + secondaryAccent: string; + cacheAccent: string; + modelAccent: string; + panelFill: string; + panelBorder: string; + primaryText: string; + secondaryText: string; + mutedText: string; + softLine: string; + watermark: string; + badgeFill: string; + badgeBorder: string; +} + +export const SHARE_CARD_THEMES: Record = { + // Classic: the pre-redesign single-hue orange — fewer colours, more refined. + // A monochrome orange ramp for the token mix (no violet/mint), on the cream + // background, so it keeps the older "high-end" restraint. + claudeClassic: { + bgTop: '#FFF7F2', + bgBottom: '#FDEEE6', + blobWarm: '#FFE6D8', + blobCool: '#FAD9C6', + primaryAccent: '#C85A2B', + primaryAccentBright: '#E07D4F', + secondaryAccent: '#F0AA82', + cacheAccent: '#F7CBB0', + modelAccent: '#E07D4F', + panelFill: 'rgba(255,255,255,0.74)', + panelBorder: '#F0D8C9', + primaryText: '#2B2B2B', + secondaryText: '#6B6B6B', + mutedText: '#9A8478', + softLine: '#F0D8C9', + watermark: 'rgba(43,43,43,0.50)', + badgeFill: 'rgba(255,255,255,0.86)', + badgeBorder: 'rgba(200,90,43,0.16)', + }, + claudeCream: { + bgTop: '#FFF8F3', + bgBottom: '#FDEEE6', + blobWarm: '#FFE1D2', + blobCool: '#F6C177', + primaryAccent: '#C85A2B', + primaryAccentBright: '#E07D4F', + secondaryAccent: '#F6C177', + cacheAccent: '#5FAE8F', + modelAccent: '#8B7CF6', + panelFill: 'rgba(255,255,255,0.82)', + panelBorder: 'rgba(200,90,43,0.14)', + primaryText: '#6B341F', + secondaryText: '#9A6A55', + mutedText: '#B9907E', + softLine: '#EFD8CB', + watermark: 'rgba(107,52,31,0.58)', + badgeFill: 'rgba(255,255,255,0.86)', + badgeBorder: 'rgba(200,90,43,0.20)', + }, + auroraDark: { + bgTop: '#111827', + bgBottom: '#1E1B4B', + blobWarm: '#C85A2B', + blobCool: '#312E81', + primaryAccent: '#E07D4F', + primaryAccentBright: '#F6A06F', + secondaryAccent: '#F6C177', + cacheAccent: '#5CC8A7', + modelAccent: '#8B7CF6', + panelFill: 'rgba(255,255,255,0.10)', + panelBorder: 'rgba(255,255,255,0.20)', + primaryText: '#FFF7F2', + secondaryText: '#C9D1D9', + mutedText: '#9CA3AF', + softLine: 'rgba(255,255,255,0.18)', + watermark: 'rgba(255,247,242,0.62)', + badgeFill: 'rgba(255,255,255,0.12)', + badgeBorder: 'rgba(255,255,255,0.24)', + }, +}; + +/** Resolve the theme. Explicit themes pass through; 'auto'/undefined follow VS + * Code (dark → auroraDark) and otherwise use the default, Claude Classic. */ +export function resolveShareCardTheme(theme: ShareCardTheme | undefined, isDark?: boolean): ConcreteTheme { + if (theme === 'auroraDark' || theme === 'claudeCream' || theme === 'claudeClassic') { + return theme; + } + return isDark ? 'auroraDark' : 'claudeClassic'; // 'auto' / undefined +} + +// The card renders in the UI language (Carl: no en/zh mixing). Fully localized +// for en / zh-CN / zh-TW (the maintainer's languages); other UI languages fall +// back to en so a card never mixes languages. Card-specific strings live here +// (self-contained + testable) rather than bloating the shared i18n table. +export type CardLang = 'en' | 'zh-CN' | 'zh-TW'; +export function cardLang(lang?: string): CardLang { + return lang === 'zh-CN' || lang === 'zh-TW' ? lang : 'en'; +} + +interface CardStrings { + subtitle: string; + totalTokens: string; + spent: string; + sessionsUnit: string; + estCost: string; + cacheHit: string; + topModel: string; + sessions: string; + messages: string; + workflows: string; + peakCtx: string; + tokenMix: string; + input: string; + output: string; + cacheWrite: string; + cacheRead: string; + daily: string; + hourly: string; + peak: string; + madeWith: string; +} + +const CARD_STRINGS: Record = { + en: { + subtitle: 'AI coding usage snapshot', totalTokens: 'total tokens', spent: 'estimated spend', sessionsUnit: 'sessions', + estCost: 'est. cost', cacheHit: 'cache hit', topModel: 'top model', sessions: 'sessions', messages: 'messages', + workflows: 'workflows', peakCtx: 'peak ctx', tokenMix: 'Token mix', input: 'Input', output: 'Output', + cacheWrite: 'Cache write', cacheRead: 'Cache read', daily: 'Daily pulse', hourly: 'Hourly pulse', peak: 'peak', + madeWith: 'Made with Claude Code Usage', + }, + 'zh-CN': { + subtitle: 'AI 编程用量快照', totalTokens: '总 token', spent: '预计花费', sessionsUnit: '会话', + estCost: '预计成本', cacheHit: '缓存命中', topModel: '主力模型', sessions: '会话数', messages: '消息数', + workflows: '工作流', peakCtx: '峰值上下文', tokenMix: 'Token 组成', input: '输入', output: '输出', + cacheWrite: '缓存写入', cacheRead: '缓存读取', daily: '每日节奏', hourly: '每小时节奏', peak: '峰值', + madeWith: '由 Claude Code Usage 制作', + }, + 'zh-TW': { + subtitle: 'AI 程式設計用量快照', totalTokens: '總 token', spent: '預計花費', sessionsUnit: '會話', + estCost: '預計成本', cacheHit: '快取命中', topModel: '主力模型', sessions: '會話數', messages: '訊息數', + workflows: '工作流', peakCtx: '峰值上下文', tokenMix: 'Token 組成', input: '輸入', output: '輸出', + cacheWrite: '快取寫入', cacheRead: '快取讀取', daily: '每日節奏', hourly: '每小時節奏', peak: '峰值', + madeWith: '由 Claude Code Usage 製作', + }, +}; + +// On-brand badge copy per language (title + one-line personality), keyed by +// selectShareBadge ids. Title AND line use the same language — no mixing. +export const BADGE_COPY: Record> = { + 'context-marathoner': { + en: { title: 'Context Marathon', line: "Half-marathon of context — the model's still catching its breath." }, + 'zh-CN': { title: 'Context 马拉松', line: '上下文跑了个半马,模型还在喘。' }, + 'zh-TW': { title: 'Context 馬拉松', line: '上下文跑了個半馬,模型還在喘。' }, + }, + 'cache-saver': { + en: { title: 'Cache Alchemist', line: 'High cache hits — barely a token wasted.' }, + 'zh-CN': { title: '缓存日子人', line: '缓存命中高,token 没白烧。' }, + 'zh-TW': { title: '快取日子人', line: '快取命中高,token 沒白燒。' }, + }, + 'token-sprinter': { + en: { title: 'Token Sprinter', line: 'Full throttle. 🔥' }, + 'zh-CN': { title: '无限火力', line: '开炮!!!' }, + 'zh-TW': { title: '無限火力', line: '開炮!!!' }, + }, + 'workflow-pilot': { + en: { title: 'Workflow Pilot', line: "You're not coding — you're running a crew." }, + 'zh-CN': { title: 'Agent 包工头', line: '你不是在写代码,你是在使唤一支小队。' }, + 'zh-TW': { title: 'Agent 包工頭', line: '你不是在寫程式,你是在使喚一支小隊。' }, + }, + 'steady-builder': { + en: { title: 'Steady Builder', line: 'No rush, no burnout — steady progress.' }, + 'zh-CN': { title: '节奏大师', line: '不卷不燥,代码稳步推进。' }, + 'zh-TW': { title: '節奏大師', line: '不捲不燥,程式碼穩步推進。' }, + }, +}; + +const REPO = 'github.com/ClaudeCodeUsage'; + +// ---- Renderer --------------------------------------------------------------- + +export interface ShareCardSvgOptions { + width?: number; + height?: number; + theme?: ShareCardTheme; // default claudeCream + isDark?: boolean; // used only when theme === 'auto' + avatarDataUri?: string; + username?: string; + fullNumbers?: boolean; + lang?: string; // UI language; the card renders in it (en fallback) +} + +export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOptions = {}): string { + const W = opts.width ?? 1200; + const H = opts.height ?? 680; + const M = 60; + const T = SHARE_CARD_THEMES[resolveShareCardTheme(opts.theme, opts.isDark)]; + const L = cardLang(opts.lang); + const S = CARD_STRINGS[L]; + const p: string[] = []; + + p.push( + `` + ); + + // Aurora background: vertical gradient + two soft radial blobs (deterministic). + p.push(''); + p.push(``); + p.push(``); + p.push(``); + p.push(``); + p.push(''); + p.push(``); + p.push(``); + p.push(``); + + // --- Brand block (top-left) --- + p.push(`Claude Code Usage`); + p.push(`${esc(S.subtitle)}`); + const range = (data.rangeLabel || rangeLabel(data.range)) + (data.projectName ? ' · ' + truncate(data.projectName, 28) : ''); + p.push(`${esc(range)}`); + + // --- Corner (top-right): avatar + badge card + optional name --- + let cornerBottom = 130; + let avatarBottom = 0; + if (opts.avatarDataUri) { + const s = 60; + const ax = W - M - s; + const ay = 46; + p.push(``); + p.push(``); + p.push(``); + avatarBottom = ay + s; + } + if (data.badge) { + const copy = BADGE_COPY[data.badge.id]?.[L] || { title: data.badge.label, line: '' }; + const hasName = !!(opts.username && opts.username.trim()); + const titleW = 16 + copy.title.length * 11; + const lineW = copy.line ? 24 + copy.line.length * 15 : 0; + const nameW = hasName ? 24 + truncate(opts.username!.trim(), 22).length * 10 : 0; + const cardW = Math.min(380, Math.max(160, titleW, lineW, nameW) + 28); + const cardX = W - M - cardW; + const cardY = avatarBottom ? avatarBottom + 12 : 52; + const cardH = (hasName ? 26 : 0) + (copy.line ? 62 : 42); + p.push(``); + // Accent dot + title. + let ty = cardY + (hasName ? 24 : 20); + if (hasName) { + p.push(`${esc(truncate(opts.username!.trim(), 24))}`); + ty += 28; + } else { + ty += 8; + } + p.push(``); + p.push(`${esc(truncate(copy.title, 22))}`); + if (copy.line) { + p.push(`${esc(truncate(copy.line, 44))}`); + } + cornerBottom = Math.max(cornerBottom, cardY + cardH); + } else { + cornerBottom = Math.max(cornerBottom, avatarBottom); + } + + // --- Middle sections --- + // The hero sits top-left and the corner block sits top-right, so they can + // overlap VERTICALLY without colliding. The remaining (full-width) sections + // flow below whichever reaches lower — so turning on the avatar only nudges + // the tiles down a little instead of pushing the whole card off the canvas. + const contentTop = 134; + const footerTop = H - 56; + type Section = { h: number; draw: (y: number) => void }; + const sections: Section[] = []; // tiles / composition / pulse (flowed) + + // Hero (drawn at a fixed top position). + let heroValue = ''; + let heroUnit = ''; + if (data.totalTokens != null) { + 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); + heroUnit = S.spent; + } else if (data.sessions != null) { + heroValue = String(data.sessions); + heroUnit = S.sessionsUnit; + } + let heroBottom = contentTop; + if (heroValue) { + const heroFont = heroValue.length > 11 ? 60 : heroValue.length > 8 ? 82 : 104; + p.push(`${esc(heroValue)}`); + p.push(`${esc(heroUnit)}`); + heroBottom = contentTop + heroFont + 48; + } + + // 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.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 }); + if (data.sessions != null) tiles.push({ label: S.sessions, value: String(data.sessions) }); + if (data.messages != null) tiles.push({ label: S.messages, value: String(data.messages) }); + if (data.workflowSharePct != null) tiles.push({ label: S.workflows, value: data.workflowSharePct + '%' }); + if (data.peakContextTokens != null) tiles.push({ label: S.peakCtx, value: compact(data.peakContextTokens, L) }); + const shownTiles = tiles.slice(0, 4); + if (shownTiles.length > 0) { + const gap = 18; + const n = shownTiles.length; + const tileW = (W - 2 * M - (n - 1) * gap) / n; + const rowH = 96; + sections.push({ + h: rowH, + draw: (y) => { + shownTiles.forEach((t, i) => { + const x = M + i * (tileW + gap); + p.push(``); + p.push(``); + p.push(`${esc(t.value)}`); + p.push(`${esc(t.label)}`); + }); + }, + }); + } + + // Token composition — stacked bar (theme-aware segment colors) + legend. + if (data.composition) { + const c = data.composition; + const segs = [ + { v: c.input, label: S.input, color: T.primaryAccent }, + { v: c.output, label: S.output, color: T.modelAccent }, + { v: c.cacheCreate, label: S.cacheWrite, color: T.secondaryAccent }, + { v: c.cacheRead, label: S.cacheRead, color: T.cacheAccent }, + ]; + const total = segs.reduce((a, b) => a + b.v, 0); + if (total > 0) { + // A touch of internal padding (label at y+10, legend at y+74) so the block + // doesn't crowd the tiles above / pulse below. + sections.push({ + h: 84, + draw: (y) => { + const barY = y + 30; + const barW = W - 2 * M; + const barH = 22; + p.push(`${esc(S.tokenMix)}`); + let x = M; + segs.forEach((s) => { + const w = (s.v / total) * barW; + if (w > 0) { + p.push(``); + x += w; + } + }); + p.push(``); + let lx = M; + const ly = barY + barH + 22; + segs.forEach((s) => { + const pct = Math.round((s.v / total) * 100); + const label = `${s.label} ${pct}% · ${compact(s.v, L)}`; + p.push(``); + p.push(`${esc(label)}`); + lx += 38 + label.length * 7.6; + }); + }, + }); + } + } + + // Daily pulse strip — centred + width-capped bars, peak + first/last labels. + if (data.rhythm && data.rhythm.length > 0) { + sections.push({ + h: 110, + draw: (y) => { + const capW = W - 2 * M; + const rh = 70; + const barsTop = y + 14; + const max = Math.max(...data.rhythm!, 1); + const nn = data.rhythm!.length; + const slot = Math.min(capW / nn, 46); + const usedW = slot * nn; + const startX = M + (capW - usedW) / 2; + const bw = Math.max(3, Math.min(slot - 6, 28)); + p.push(`${esc(nn <= 24 && data.range === 'today' ? S.hourly : S.daily)}`); + p.push(`${esc(S.peak)} ${esc(compact(max, L))}`); + p.push(``); + let firstCx = startX; + let lastCx = startX; + data.rhythm!.forEach((v, i) => { + const bh = Math.max(2, (v / max) * rh); + const bx = startX + i * slot + (slot - bw) / 2; + if (i === 0) firstCx = bx + bw / 2; + if (i === nn - 1) lastCx = bx + bw / 2; + const col = i === data.rhythm!.indexOf(max) ? T.primaryAccentBright : T.primaryAccent; + p.push(``); + }); + if (data.rhythmStart) { + p.push(`${esc(shortDay(data.rhythmStart))}`); + } + if (data.rhythmEnd && nn > 1) { + p.push(`${esc(shortDay(data.rhythmEnd))}`); + } + }, + }); + } + + // Flow the full-width sections below whichever of the hero / corner reaches + // lower, distributing the spare height as even gaps clamped to a sane band + // (so a card with few sections isn't hollow and a full one isn't cramped). + const restTop = Math.max(heroBottom, cornerBottom + 12); + const sumH = sections.reduce((a, s) => a + s.h, 0); + const n = sections.length; + let gap = n > 0 ? (footerTop - restTop - sumH) / (n + 1) : 0; + gap = Math.max(14, Math.min(38, gap)); + let cursor = restTop + gap; + for (const s of sections) { + s.draw(cursor); + cursor += s.h + gap; + } + + // --- Footer (brand signature) --- + if (data.watermark) { + p.push(`${esc(S.madeWith)} · ${esc(REPO)}`); + } + + p.push(''); + return p.join('\n'); +} diff --git a/src/statusBar.ts b/src/statusBar.ts index 820f93ba..8bd2a392 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -298,19 +298,18 @@ export class StatusBarManager { if (t > now) { return limit; // still current } - // Expired. If it reset only recently (within ~2 periods), the data is - // simply waiting for the next refetch — show 0% for the fresh window. - // But if it expired long ago, the fetch has been failing for ages and we - // have no trustworthy data: drop it rather than assert a fabricated 0% - // (which would falsely imply "full quota available"). + // Expired. If it expired long ago the fetch has been failing for ages and + // we have no trustworthy data: drop it rather than assert a fabricated 0%. if (now - t > 2 * periodMs) { return undefined; } - let next = t; - while (next <= now) { - next += periodMs; - } - return { utilization: 0, resets_at: new Date(next).toISOString() }; + // Expired recently: the window rolled over, so utilisation is back to 0. + // But these windows are USAGE-anchored — the next window (and its reset) + // only starts when you next send a message. Fabricating "reset = old + 5h" + // showed a countdown that was wrong until the next real fetch (the reset + // appeared to already be ticking before any message). So show 0% with NO + // countdown (resets_at cleared → "—"); the real reset lands on next use. + return { utilization: 0, resets_at: '' }; }; const out: ClaudeApiUsageResponse = { five_hour: roll(usageLimits.five_hour, H5), @@ -478,7 +477,9 @@ export class StatusBarManager { ); } - /** Time remaining until a reset, e.g. "2h 15m" or "3.2d". */ + /** Time remaining until a reset, e.g. "2h 15m" or "4d 12h". The detailed + * tooltip uses whole days + hours (fractional days like "4.5d" read oddly); + * the compact status-bar countdown keeps the "4.5d" form (see quotaFormat). */ private formatCountdown(target: Date): string { const ms = target.getTime() - Date.now(); if (ms <= 0) { @@ -488,7 +489,9 @@ export class StatusBarManager { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours >= 24) { - return `${(hours / 24).toFixed(1)}d`; + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return `${days}d ${remHours}h`; } return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } diff --git a/src/test/cacheChurn.test.ts b/src/test/cacheChurn.test.ts new file mode 100644 index 00000000..8a93cdfb --- /dev/null +++ b/src/test/cacheChurn.test.ts @@ -0,0 +1,48 @@ +// Tests for estimateCacheChurnCost — the "cache-churn bill" data layer. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { ClaudeDataLoader } from '../dataLoader'; +import { ClaudeUsageRecord } from '../types'; + +const min = 60 * 1000; +const rec = (sid: string, model: string, tsMs: number, write: number, read: number): ClaudeUsageRecord => ({ + timestamp: new Date(tsMs).toISOString(), + message: { + usage: { input_tokens: 2, output_tokens: 100, cache_creation_input_tokens: write, cache_read_input_tokens: read }, + model, + }, + _sessionId: sid, +}); + +test('estimateCacheChurnCost splits idle vs model-switch churn', () => { + const now = Date.now(); + const records: ClaudeUsageRecord[] = [ + // session a: rewrite 90 min later, same model → idle churn + rec('a', 'claude-opus-4-8', now - 120 * min, 20000, 0), + rec('a', 'claude-opus-4-8', now - 30 * min, 20000, 0), + // session b: rewrite 5 min later but a model switch → switch churn + rec('b', 'claude-opus-4-8', now - 20 * min, 15000, 5000), + rec('b', 'claude-sonnet-4-6', now - 15 * min, 15000, 0), + // session c: another idle rewrite (to clear the min-sample gate) + rec('c', 'claude-opus-4-8', now - 200 * min, 12000, 0), + rec('c', 'claude-opus-4-8', now - 100 * min, 12000, 0), + ]; + const r = ClaudeDataLoader.estimateCacheChurnCost(records, 30, 60); + assert.ok(r, 'should return a result'); + assert.equal(r!.switchCount, 1); + assert.equal(r!.idleCount, 2); + assert.ok(r!.switchUsd > 0 && r!.idleUsd > 0); + assert.ok(Math.abs(r!.wastedUsd - (r!.switchUsd + r!.idleUsd)) < 1e-9); +}); + +test('returns null without enough churn signal', () => { + const now = Date.now(); + // a single warm read (no churn) → nothing to bill + const records: ClaudeUsageRecord[] = [ + rec('a', 'claude-opus-4-8', now - 20 * min, 10000, 0), + rec('a', 'claude-opus-4-8', now - 15 * min, 0, 10000), // warm read, not a rewrite + ]; + assert.equal(ClaudeDataLoader.estimateCacheChurnCost(records, 30, 60), null); +}); diff --git a/src/test/conversationLog.test.ts b/src/test/conversationLog.test.ts new file mode 100644 index 00000000..f472b549 --- /dev/null +++ b/src/test/conversationLog.test.ts @@ -0,0 +1,129 @@ +// Tests for the read-only conversation viewer's .jsonl parser. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { parseConversation } from '../conversationLog'; + +const line = (o: unknown): string => JSON.stringify(o); + +const SAMPLE = [ + line({ type: 'custom-title', customTitle: 'My session' }), + line({ type: 'summary', summary: 'ignored because custom wins' }), + line({ + type: 'user', + uuid: 'u1', + timestamp: '2026-07-01T10:00:00Z', + message: { role: 'user', content: 'Fix the login bug' }, + }), + line({ + type: 'assistant', + uuid: 'a1', + timestamp: '2026-07-01T10:00:05Z', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + content: [ + { type: 'thinking', thinking: 'Let me look at auth.ts' }, + { type: 'text', text: 'I will check auth.ts.' }, + { type: 'tool_use', name: 'Read', input: { file_path: 'src/auth.ts' } }, + ], + }, + }), + line({ + type: 'user', + uuid: 'u2', + timestamp: '2026-07-01T10:00:06Z', + message: { role: 'user', content: [{ type: 'tool_result', content: 'file contents...', is_error: false }] }, + }), + line({ + type: 'assistant', + uuid: 'a2', + timestamp: '2026-07-01T10:00:10Z', + message: { role: 'assistant', model: 'claude-opus-4-8', content: [{ type: 'text', text: 'Found it: line 42.' }] }, + }), +].join('\n'); + +test('parses titles, prompts, text, thinking and tool turns in order', () => { + const r = parseConversation(SAMPLE); + assert.equal(r.title, 'My session'); + assert.equal(r.promptCount, 1); + const kinds = r.turns.map((t) => t.kind); + assert.deepEqual(kinds, ['prompt', 'thinking', 'text', 'tool_use', 'tool_result', 'text']); + assert.equal(r.turns[0].text, 'Fix the login bug'); + assert.equal(r.turns[3].toolName, 'Read'); + assert.equal(r.turns[3].text, 'src/auth.ts'); + assert.equal(r.turns[5].model, 'claude-opus-4-8'); + assert.equal(r.firstTs, '2026-07-01T10:00:00Z'); + assert.equal(r.lastTs, '2026-07-01T10:00:10Z'); +}); + +test('skips meta and sidechain lines, and dedupes by uuid', () => { + const txt = [ + line({ type: 'user', uuid: 'm', isMeta: true, message: { role: 'user', content: '' } }), + line({ type: 'assistant', uuid: 's', isSidechain: true, message: { role: 'assistant', content: [{ type: 'text', text: 'subagent' }] } }), + line({ type: 'user', uuid: 'dup', message: { role: 'user', content: 'hello' } }), + line({ type: 'user', uuid: 'dup', message: { role: 'user', content: 'hello again (same uuid)' } }), + ].join('\n'); + const r = parseConversation(txt); + assert.equal(r.turns.length, 1); + assert.equal(r.turns[0].text, 'hello'); +}); + +test('malformed lines are skipped, not fatal', () => { + const txt = ['not json', '', ' ', line({ type: 'user', uuid: 'x', message: { role: 'user', content: 'ok' } })].join('\n'); + const r = parseConversation(txt); + assert.equal(r.turns.length, 1); + assert.equal(r.turns[0].text, 'ok'); +}); + +test('maxTurns keeps the most recent turns; totalTurns reports the full count', () => { + const many = Array.from({ length: 10 }, (_, i) => + line({ type: 'user', uuid: 'u' + i, message: { role: 'user', content: 'msg ' + i } }) + ).join('\n'); + const r = parseConversation(many, { maxTurns: 3 }); + assert.equal(r.totalTurns, 10); + assert.equal(r.turns.length, 3); + assert.deepEqual(r.turns.map((t) => t.text), ['msg 7', 'msg 8', 'msg 9']); +}); + +test('maxRounds keeps the last N rounds, not raw turns (prompts are not starved)', () => { + // 4 rounds; each prompt followed by many tool turns — a raw-turn cap would + // drop early prompts, a round cap keeps whole rounds. + const parts: string[] = []; + for (let p = 0; p < 4; p++) { + parts.push(line({ type: 'user', uuid: 'p' + p, message: { role: 'user', content: 'prompt ' + p } })); + for (let k = 0; k < 5; k++) { + parts.push( + line({ type: 'assistant', uuid: `a${p}_${k}`, message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Bash', input: { command: 'x' } }] } }) + ); + } + } + const r = parseConversation(parts.join('\n'), { maxRounds: 2 }); + assert.equal(r.totalRounds, 4); + assert.equal(r.turns.filter((t) => t.kind === 'prompt').length, 2); // last 2 prompts kept whole + assert.deepEqual(r.turns.filter((t) => t.kind === 'prompt').map((t) => t.text), ['prompt 2', 'prompt 3']); +}); + +test('maxCharsPerTurn truncates and flags it', () => { + const long = 'x'.repeat(2000); + const r = parseConversation(line({ type: 'user', uuid: 'u', message: { role: 'user', content: long } }), { + maxCharsPerTurn: 100, + }); + assert.equal(r.turns[0].text.length, 100); + assert.equal(r.turns[0].truncated, true); +}); + +test('includeThinking / includeTools filters drop those turns', () => { + const r = parseConversation(SAMPLE, { includeThinking: false, includeTools: false }); + const kinds = r.turns.map((t) => t.kind); + assert.deepEqual(kinds, ['prompt', 'text', 'text']); +}); + +test('ai-title is used when no custom-title is present', () => { + const txt = [ + line({ type: 'ai-title', aiTitle: 'Auto title' }), + line({ type: 'user', uuid: 'u', message: { role: 'user', content: 'hi' } }), + ].join('\n'); + assert.equal(parseConversation(txt).title, 'Auto title'); +}); diff --git a/src/test/dataContribution.test.ts b/src/test/dataContribution.test.ts new file mode 100644 index 00000000..745051d8 --- /dev/null +++ b/src/test/dataContribution.test.ts @@ -0,0 +1,46 @@ +// The data-contribution scaffold must stay OFF and privacy-safe by default. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { + DEFAULT_CONTRIBUTION_CONFIG, + UsageObservation, + contributeObservations, + redactObservation, + willContribute, +} from '../dataContribution'; + +const obs: UsageObservation = { + kind: 'cache-ttl', + modelFamily: 'opus', + cacheWarmMinutes: 60, + sampleN: 7702, + monthBucket: '2026-07', + subscriptionTier: 'max', +}; + +test('nothing contributes by default (off, no endpoint)', () => { + assert.equal(willContribute(DEFAULT_CONTRIBUTION_CONFIG), false); + assert.equal(willContribute({ enabled: true }), false); // enabled but no endpoint + assert.equal(willContribute({ enabled: false, endpoint: 'https://x' }), false); + assert.equal(willContribute({ enabled: true, endpoint: 'https://x' }), true); +}); + +test('contributeObservations is a no-op today even when "enabled"', async () => { + const r = await contributeObservations([obs], { enabled: true, endpoint: 'https://x' }); + assert.deepEqual(r, { sent: 0 }); +}); + +test('tier is redacted unless explicitly shared', () => { + assert.equal(redactObservation(obs, { enabled: true }).subscriptionTier, undefined); + assert.equal(redactObservation(obs, { enabled: true, shareTier: true }).subscriptionTier, 'max'); +}); + +test('the Observation type carries no identifying field', () => { + // Compile-time guarantee mostly; assert the sample only has aggregate keys. + const allowed = new Set(['kind', 'modelFamily', 'cacheWarmMinutes', 'sampleN', 'monthBucket', 'subscriptionTier']); + for (const k of Object.keys(obs)) { + assert.ok(allowed.has(k), `unexpected field ${k}`); + } +}); diff --git a/src/test/dateKeys.test.ts b/src/test/dateKeys.test.ts new file mode 100644 index 00000000..1f9f5ee3 --- /dev/null +++ b/src/test/dateKeys.test.ts @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { dayKeyInZone, monthKeyInZone } from '../dateKeys'; + +test('a post-midnight local record buckets into the local day, not the UTC day', () => { + // 20:00 UTC on 30 Jun is 04:00 on 1 Jul in Hong Kong (UTC+8). + const d = new Date('2026-06-30T20:00:00Z'); + assert.equal(dayKeyInZone(d, 'Asia/Hong_Kong'), '2026-07-01'); + assert.equal(dayKeyInZone(d, 'UTC'), '2026-06-30'); +}); + +test('month key follows the same zone (the This-Month boundary bug)', () => { + // 01:00 UTC on 1 Jul is 21:00 on 30 Jun in New York (UTC-4, EDT). + const d = new Date('2026-07-01T01:00:00Z'); + assert.equal(monthKeyInZone(d, 'America/New_York'), '2026-06'); + assert.equal(monthKeyInZone(d, 'Asia/Hong_Kong'), '2026-07'); +}); + +test('day and month keys agree within one zone', () => { + const d = new Date('2026-07-01T01:00:00Z'); + assert.equal(dayKeyInZone(d, 'America/New_York').slice(0, 7), monthKeyInZone(d, 'America/New_York')); +}); + +test('empty zone uses the system zone and still yields a well-formed key', () => { + const key = dayKeyInZone(new Date('2026-07-01T12:00:00Z'), ''); + assert.match(key, /^\d{4}-\d{2}-\d{2}$/); +}); + +test('an invalid user-typed zone falls back instead of throwing', () => { + const key = dayKeyInZone(new Date('2026-07-01T12:00:00Z'), 'Not/AZone'); + assert.match(key, /^\d{4}-\d{2}-\d{2}$/); +}); + +test('an invalid date yields an empty key', () => { + assert.equal(dayKeyInZone(new Date('nonsense'), 'UTC'), ''); + assert.equal(monthKeyInZone(new Date('nonsense'), 'UTC'), ''); +}); diff --git a/src/test/format.test.ts b/src/test/format.test.ts new file mode 100644 index 00000000..68cfe9c2 --- /dev/null +++ b/src/test/format.test.ts @@ -0,0 +1,58 @@ +// Tests for compact token formatting + the tokenDecimalPlaces setting (V2.2). +// I18n is a pure module (no vscode import), so it runs under node:test directly. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { I18n } from '../i18n'; + +I18n.setLanguage('en'); // deterministic locale for the <1000 branch + +test('formatTokensCompact honours tokenDecimalPlaces (default 1)', () => { + I18n.setTokenDecimalPlaces(1); + assert.equal(I18n.formatTokensCompact(1_200_000), '1.2M'); + assert.equal(I18n.formatTokensCompact(345_600), '345.6k'); + assert.equal(I18n.formatTokensCompact(1_234_567_890), '1.2B'); +}); + +test('tokenDecimalPlaces = 0 rounds to whole units', () => { + I18n.setTokenDecimalPlaces(0); + assert.equal(I18n.formatTokensCompact(1_200_000), '1M'); + assert.equal(I18n.formatTokensCompact(1_900_000), '2M'); // rounds up + assert.equal(I18n.formatTokensCompact(345_600), '346k'); +}); + +test('tokenDecimalPlaces = 2 keeps two decimals', () => { + I18n.setTokenDecimalPlaces(2); + assert.equal(I18n.formatTokensCompact(1_234_000), '1.23M'); + assert.equal(I18n.formatTokensCompact(12_340), '12.34k'); +}); + +test('compact token formatting does NOT touch full integer counts (<1000)', () => { + I18n.setTokenDecimalPlaces(2); + assert.equal(I18n.formatTokensCompact(500), '500'); + assert.equal(I18n.formatTokensCompact(0), '0'); +}); + +test('formatNumber compact path uses tokenDecimalPlaces and trims trailing zeros', () => { + I18n.setCompactNumbers(true); + I18n.setTokenDecimalPlaces(1); + assert.equal(I18n.formatNumber(1_200_000), '1.2M'); + assert.equal(I18n.formatNumber(1_000_000), '1M'); // parseFloat trims 1.0 -> 1 + I18n.setTokenDecimalPlaces(0); + assert.equal(I18n.formatNumber(1_200_000), '1M'); + I18n.setCompactNumbers(false); +}); + +test('formatNumber without compact leaves full integers intact', () => { + I18n.setCompactNumbers(false); + assert.equal(I18n.formatNumber(1_234_567), (1_234_567).toLocaleString('en')); +}); + +test('setTokenDecimalPlaces clamps to 0..2 (out-of-range ignored)', () => { + I18n.setTokenDecimalPlaces(1); + I18n.setTokenDecimalPlaces(5); // ignored + assert.equal(I18n.formatTokensCompact(1_200_000), '1.2M'); + I18n.setTokenDecimalPlaces(-1); // ignored + assert.equal(I18n.formatTokensCompact(1_200_000), '1.2M'); +}); diff --git a/src/test/heatmap.test.ts b/src/test/heatmap.test.ts new file mode 100644 index 00000000..205cdc0f --- /dev/null +++ b/src/test/heatmap.test.ts @@ -0,0 +1,58 @@ +// Tests for the token heatmap pure logic (V2.2 Phase 6.4) — month aggregation, +// zero-usage days, intensity buckets, metric switching. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { buildMonthHeatmap, daysInMonth, intensityBucket } from '../heatmap'; + +test('daysInMonth handles 28–31 day months', () => { + assert.equal(daysInMonth(2026, 2), 28); + assert.equal(daysInMonth(2024, 2), 29); // leap + assert.equal(daysInMonth(2026, 6), 30); + assert.equal(daysInMonth(2026, 7), 31); +}); + +test('intensityBucket: 0 for no usage, 1..4 by quartile of max', () => { + assert.equal(intensityBucket(0, 100), 0); + assert.equal(intensityBucket(-5, 100), 0); + assert.equal(intensityBucket(10, 0), 0); // max 0 → 0 + assert.equal(intensityBucket(1, 100), 1); // tiny but non-zero + assert.equal(intensityBucket(25, 100), 1); + assert.equal(intensityBucket(26, 100), 2); + assert.equal(intensityBucket(75, 100), 3); + assert.equal(intensityBucket(100, 100), 4); // the max day +}); + +test('buildMonthHeatmap fills every day, zero for missing', () => { + const daily = { + '2026-06-01': { tokens: 1000, cost: 0.5, sessions: 2 }, + '2026-06-15': { tokens: 4000, cost: 2.0, sessions: 5 }, + }; + const hm = buildMonthHeatmap(daily, 2026, 6, 'tokens'); + assert.equal(hm.cells.length, 30); + assert.equal(hm.max, 4000); + const d1 = hm.cells.find((c) => c.day === 1)!; + const d2 = hm.cells.find((c) => c.day === 2)!; + const d15 = hm.cells.find((c) => c.day === 15)!; + assert.equal(d1.value, 1000); + assert.equal(d1.bucket, intensityBucket(1000, 4000)); + assert.equal(d2.value, 0); // missing day → zero + assert.equal(d2.bucket, 0); + assert.equal(d15.bucket, 4); // the max day + assert.equal(d15.dateISO, '2026-06-15'); +}); + +test('metric switching changes the driving value', () => { + const daily = { + '2026-06-01': { tokens: 1000, cost: 9.0, sessions: 1 }, + '2026-06-02': { tokens: 9000, cost: 1.0, sessions: 1 }, + }; + const byTokens = buildMonthHeatmap(daily, 2026, 6, 'tokens'); + const byCost = buildMonthHeatmap(daily, 2026, 6, 'cost'); + const bySessions = buildMonthHeatmap(daily, 2026, 6, 'sessions'); + assert.equal(byTokens.cells.find((c) => c.day === 2)!.bucket, 4); // day 2 biggest by tokens + assert.equal(byCost.cells.find((c) => c.day === 1)!.bucket, 4); // day 1 biggest by cost + assert.equal(bySessions.max, 1); // equal sessions + assert.equal(bySessions.cells.find((c) => c.day === 1)!.bucket, 4); +}); diff --git a/src/test/heatmapSvg.test.ts b/src/test/heatmapSvg.test.ts new file mode 100644 index 00000000..1f3f3449 --- /dev/null +++ b/src/test/heatmapSvg.test.ts @@ -0,0 +1,67 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { buildContributionGrid, renderHeatmapSvg, CLAUDE_ORANGE_SCALE } from '../heatmapSvg'; +import { DayUsage } from '../heatmap'; + +const day = (tokens: number): DayUsage => ({ tokens, cost: tokens / 1000, sessions: 1 }); + +test('grid holds only days in [start, end], and totals them', () => { + const daily: Record = { '2026-01-05': day(10), '2026-01-10': day(20) }; + const g = buildContributionGrid(daily, '2026-01-01', '2026-01-20', 'tokens'); + assert.equal(g.cells.length, 20); + assert.equal(g.cells[0].dateISO, '2026-01-01'); + assert.equal(g.cells[g.cells.length - 1].dateISO, '2026-01-20'); + assert.equal(g.total, 30); +}); + +test('renderHeatmapSvg shows a trailing ~year ending today', () => { + const daily: Record = { '2026-03-15': day(1000) }; + const svg = renderHeatmapSvg(daily, { endDateISO: '2026-07-01' }); + assert.ok(svg.includes('March 15th')); // ~4 months back is in-window +}); + +test('the window ends at today — no future cells drawn', () => { + const g = buildContributionGrid({}, '2025-07-06', '2026-07-01', 'tokens'); + assert.ok(g.cells.every((c) => c.dateISO <= '2026-07-01')); + assert.ok(g.cells.some((c) => c.dateISO === '2026-07-01')); // today is included +}); + +test('top-left summary reports the compact total, "tokens in Claude Code · YEAR"', () => { + const daily: Record = { '2026-06-01': day(5_300_000_000) }; + const svg = renderHeatmapSvg(daily, { endDateISO: '2026-07-01' }); + assert.ok(svg.includes('5.3B tokens in Claude Code · 2026')); +}); + +test('tooltips read GitHub-style: " tokens on "', () => { + const daily: Record = { + '2026-06-18': day(1_200_000), + '2026-06-21': day(500), + }; + const svg = renderHeatmapSvg(daily, { endDateISO: '2026-07-01' }); + assert.ok(svg.includes('1.2M tokens on June 18th')); + assert.ok(svg.includes('500 tokens on June 21st')); // ordinal 21 → 21st + assert.ok(svg.includes('No tokens on ')); // empty days +}); + +test('buckets scale to the range max (empty 0, max 4, tiny 1)', () => { + const daily: Record = { '2026-06-30': day(1_000_000), '2026-06-01': day(5) }; + const g = buildContributionGrid(daily, '2026-01-01', '2026-07-01', 'tokens'); + assert.equal(g.cells.find((c) => c.dateISO === '2026-06-30')?.bucket, 4); + assert.equal(g.cells.find((c) => c.dateISO === '2026-06-01')?.bucket, 1); + assert.equal(g.cells.find((c) => c.value === 0)?.bucket, 0); +}); + +test('cost metric switches value, noun and summary', () => { + const daily: Record = { '2026-06-20': { tokens: 5, cost: 12.5, sessions: 2 } }; + const svg = renderHeatmapSvg(daily, { endDateISO: '2026-07-01', metric: 'cost' }); + assert.ok(svg.includes('in Claude Code · 2026')); + assert.ok(svg.includes('$12.5 on June 20th') || svg.includes('$13 on June 20th')); +}); + +test('has legend, watermark and the orange ramp; no crash on empty data', () => { + const svg = renderHeatmapSvg({}, { endDateISO: '2026-07-01', watermark: 'Made with Claude Code Usage' }); + assert.match(svg, /^ { + I18n.setTimezone('Asia/Hong_Kong'); + assert.equal(I18n.getTimezone(), 'Asia/Hong_Kong'); +}); + +test('an invalid zone falls back to system (empty), never crashes (#51)', () => { + I18n.setTimezone('EST-5 Detroit'); // what the bug reporter typed + assert.equal(I18n.getTimezone(), ''); + assert.doesNotThrow(() => new Intl.DateTimeFormat('en', I18n.dateFormatOptions())); +}); + +test('empty stays empty (system zone)', () => { + I18n.setTimezone(''); + assert.equal(I18n.getTimezone(), ''); +}); + +test('isValidTimeZone distinguishes real zones from junk', () => { + assert.equal(I18n.isValidTimeZone('America/New_York'), true); + assert.equal(I18n.isValidTimeZone('UTC'), true); + assert.equal(I18n.isValidTimeZone('EST-5 Detroit'), false); +}); diff --git a/src/test/miniMarkdown.test.ts b/src/test/miniMarkdown.test.ts new file mode 100644 index 00000000..aeaef23c --- /dev/null +++ b/src/test/miniMarkdown.test.ts @@ -0,0 +1,72 @@ +// Tests for the dependency-free Markdown renderer used by the conversation viewer. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { renderMarkdown } from '../miniMarkdown'; + +test('escapes HTML before formatting (no injection)', () => { + const html = renderMarkdown(' **bold**'); + assert.ok(!html.includes('bold')); +}); + +test('headings, bold, italic, inline code, strikethrough', () => { + assert.ok(renderMarkdown('# Title').includes('

Title

')); + assert.ok(renderMarkdown('**b**').includes('b')); + assert.ok(renderMarkdown('_i_').includes('i')); + assert.ok(renderMarkdown('`x`').includes('x')); + assert.ok(renderMarkdown('~~s~~').includes('s')); +}); + +test('fenced code block is escaped and not inline-formatted', () => { + const html = renderMarkdown('```\nconst a = **notbold**;\n\n```'); + assert.ok(html.includes('
'));
+  assert.ok(html.includes('**notbold**')); // literal, not bolded
+  assert.ok(html.includes('<b>'));
+  assert.ok(!html.includes(''));
+});
+
+test('unordered and ordered lists', () => {
+  const ul = renderMarkdown('- one\n- two');
+  assert.ok(ul.includes('
  • one
  • two
')); + const ol = renderMarkdown('1. a\n2. b'); + assert.ok(ol.includes('
  1. a
  2. b
')); +}); + +test('safe links only; javascript: is neutralised', () => { + assert.ok(renderMarkdown('[x](https://example.com)').includes('x')); + const bad = renderMarkdown('[x](javascript:alert(1))'); + assert.ok(!bad.includes('href="javascript')); + assert.ok(bad.includes('x')); +}); + +test('blockquote and horizontal rule', () => { + assert.ok(renderMarkdown('> quoted').includes('
quoted
')); + assert.ok(renderMarkdown('---').includes('
')); +}); + +test('paragraph keeps soft line breaks', () => { + assert.ok(renderMarkdown('line a\nline b').includes('line a
line b')); +}); + +test('empty / null input is safe', () => { + assert.equal(renderMarkdown(''), ''); + assert.equal(renderMarkdown(null as unknown as string), ''); +}); + +test('GFM table renders with header, rows and alignment', () => { + const md = ['| Model | TTL |', '| :--- | ---: |', '| opus | 60m |', '| deepseek | 240m |'].join('\n'); + const html = renderMarkdown(md); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); + assert.ok(html.includes('')); +}); + +test('a lone pipe line is not treated as a table', () => { + const html = renderMarkdown('a | b is just text'); + assert.ok(!html.includes('
ModelTTL
opus240m
')); + assert.ok(html.includes('

')); +}); diff --git a/src/test/promptDedup.test.ts b/src/test/promptDedup.test.ts new file mode 100644 index 00000000..c2231aec --- /dev/null +++ b/src/test/promptDedup.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { isRetryDuplicatePrompt, PROMPT_RETRY_WINDOW_MS } from '../promptDedup'; + +const t0 = Date.parse('2026-06-11T16:26:38.000Z'); + +test('first occurrence of a prompt is always counted', () => { + const seen = new Map(); + assert.equal(isRetryDuplicatePrompt('继续', t0, seen), false); +}); + +test('identical prompt within the window is a retry re-log (skipped)', () => { + const seen = new Map(); + isRetryDuplicatePrompt('继续', t0, seen); // counted + // ~30 s later (observed retry gap) → skip + assert.equal(isRetryDuplicatePrompt('继续', t0 + 30_000, seen), true); +}); + +test('identical prompt well after the window is a genuine re-send (counted)', () => { + const seen = new Map(); + isRetryDuplicatePrompt('继续', t0, seen); + // 8 minutes later → real repeat, counts + assert.equal(isRetryDuplicatePrompt('继续', t0 + 8 * 60_000, seen), false); +}); + +test('a rapid retry chain collapses to a single count', () => { + const seen = new Map(); + const gaps = [0, 20_000, 55_000, 110_000, 160_000]; // each near the previous + const counted = gaps.filter((g) => !isRetryDuplicatePrompt('spec', t0 + g, seen)); + assert.equal(counted.length, 1); +}); + +test('different prompts never dedupe each other', () => { + const seen = new Map(); + assert.equal(isRetryDuplicatePrompt('a', t0, seen), false); + assert.equal(isRetryDuplicatePrompt('b', t0 + 1000, seen), false); +}); + +test('boundary: exactly at the window edge is still a duplicate', () => { + const seen = new Map(); + isRetryDuplicatePrompt('x', t0, seen); + assert.equal(isRetryDuplicatePrompt('x', t0 + PROMPT_RETRY_WINDOW_MS, seen), true); + assert.equal(isRetryDuplicatePrompt('x', t0 + PROMPT_RETRY_WINDOW_MS + 1, new Map([['x', t0]])), false); +}); + +test('unusable timestamp is counted, never dropped', () => { + const seen = new Map(); + assert.equal(isRetryDuplicatePrompt('x', NaN, seen), false); +}); diff --git a/src/test/shareCard.test.ts b/src/test/shareCard.test.ts new file mode 100644 index 00000000..26cebc57 --- /dev/null +++ b/src/test/shareCard.test.ts @@ -0,0 +1,114 @@ +// Tests for the Usage Share Card pure logic (V2.2 Phase 5.8) — aggregation, +// privacy redaction, optional sections, badge selection, filename. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { UsageData } from '../types'; +import { + DEFAULT_SECTIONS, + ShareInput, + ShareSections, + buildShareCardData, + cacheSharePct, + modelFamily, + prettyModelName, + selectShareBadge, + shareCardFilename, + totalTokens, +} from '../shareCard'; + +const u = (o: Partial): UsageData => ({ + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalCost: 0, + costBreakdown: { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 }, + messageCount: 0, + modelBreakdown: {}, + ...o, +}); + +const input = (o: Partial = {}): ShareInput => ({ + range: 'month', + rangeData: u({ totalInputTokens: 100, totalOutputTokens: 50, totalCacheCreationTokens: 50, totalCacheReadTokens: 800, totalCost: 1.23 }), + daily: [1, 2, 3, 4], + sessionCount: 5, + topModel: 'claude-opus-4-8', + ...o, +}); + +test('totalTokens sums the four buckets; cacheSharePct is read/input-side', () => { + const d = u({ totalInputTokens: 100, totalOutputTokens: 50, totalCacheCreationTokens: 50, totalCacheReadTokens: 800 }); + assert.equal(totalTokens(d), 1000); + assert.equal(cacheSharePct(d), Math.round((800 / 950) * 100)); // 84 + assert.equal(cacheSharePct(u({})), 0); // no divide-by-zero +}); + +test('modelFamily reduces Claude to a family, keeps third-party', () => { + assert.equal(modelFamily('claude-opus-4-8'), 'Opus'); + assert.equal(modelFamily('claude-sonnet-4-6'), 'Sonnet'); + assert.equal(modelFamily('claude-haiku-4-5'), 'Haiku'); + assert.equal(modelFamily('claude-fable-5'), 'Fable'); + assert.equal(modelFamily('deepseek-v4-pro'), 'deepseek-v4-pro'); + assert.equal(modelFamily(undefined), undefined); +}); + +test('prettyModelName keeps the version (Carl: show the model, not just the family)', () => { + assert.equal(prettyModelName('claude-opus-4-8'), 'Opus 4.8'); + assert.equal(prettyModelName('claude-sonnet-4-6'), 'Sonnet 4.6'); + assert.equal(prettyModelName('claude-fable-5'), 'Fable 5'); + assert.equal(prettyModelName('claude-3-5-sonnet-20241022'), 'Sonnet 3.5'); + assert.equal(prettyModelName('deepseek-v4-pro'), 'deepseek-v4-pro'); + assert.equal(prettyModelName(undefined), undefined); +}); + +test('DEFAULT_SECTIONS redacts project name / workflow / peak / composition', () => { + const out = buildShareCardData(input({ projectName: 'Secret-Project', workflowShare: 0.4, peakContextTokens: 700000 }), DEFAULT_SECTIONS); + // privacy-safe fields present + assert.ok('totalTokens' in out && 'estimatedCost' in out && 'cacheSharePct' in out && 'badge' in out); + // default-off / private fields ABSENT + assert.ok(!('projectName' in out), 'project name must be hidden by default'); + assert.ok(!('workflowSharePct' in out)); + assert.ok(!('peakContextTokens' in out)); + // structurally impossible to leak ids / paths / prompts (no such keys exist; + // "sessions" is a non-identifying count and is allowed) + assert.deepEqual( + Object.keys(out).filter((k) => /sessionid|path|prompt|cwd|username|directory|filename/i.test(k)), + [] + ); +}); + +test('project name only appears when explicitly enabled', () => { + const on: ShareSections = { ...DEFAULT_SECTIONS, projectName: true }; + const out = buildShareCardData(input({ projectName: 'My-Repo' }), on); + assert.equal(out.projectName, 'My-Repo'); +}); + +test('toggling sections off omits those fields', () => { + const out = buildShareCardData(input(), { ...DEFAULT_SECTIONS, estimatedCost: false, cacheEfficiency: false, badge: false }); + assert.ok(!('estimatedCost' in out)); + assert.ok(!('cacheSharePct' in out)); + assert.ok(!('badge' in out)); + assert.equal(out.totalTokens, 1000); +}); + +test('badge selection is deterministic and prioritised', () => { + // peak context wins over cache + assert.equal(selectShareBadge(input({ peakContextTokens: 600000, rangeData: u({ totalCacheReadTokens: 900, totalInputTokens: 100 }) })).id, 'context-marathoner'); + // cache saver + assert.equal(selectShareBadge(input({ rangeData: u({ totalCacheReadTokens: 900, totalInputTokens: 100 }) })).id, 'cache-saver'); + // token sprinter (big single day) + assert.equal(selectShareBadge(input({ daily: [6_000_000], rangeData: u({ totalOutputTokens: 10 }) })).id, 'token-sprinter'); + // workflow pilot (many sessions) + assert.equal(selectShareBadge(input({ sessionCount: 40, daily: [1], rangeData: u({ totalOutputTokens: 10 }) })).id, 'workflow-pilot'); + // fallback + assert.equal(selectShareBadge(input({ sessionCount: 1, daily: [1], rangeData: u({ totalOutputTokens: 10 }) })).id, 'steady-builder'); +}); + +test('filename follows the documented pattern', () => { + assert.equal(shareCardFilename('week', new Date(2026, 5, 27)), 'claude-code-usage-2026-06-week.png'); + assert.equal(shareCardFilename('month', new Date(2026, 5, 27)), 'claude-code-usage-2026-06-month.png'); + assert.equal(shareCardFilename('today', new Date(2026, 5, 27)), 'claude-code-usage-2026-06-27-day.png'); +}); diff --git a/src/test/shareCardSvg.test.ts b/src/test/shareCardSvg.test.ts new file mode 100644 index 00000000..f3c6670b --- /dev/null +++ b/src/test/shareCardSvg.test.ts @@ -0,0 +1,165 @@ +// Tests for the share-card SVG renderer (Code Pulse · Aurora Console) — themes, +// well-formed output, present fields drawn, absent fields omitted (privacy). + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { ShareCardData } from '../shareCard'; +import { renderShareCardSvg, resolveShareCardTheme, SHARE_CARD_THEMES, BADGE_COPY } from '../shareCardSvg'; + +const base: ShareCardData = { range: 'month', watermark: true }; + +test('renders a well-formed 1200x680 svg with the brand', () => { + const svg = renderShareCardSvg(base); + assert.match(svg, /^$/); + assert.match(svg, /width="1200" height="680"/); + assert.match(svg, />Claude Code UsageAI coding usage snapshot { + const svg = renderShareCardSvg({ ...base, totalTokens: 5_000_000_000 }); + assert.match(svg, />5\.0Btotal tokens { + const zh = renderShareCardSvg({ ...base, totalTokens: 512_400_000 }, { lang: 'zh-CN' }); + assert.match(zh, />5\.1亿3\.4萬 { + const svg = renderShareCardSvg({ ...base, totalTokens: 1_841_143_919 }, { fullNumbers: true }); + assert.match(svg, />1,841,143,919 { + const svg = renderShareCardSvg({ ...base, estimatedCost: 12.5 }); + assert.match(svg, /\$12\.50/); +}); + +test('omits sections that are absent (privacy: only draws what is present)', () => { + const svg = renderShareCardSvg(base); + assert.doesNotMatch(svg, />sessionscache hittop model { + const svg = renderShareCardSvg({ + ...base, + totalTokens: 1_000_000, + estimatedCost: 42, + cacheSharePct: 71, + topModelName: 'Opus 4.8', + sessions: 9, + }); + assert.match(svg, />est\. cost71%Opus 4\.8sessions { + const svg = renderShareCardSvg({ + ...base, + composition: { input: 1_000_000, output: 3_000_000, cacheCreate: 2_000_000, cacheRead: 4_000_000 }, + }); + assert.match(svg, /Token mix/); + assert.match(svg, /Cache read 40% · 4M/); + assert.match(svg, /Input 10% · 1M/); +}); + +test('daily pulse labels the peak and first/last dates', () => { + const svg = renderShareCardSvg({ ...base, rhythm: [1, 2, 5_300_000], rhythmStart: '2026-06-01', rhythmEnd: '2026-06-30' }); + assert.match(svg, /peak 5\.3M/); + assert.match(svg, />Jun 1Jun 30 { + const en = renderShareCardSvg({ ...base, badge: { id: 'cache-saver', label: 'Cache Saver' } }); + assert.match(en, />Cache Alchemist { + const svg = renderShareCardSvg( + { ...base, badge: { id: 'token-sprinter', label: 'x' } }, + { avatarDataUri: 'data:image/png;base64,AAA', username: 'octocat' } + ); + assert.match(svg, /]+href="data:image\/png;base64,AAA"/); + assert.match(svg, />octocatToken Sprinter { + const svg = renderShareCardSvg(base); + assert.match(svg, /github\.com\/ClaudeCodeUsage/); + assert.doesNotMatch(svg, / { + const svg = renderShareCardSvg({ ...base, projectName: 'ac' }); + assert.match(svg, /a<b>c/); +}); + +// --- Themes --- + +test('theme resolution: default is claudeClassic; auto follows VS Code', () => { + assert.equal(resolveShareCardTheme(undefined), 'claudeClassic'); + assert.equal(resolveShareCardTheme('claudeCream'), 'claudeCream'); + assert.equal(resolveShareCardTheme('claudeClassic'), 'claudeClassic'); + assert.equal(resolveShareCardTheme('auroraDark'), 'auroraDark'); + assert.equal(resolveShareCardTheme('auto', true), 'auroraDark'); + assert.equal(resolveShareCardTheme('auto', false), 'claudeClassic'); +}); + +test('default theme paints the Claude Classic background', () => { + const svg = renderShareCardSvg(base); + assert.match(svg, new RegExp(SHARE_CARD_THEMES.claudeClassic.bgTop)); +}); + +test('aurora dark theme paints the navy background', () => { + const svg = renderShareCardSvg(base, { theme: 'auroraDark' }); + assert.match(svg, new RegExp(SHARE_CARD_THEMES.auroraDark.bgTop)); // #111827 + assert.doesNotMatch(svg, new RegExp(SHARE_CARD_THEMES.claudeClassic.bgBottom)); // #FDEEE6 not in dark +}); + +test('hidden project / cost stay hidden in both themes', () => { + for (const theme of ['claudeCream', 'auroraDark'] as const) { + const svg = renderShareCardSvg({ range: 'month', watermark: true, totalTokens: 1 }, { theme }); + assert.doesNotMatch(svg, /est\. cost/); // no cost tile without estimatedCost + assert.doesNotMatch(svg, /Secret-Project/); // a project name would only appear if set + } +}); + +test('BADGE_COPY covers every badge id in en / zh-CN / zh-TW', () => { + for (const id of ['context-marathoner', 'cache-saver', 'token-sprinter', 'workflow-pilot', 'steady-builder']) { + for (const lang of ['en', 'zh-CN', 'zh-TW'] as const) { + assert.ok(BADGE_COPY[id]?.[lang]?.title && BADGE_COPY[id]?.[lang]?.line, `${id} ${lang}`); + } + } +}); + +test('card text follows the UI language', () => { + const data = { ...base, totalTokens: 1_000_000, estimatedCost: 5, cacheSharePct: 90 }; + const en = renderShareCardSvg(data, { lang: 'en' }); + assert.match(en, />total tokenscache hit总 token缓存命中total tokenstotal tokens; + churn: ReturnType; + byModel: ReturnType; + rightsizing: ReturnType; + health: ReturnType; + hours: ReturnType; + skillRoi: ReturnType; + }; + // One reusable conversation-viewer panel: each "view" click re-reads the file + // and refreshes this panel rather than piling up new tabs. + private conversationPanel?: vscode.WebviewPanel; + // Last generated share card + its config, kept so an auto-refresh re-render + // doesn't wipe the user's picks or preview (share card / heatmap / optimizer + // output should survive data refreshes — only usage numbers update). + private lastShareCardSvg?: string; + private lastShareCardConfig?: { + range: string; + scope: string; + sections: Record; + avatar: boolean; + username: boolean; + fullNumbers: boolean; + theme: string; + }; // Real quota utilisation (pushed asynchronously) for the workflow quota // guard banner; dismissal lasts for the lifetime of this window. private usageLimits: ClaudeApiUsageResponse | null = null; @@ -110,10 +149,85 @@ export class UsageWebviewProvider { case 'getAdvice': vscode.commands.executeCommand('claudeCodeUsage.getAdvice'); break; - case 'setPauseDashboardRefresh': + case 'exportHeatmap': + vscode.commands.executeCommand('claudeCodeUsage.exportHeatmap'); + break; + case 'publishHeatmap': + vscode.commands.executeCommand('claudeCodeUsage.publishHeatmapToGitHub'); + break; + case 'buildShareCard': { + // On-demand preview: build the SVG from the panel's config and send + // it back for injection (no full re-render). + if (this.panel && this.allRecords && this.allRecords.length > 0) { + try { + const id = message.avatar || message.username ? await this.getGithubIdentity() : {}; + const range = String(message.range || 'last30'); + const scope = String(message.scope || 'all'); + const sections = (message.sections || {}) as Record; + const theme = (message.theme as ShareCardTheme) || 'claudeClassic'; + const svg = this.buildShareCardSvgFor(range, scope, sections as Partial, { + avatarDataUri: message.avatar ? id.avatar : undefined, + username: message.username ? id.name : undefined, + fullNumbers: !!message.fullNumbers, + theme, + }); + // Remember it so a re-render restores the preview + picks. + this.lastShareCardSvg = svg; + this.lastShareCardConfig = { + range, + scope, + sections, + avatar: !!message.avatar, + username: !!message.username, + fullNumbers: !!message.fullNumbers, + theme, + }; + this.panel.webview.postMessage({ command: 'shareCardResult', svg }); + } catch (e) { + this.panel.webview.postMessage({ command: 'shareCardResult', error: (e as Error).message }); + } + } + break; + } + case 'exportShareCard': { + // Export using the panel's config (range / scope / sections). + if (!this.allRecords || this.allRecords.length === 0) { + vscode.window.showWarningMessage(I18n.t.popup.noDataMessage); + break; + } + const range = String(message.range || 'last30'); + const id = message.avatar || message.username ? await this.getGithubIdentity() : {}; + const svg = this.buildShareCardSvgFor(range, String(message.scope || 'all'), (message.sections || {}) as Partial, { + avatarDataUri: message.avatar ? id.avatar : undefined, + username: message.username ? id.name : undefined, + fullNumbers: !!message.fullNumbers, + theme: (message.theme as ShareCardTheme) || 'claudeClassic', + }); + const defaultName = shareCardFilename(range).replace(/\.png$/, '.svg'); + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), + filters: { 'SVG image': ['svg'] }, + saveLabel: 'Export share card', + }); + if (!uri) { + break; + } + try { + await vscode.workspace.fs.writeFile(uri, Buffer.from(svg, 'utf8')); + const open = 'Open'; + const pick = await vscode.window.showInformationMessage('Usage share card exported.', open); + if (pick === open) { + await vscode.commands.executeCommand('vscode.open', uri); + } + } catch (e) { + vscode.window.showErrorMessage(`Share card export failed: ${(e as Error).message}`); + } + break; + } + case 'setDashboardAutoRefresh': // Persist the in-dashboard toggle so it survives reload. Lives in the // dashboard-managed store now (no longer in VS Code Settings). - await this.settings?.set('pauseDashboardRefresh', !!message.value); + await this.settings?.set('dashboardAutoRefresh', !!message.value); break; case 'tabChanged': this.currentTab = message.tab; @@ -183,6 +297,16 @@ export class UsageWebviewProvider { } break; } + case 'viewConversation': { + // Read-only: read the session .jsonl, parse it, open a viewer panel. + const sessionId = message.sessionId; + if (!isValidSessionId(sessionId)) { + vscode.window.showWarningMessage(I18n.t.popup.resumeInvalid); + break; + } + await this.openConversationViewer(sessionId, String(message.title || sessionId)); + break; + } case 'updateSetting': if (this.settings && typeof message.key === 'string') { await this.settings.set(message.key, message.value); @@ -304,7 +428,8 @@ export class UsageWebviewProvider { projectBreakdown: ProjectGroup[] = [], contentAnalysis: ContentAnalysis | null = null, branchBreakdown: BranchUsage[] = [], - workflowBreakdown: WorkflowUsage[] = [] + workflowBreakdown: WorkflowUsage[] = [], + costliestMessages: CostlyMessage[] = [] ): void { this.currentSessionData = sessionData; this.todayData = todayData; @@ -324,6 +449,7 @@ export class UsageWebviewProvider { this.contentAnalysis = contentAnalysis; this.branchBreakdown = branchBreakdown; this.workflowBreakdown = workflowBreakdown; + this.costliestMessages = costliestMessages; if (this.panel) { this.updateWebview(); @@ -420,7 +546,7 @@ export class UsageWebviewProvider { - + ${I18n.t.popup.title} @@ -442,7 +568,7 @@ export class UsageWebviewProvider { - + ${I18n.t.popup.title} @@ -466,7 +592,7 @@ export class UsageWebviewProvider { - + ${I18n.t.popup.title} @@ -520,7 +646,7 @@ export class UsageWebviewProvider { '" onclick="showTab(\'content\')">' + contentTab + '' : ''; const contentTabContent = contentEnabled - ? '

' + this.renderContentData() + '
' + ? '
' + this.renderContentData() + this.renderCacheWarmth() + this.renderInsights() + this.renderCostliestMessages() + '
' : ''; return ( @@ -529,7 +655,7 @@ export class UsageWebviewProvider { - + ` + title + ` @@ -537,7 +663,7 @@ export class UsageWebviewProvider { this.getStyles() + ` - +

` + @@ -693,6 +819,7 @@ export class UsageWebviewProvider { const snap: SettingView[] = this.settings ? this.settings.snapshot() : []; const groups: { key: string; label: string }[] = [ { key: 'general', label: t.settingsGroupGeneral }, + { key: 'features', label: t.settingsGroupFeatures }, { key: 'statusBar', label: t.settingsGroupStatusBar }, { key: 'data', label: t.settingsGroupData }, { key: 'advice', label: t.settingsGroupAdvice }, @@ -742,13 +869,30 @@ export class UsageWebviewProvider { onCh('boolean') + '>'; } else if (it.type === 'enum') { - const opts = (it.enumValues || []) - .map((v, i) => { - const label = (it.enumLabels && it.enumLabels[i]) || (v === '' ? '(default)' : v); - const sel = String(it.value) === v ? ' selected' : ''; - return ''; - }) - .join(''); + const cur = String(it.value); + const option = (v: string, lbl: string): string => + ''; + let opts = ''; + // Keep a previously-stored value selectable even if it's not in the list. + const known = new Set(it.enumValues || []); + if (cur && !known.has(cur)) { + opts += option(cur, cur + ' (current)'); + } + if (it.enumGroups && it.enumGroups.length > 0) { + // Grouped rendering (e.g. timezone: Common / All zones). + opts += it.enumGroups + .map( + (g) => + '' + + g.values.map((v, i) => option(v, g.labels[i] || (v === '' ? '(default)' : v))).join('') + + '' + ) + .join(''); + } else { + const values = it.enumValues || []; + const labels = it.enumLabels || []; + opts += values.map((v, i) => option(v, labels[i] || (v === '' ? '(default)' : v))).join(''); + } control = ''; } else if (it.type === 'number') { control = @@ -903,6 +1047,38 @@ export class UsageWebviewProvider { return todaySummary + hourlyBreakdown; } + /** Opt-in (showEfficiency) efficiency chips appended to a usage summary: + * cost per message (exact: cost ÷ messages) and realised cache savings + * (cacheRead tokens × the input−cacheRead price gap, per model). Off by + * default — not everyone wants cost-efficiency framing. */ + private renderEfficiencyChips(data: UsageData): string { + if (!this.setting('showEfficiency', false) || !data || data.messageCount <= 0) { + return ''; + } + const costPerMsg = data.totalCost / data.messageCount; + const totalTokens = + data.totalInputTokens + data.totalOutputTokens + data.totalCacheCreationTokens + data.totalCacheReadTokens; + const tokensPerMsg = totalTokens / data.messageCount; + let savings = 0; + for (const [model, mb] of Object.entries(data.modelBreakdown)) { + if (mb.cacheReadTokens > 0) { + const r = getModelRatesPerMillion(model); + if (r) { + savings += (mb.cacheReadTokens * Math.max(0, r.input - r.cacheRead)) / 1_000_000; + } + } + } + const chip = (label: string, value: string, title: string): string => + '' + label + + '' + value + ''; + let chips = chip('Cost / message', I18n.formatCurrency(costPerMsg), 'Total cost ÷ messages you sent'); + chips += chip('Tokens / message', I18n.formatNumber(Math.round(tokensPerMsg)), 'All tokens (in + out + cache) ÷ messages you sent'); + if (savings > 0) { + chips += chip('Cache savings', I18n.formatCurrency(savings), 'What cache reads saved vs. paying full input price'); + } + return '
' + chips + '
'; + } + private renderUsageData(data: UsageData | null): string { if (!data) { return '

' + I18n.t.popup.noDataMessage + '

'; @@ -1101,6 +1277,7 @@ export class UsageWebviewProvider { html += '

'; } + html += this.renderEfficiencyChips(data); return html; } @@ -1148,6 +1325,7 @@ export class UsageWebviewProvider {
+ @@ -1163,6 +1341,7 @@ export class UsageWebviewProvider { + - + @@ -1251,6 +1445,7 @@ export class UsageWebviewProvider { + - ' + '' + '' + '' + '' + + '' + '' + // Copy id / resume / delete actions; id re-validated host-side. '' + ''; }); @@ -1395,6 +1836,7 @@ export class UsageWebviewProvider { th('context', t.peakContext) + th('thinking', t.thinkingShare) + th('messages', t.messages) + + '' + th('duration', t.duration) + '' + '' + @@ -1449,6 +1891,55 @@ export class UsageWebviewProvider { ); } + /** Open a read-only viewer for a past conversation. Reads the session's + * .jsonl fresh, parses it, and (re)uses a single viewer panel — nothing is + * loaded back into any model context (that's the whole point vs. resume). + * Reads on every click, so an ongoing session shows its latest turns. */ + private async openConversationViewer(sessionId: string, title: string): Promise { + const files = await ClaudeDataLoader.findSessionFiles(sessionId, this.dataDirectory); + // Prefer the main session file (basename === sessionId.jsonl); else the + // largest match. Sub-agent side files can share the id but aren't the chat. + const main = + files.find((f) => path.basename(f).toLowerCase() === sessionId.toLowerCase() + '.jsonl') || files[0]; + if (!main) { + vscode.window.showWarningMessage(I18n.t.popup.deleteSessionNotFound); + return; + } + let text = ''; + try { + const bytes = await vscode.workspace.fs.readFile(vscode.Uri.file(main)); + text = Buffer.from(bytes).toString('utf8'); + } catch { + vscode.window.showWarningMessage(I18n.t.popup.deleteSessionNotFound); + return; + } + // Cap by ROUNDS (prompts), not raw turns — a single prompt can be followed + // by dozens of tool turns, so a raw-turn cap starves the prompt list. Keep + // the last 10 rounds (all rendered flat + all in the nav), with a raw-turn + // safety ceiling so a pathological round can't bloat the DOM. + const parsed = parseConversation(text, { maxRounds: 10, maxTurns: 2500, maxCharsPerTurn: 4000 }); + const panelTitle = (parsed.title || title || sessionId).slice(0, 60); + const html = renderConversationViewer(parsed, { sessionId, timezone: I18n.getTimezone() }); + // Reuse the single viewer panel (refresh it) if it's still open; else make one. + if (this.conversationPanel) { + this.conversationPanel.title = panelTitle; + this.conversationPanel.webview.html = html; + this.conversationPanel.reveal(vscode.ViewColumn.Active, false); + } else { + const panel = vscode.window.createWebviewPanel( + 'ccuConversation', + panelTitle, + { viewColumn: vscode.ViewColumn.Active, preserveFocus: false }, + { enableScripts: false, retainContextWhenHidden: false } + ); + panel.onDidDispose(() => { + this.conversationPanel = undefined; + }); + panel.webview.html = html; + this.conversationPanel = panel; + } + } + private formatDuration(start: Date, end: Date): string { if (!start || !end || isNaN(start.getTime()) || isNaN(end.getTime())) { return '-'; @@ -1469,6 +1960,14 @@ export class UsageWebviewProvider { return minutes > 0 ? hours + 'h ' + minutes + 'm' : hours + 'h'; } + /** Same formatting as formatDuration but from a raw millisecond span. */ + private formatDurationMs(ms: number): string { + if (!(ms > 0)) { + return '<1m'; + } + return this.formatDuration(new Date(0), new Date(ms)); + } + /** A table cell showing the project's friendly name with its full path beneath. */ private renderProjectCell(name: string, fullPath: string): string { const safeName = this.escapeHtml(name || 'unknown'); @@ -1493,6 +1992,14 @@ export class UsageWebviewProvider { } const t = I18n.t.popup; + // Opt-in efficiency column (cost per message), gated like the Today chips. + const showEff = this.setting('showEfficiency', false); + const effCell = (d: UsageData): string => + showEff + ? '' + : ''; const usageCells = (d: UsageData): string => '' + @@ -1500,7 +2007,8 @@ export class UsageWebviewProvider { '' + '' + '' + - ''; + '' + + effCell(d); let rows = ''; this.projectBreakdown.forEach((group, idx) => { @@ -1573,6 +2081,7 @@ export class UsageWebviewProvider { th('cachecreate', t.cacheCreation) + th('cacheread', t.cacheRead) + th('messages', t.messages) + + (showEff ? '' : '') + th('lastactive', t.lastActive) + '' + '' + rows + '' + @@ -2183,6 +2692,318 @@ export class UsageWebviewProvider { * tokens (your prompts vs. tool results vs. assistant output), to help spot * habits worth optimising. Token figures are estimated from text length. */ + /** Opt-in (showEfficiency) "top 10 costliest messages" panel for the Content + * tab. Ranks single assistant turns by cost; each row expands (native + *
) to the prompt that triggered it, its token breakdown, model and + * skill — so an expensive turn can be understood, in the same spirit as the + * AI advice and optimizer cards that live alongside it. */ + /** Opt-in (showInsights) "Experimental insights" section on the Content tab. + * Our own heuristic estimates from the local logs — labelled as estimates, + * off by default, no prompts. First card: the cache-churn bill. Styled to the + * plugin's existing card language (theme vars), with a small "estimate" tag + * as the honest signature. */ + /** The cache analyses, recomputed at most once a week (they move slowly). */ + private getAnalysis(): NonNullable { + const WEEK = 7 * 24 * 60 * 60 * 1000; + if (!this.analysisCache || Date.now() - this.analysisCache.at > WEEK) { + this.analysisCache = { + at: Date.now(), + ttl: ClaudeDataLoader.estimateCacheTtl(this.allRecords), + churn: ClaudeDataLoader.estimateCacheChurnCost(this.allRecords, 30), + byModel: ClaudeDataLoader.cacheStatsByModel(this.allRecords, 30), + rightsizing: ClaudeDataLoader.modelRightsizing(this.allRecords, 30), + health: ClaudeDataLoader.sessionHealth(this.allRecords, 30), + hours: ClaudeDataLoader.activeHours(this.allRecords, 30, I18n.getTimezone()), + skillRoi: ClaudeDataLoader.skillRoi(this.allRecords, 30), + }; + } + return this.analysisCache; + } + + private renderInsights(): string { + if (!this.setting('showInsights', false) || !this.allRecords || this.allRecords.length === 0) { + return ''; + } + const cards: string[] = []; + const analysis = this.getAnalysis(); + const money = (n: number): string => I18n.formatCurrency(n); + + // Cache-churn bill (缓存损耗账单). + const churn = analysis.churn; + if (churn && churn.wastedUsd >= 0.5) { + cards.push( + '
' + + '
Cache-churn bill' + + 'estimate · last 30 days
' + + '
' + money(churn.wastedUsd) + '
' + + '
wasted re-writing cache a warm cache would have served cheaply
' + + '
' + + '' + money(churn.idleUsd) + ' · idle gaps · ' + churn.idleCount + '×' + + '' + money(churn.switchUsd) + ' · model switches · ' + churn.switchCount + '×' + + '
' + + '
💡 Most is recoverable — keep momentum inside the ~60-min warm window and batch same-model work.
' + + '
A switch can still be worth it if the other model is cheaper per token; this counts only the churn cost.
' + + '
' + ); + } + + // Cache TTL by model / provider — how long each model's cache stays warm + // (the durable, cross-user-comparable signal; it drifts over time). Bar and + // number are the SAME metric (TTL) so there's no ambiguity; hit rate and + // volume ride along as secondary. (Provider is the family's nominal vendor; + // the serving endpoint isn't logged — a true cross-provider comparison needs + // pooled cross-user data, reserved via dataContribution.ts.) + const byModel = analysis.byModel; + const withTtl = byModel.filter((s) => s.ttlMin != null); + if (byModel.length >= 2 && withTtl.length >= 1) { + const maxTtl = Math.max(...withTtl.map((s) => s.ttlMin as number), 60); + const rows = byModel + .map((s) => { + const ttl = s.ttlMin; + const w = ttl != null ? Math.max(4, Math.round((ttl / maxTtl) * 100)) : 0; + const ttlLabel = ttl != null ? '~' + ttl + ' min' : '—'; + const sub = Math.round(s.hitRate * 100) + '% hit · ' + I18n.formatNumber(s.tokens) + ' tok'; + return ( + '
' + + '
' + this.escapeHtml(this.shortModelName(s.model)) + + '' + this.escapeHtml(s.provider) + '
' + + '
' + + '
' + this.escapeHtml(sub) + '
' + + '
' + this.escapeHtml(ttlLabel) + '
' + + '
' + ); + }) + .join(''); + cards.push( + '
' + + '
Cache warmth by model' + + 'last 30 days
' + + '
how long each model keeps your cache warm (bar & number = estimated warm window; hit rate + volume below)
' + + '
' + rows + '
' + + '
💡 A shorter warm window means that model re-writes cache sooner after an idle gap — keep momentum tighter on it, or lean on the longer-lived model for stop-start work.
' + + '
Warm window is inferred from idle-gap hit rates; “—” means too few gaps to estimate yet. The serving provider isn\'t logged, so provider is nominal.
' + + '
' + ); + } + + // Model right-sizing (C5) is DEFERRED, not rendered. Carl's call: output size + // is a weak proxy for task difficulty, and "use a cheaper model for simple + // tasks" isn't an insight users lack — in practice they habitually stay on + // the top model and just tack a simple task onto a long one. A worthwhile + // version needs a real judgement of each task's "difficulty", which means + // sampling prompt text through the (opt-in, user-key) AI-advice API — and + // that upload must be clearly labelled. `modelRightsizing` stays in + // dataLoader as dormant scaffolding for that v2. (analysis.rightsizing is + // still computed weekly but intentionally not shown.) + void analysis.rightsizing; + + // Session health: big one-shot turns → a nudge toward more checkpoints. + const health = analysis.health; + if (health) { + const bigK = Math.round(health.biggestOut / 1000); + cards.push( + '
' + + '
Big one-shot turns' + + 'last 30 days
' + + '
' + Math.round(health.jumboSharePct) + '%
' + + '
of your output came from ' + health.jumboCount + ' single turns over 4k tokens each (largest ≈ ' + bigK + 'k)
' + + '
💡 Big one-shot generations are costlier and slower to review, and a wrong direction is caught late. More, smaller checkpoints keep review cheap and catch mistakes early — reserve the big one-shots for when you genuinely can\'t monitor the run.
' + + '
A large single turn is sometimes exactly right (a whole file, a long doc); this is a habit signal, not a verdict.
' + + '
' + ); + } + + // Active hours: when in the day you actually drive work (24h sparkline). + const hrs = analysis.hours; + if (hrs) { + const max = Math.max(...hrs.hours, 1); + const pk = hrs.peakStart; + const hh = (h: number): string => String(((h % 24) + 24) % 24).padStart(2, '0') + ':00'; + const bars = hrs.hours + .map((v, h) => { + const inPeak = (((h - pk) % 24) + 24) % 24 < 4; + const height = Math.max(2, Math.round((v / max) * 100)); + return ( + '
' + + '
' + + (h % 6 === 0 ? '
' + String(h).padStart(2, '0') + '
' : '
') + + '
' + ); + }) + .join(''); + cards.push( + '
' + + '
Your active hours' + + 'last 30 days
' + + '
' + hh(pk) + '–' + hh(pk + 4) + '
' + + '
your busiest 4-hour window — ' + Math.round(hrs.peakShare) + '% of your work happens then (bars = tokens per hour of day, your timezone)
' + + '
' + bars + '
' + + '
💡 Protect that window for the heavy work, and try to keep a task inside one warm session there — momentum in your peak hours is where the cache stays hot and review is sharpest.
' + + '
' + ); + } + + // Skill ROI: output returned per $ spent, per skill/plugin. Bar AND the + // right-hand number are the SAME metric (out tokens per $) so they line up; + // cost + turns ride along as secondary. Sorted by ROI so the bars rank. + const roi = (analysis.skillRoi || []).slice().sort((a, b) => b.outPerUsd - a.outPerUsd); + if (roi.length >= 2) { + const maxRoi = Math.max(...roi.map((s) => s.outPerUsd), 1); + const rows = roi + .map((s) => { + const short = s.name.includes(':') ? s.name.slice(s.name.indexOf(':') + 1) : s.name; + const roiLabel = I18n.formatNumber(Math.round(s.outPerUsd)); // output tokens per $ + const w = Math.max(4, Math.round((s.outPerUsd / maxRoi) * 100)); + return ( + '
' + + '
' + this.escapeHtml(short) + + '' + s.kind + '
' + + '
' + + '
' + this.escapeHtml(money(s.costUsd) + ' · ' + s.turns + ' turns') + '
' + + '
' + roiLabel + '/$
' + + '
' + ); + }) + .join(''); + cards.push( + '
' + + '
Skill ROI' + + 'last 30 days
' + + '
output tokens returned per $ spent while each skill / plugin was active (bar & number = out tokens per $; cost + turns below)
' + + '
' + rows + '
' + + '
💡 A low out-tokens/$ skill is burning context for little output — worth invoking deliberately, or trimming what it loads. A high one is a lean win you can lean on.
' + + '
Only turns Claude Code tagged with a skill/plugin (≥2.1) are counted; cost is attributed to the active skill and output isn\'t "value", so treat it as a proxy.
' + + '
' + ); + } + + if (cards.length === 0) { + return ''; + } + return ( + '

Experimental insights

' + + '

Our own estimates from your local logs — heuristics, not standardized metrics (hence opt-in + labelled). No prompts leave your machine.

' + + cards.join('') + + '
' + ); + } + + /** Opt-in (showEfficiency) "cache warmth" insight: an estimate of how long the + * prompt cache stays warm while idle, measured from the user's own turns. + * Aggregate only (no prompts) — so it rides on the efficiency toggle. */ + private renderCacheWarmth(): string { + if (!this.setting('showEfficiency', false) || !this.allRecords || this.allRecords.length === 0) { + return ''; + } + const est = this.getAnalysis().ttl; // recomputed at most weekly + if (!est) { + return ''; + } + return ( + '

Cache warmth

' + + '

Estimated from ' + I18n.formatNumber(est.sampleN) + + ' of your same-model turns — the cache TTL is platform-side and can vary, so this is a measurement, not a guarantee.

' + + '
Your prompt cache appears to stay warm for about ' + est.estimateMin + + ' min of idle; the hit rate drops off after ~' + est.coldFromMin + + ' min. Keep a complex task moving within that window to avoid re-paying for cold-cache rewrites.
' + + '
' + ); + } + + private renderCostliestMessages(): string { + // Own opt-in (separate from showEfficiency) because it displays your prompt + // text — a privacy step above the aggregate efficiency chips. + if (!this.setting('showCostliestMessages', false)) { + return ''; + } + const top = this.costliestMessages || []; + if (top.length === 0) { + return ''; + } + const t = I18n.t.popup; + const detailRow = (label: string, value: string): string => + '
' + label + '' + value + '
'; + // Bold row for the two signals that explain a costly turn. + const detailRowBold = (label: string, value: string): string => + '
' + label + '' + value + '
'; + // Human gap since the previous turn; "New conversation" for a session's first. + const fmtGap = (ms: number | undefined): string => { + if (ms === undefined) { + return 'New conversation'; + } + const m = Math.round(ms / 60000); + if (m < 60) { + return m + 'm'; + } + const h = Math.floor(m / 60); + return h < 24 ? h + 'h ' + (m % 60) + 'm' : (h / 24).toFixed(1) + 'd'; + }; + // Which token type drove the cost? Names the reason a turn was expensive so + // the prompt alone isn't the only signal Carl has to go on. + const driverOf = (m: CostlyMessage): string => { + const parts: { key: string; v: number }[] = [ + { key: 'cache writes (miss)', v: m.costCacheWrite }, + { key: 'output', v: m.costOutput }, + { key: 'uncached input', v: m.costInput }, + { key: 'cache reads', v: m.costCacheRead }, + ]; + parts.sort((a, b) => b.v - a.v); + const share = m.cost > 0 ? Math.round((parts[0].v / m.cost) * 100) : 0; + return `${parts[0].key} (${share}% of cost)`; + }; + let rows = ''; + top.forEach((m, i) => { + const tokens = m.inputTokens + m.outputTokens + m.cacheCreationTokens + m.cacheReadTokens; + const inputSide = m.inputTokens + m.cacheCreationTokens + m.cacheReadTokens; + const hitRate = inputSide > 0 ? m.cacheReadTokens / inputSide : 0; + const promptText = m.prompt && m.prompt.trim() ? m.prompt.trim() : '(no prompt captured)'; + const promptShort = promptText.length > 80 ? promptText.slice(0, 80) + '…' : promptText; + const truncated = !!(m.prompt && m.prompt.length >= 4000); + const skillLabel = m.skill ? (m.plugin ? m.skill + ' · ' + m.plugin : m.skill) : '—'; + // Cost split — the key to telling a cache miss from a long answer. + const costSplit = + 'write ' + I18n.formatCurrency(m.costCacheWrite) + + ' · read ' + I18n.formatCurrency(m.costCacheRead) + + ' · out ' + I18n.formatCurrency(m.costOutput) + + ' · in ' + I18n.formatCurrency(m.costInput); + rows += + '
' + + '' + (i + 1) + '' + + '' + this.escapeHtml(promptShort) + '' + + '' + I18n.formatCurrency(m.cost) + '' + + '
' + + (m.prompt + ? '
' + this.escapeHtml(promptText) + (truncated ? '\n… (truncated)' : '') + '
' + : '') + + detailRow('Main cost driver', this.escapeHtml(driverOf(m))) + + detailRowBold('Cache-hit rate', this.formatPercent(hitRate)) + + detailRowBold('Time since last turn', this.escapeHtml(fmtGap(m.gapMs)) + + (m.gapMs !== undefined && m.gapMs > 5 * 60000 ? ' ⚠ past cache TTL' : '')) + + // Name the likely cache-miss cause when the hit rate is low. + (hitRate < 0.2 && m.prevModel && m.prevModel !== m.model + ? detailRowBold('Likely cause', 'model switch (' + this.escapeHtml(this.shortModelName(m.prevModel)) + ' → ' + this.escapeHtml(this.shortModelName(m.model)) + ') flushed the cache') + : hitRate < 0.2 && m.gapMs !== undefined && m.gapMs > 5 * 60000 + ? detailRowBold('Likely cause', 'idle ' + this.escapeHtml(fmtGap(m.gapMs)) + ' — cache went cold') + : '') + + detailRow('Cost split', this.escapeHtml(costSplit)) + + detailRow(t.model, this.escapeHtml(m.model)) + + detailRow('Skill', this.escapeHtml(skillLabel)) + + detailRow(t.totalTokens, I18n.formatNumber(tokens)) + + detailRow(t.inputTokens, I18n.formatNumber(m.inputTokens)) + + detailRow(t.outputTokens, I18n.formatNumber(m.outputTokens)) + + detailRow(t.cacheCreation, I18n.formatNumber(m.cacheCreationTokens)) + + detailRow(t.cacheRead, I18n.formatNumber(m.cacheReadTokens)) + + detailRow(t.project, this.escapeHtml(m.projectName || '—')) + + '
'; + }); + return ( + '

Top 10 costliest messages

' + + '

Single responses ranked by cost. Expand for the prompt and a cost split — a high "cache writes (miss)" share means the context was re-sent uncached, not that the answer was long.

' + + rows + + '
' + ); + } + private renderContentData(): string { const t = I18n.t.popup; const topCards = this.renderAdviceCard() + this.renderOptimizerCard(); @@ -2307,7 +3128,7 @@ export class UsageWebviewProvider { * Static stacked-bar chart breaking each period into input / cache-read / * cache-write / output tokens — a finer view than the single-metric chart. */ - private renderCompositionChart(items: { label: string; data: UsageData }[]): string { + private renderCompositionChart(items: { label: string; data: UsageData; key?: string }[]): string { if (!items || items.length === 0) { return ''; } @@ -2339,8 +3160,15 @@ export class UsageWebviewProvider { '">' ); }; + // In the all-time view each bar is a month (it.key = its date); make it + // click-to-expand that month's per-day composition (reuses the table's + // month-detail round-trip). + const colOpen = it.key + ? '
' + : '
'; bars += - '
' + + colOpen + '
open across auto-refresh re-renders — a +// data refresh shouldn't collapse something you're reading. Only a tab switch +// resets to the default (see clearPersistedDetails in showTab). +function savePersistedDetails() { + try { + var open = []; + document.querySelectorAll('details[data-persist]').forEach(function(d){ + if (d.open) { open.push(d.getAttribute('data-persist')); } + }); + var st = vscode.getState() || {}; + st.openDetails = open; + vscode.setState(st); + } catch (e) {} +} +function restorePersistedDetails() { + try { + var st = vscode.getState() || {}; + var open = st.openDetails || []; + if (!open.length) { return; } + document.querySelectorAll('details[data-persist]').forEach(function(d){ + if (open.indexOf(d.getAttribute('data-persist')) !== -1) { d.open = true; } + }); + } catch (e) {} +} +function clearPersistedDetails() { + try { var st = vscode.getState() || {}; st.openDetails = []; vscode.setState(st); } catch (e) {} +} +// 'toggle' doesn't bubble — listen in the capture phase. +document.addEventListener('toggle', function(e){ + if (e.target && e.target.matches && e.target.matches('details[data-persist]')) { savePersistedDetails(); } +}, true); + // Restore the active tab from localStorage before first paint, so a reload can't change it. function restoreActiveTab() { try { @@ -3991,7 +5155,7 @@ function restoreActiveTab() { } } catch (e) {} } -function restoreUi() { restoreActiveTab(); restoreSessionFilter(); } +function restoreUi() { restoreActiveTab(); restoreSessionFilter(); restorePersistedDetails(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', restoreUi); } else { @@ -4010,6 +5174,44 @@ const __dateOpts = (extra) => { }; // Define basic functions +function scReadConfig() { + var rangeEl = document.getElementById('scRange'); + var scopeEl = document.getElementById('scScope'); + var sections = {}; + var boxes = document.querySelectorAll('.sc-sec'); + for (var i = 0; i < boxes.length; i++) { + sections[boxes[i].getAttribute('data-sec')] = boxes[i].checked; + } + var avatarEl = document.getElementById('scAvatar'); + var nameEl = document.getElementById('scName'); + var fullEl = document.getElementById('scFull'); + var themeEl = document.getElementById('scTheme'); + return { + range: rangeEl ? rangeEl.value : 'last30', + scope: scopeEl ? scopeEl.value : 'all', + theme: themeEl ? themeEl.value : 'claudeCream', + avatar: avatarEl ? avatarEl.checked : false, + username: nameEl ? nameEl.checked : false, + fullNumbers: fullEl ? fullEl.checked : false, + sections: sections + }; +} +function generateShareCard() { + var cfg = scReadConfig(); + var prev = document.getElementById('scPreview'); + if (prev) { prev.innerHTML = '

Generating…

'; } + vscode.postMessage({ command: 'buildShareCard', range: cfg.range, scope: cfg.scope, theme: cfg.theme, avatar: cfg.avatar, username: cfg.username, fullNumbers: cfg.fullNumbers, sections: cfg.sections }); +} +function exportShareCardConfigured() { + var cfg = scReadConfig(); + vscode.postMessage({ command: 'exportShareCard', range: cfg.range, scope: cfg.scope, theme: cfg.theme, avatar: cfg.avatar, username: cfg.username, fullNumbers: cfg.fullNumbers, sections: cfg.sections }); +} +function exportHeatmap() { + vscode.postMessage({ command: 'exportHeatmap' }); +} +function publishHeatmap() { + vscode.postMessage({ command: 'publishHeatmap' }); +} function refresh() { console.log("[DEBUG] refresh called"); vscode.postMessage({ command: 'refresh' }); @@ -4178,6 +5380,18 @@ function deleteSession(btn) { }); } +function viewConversation(btn) { + if (!btn) { return; } + var id = btn.getAttribute('data-session-id') || ''; + if (!id) { return; } + // Host reads the .jsonl, parses it, and opens a read-only viewer panel. + vscode.postMessage({ + command: 'viewConversation', + sessionId: id, + title: btn.getAttribute('data-title') || '' + }); +} + function applySessionFilter(mode) { var list = document.getElementById('sessionList'); if (!list) { return; } @@ -4327,6 +5541,8 @@ function showTab(tabName) { console.log("[DEBUG] Tab switched successfully to:", tabName); vscode.postMessage({ command: 'tabChanged', tab: tabName }); + // Switching tabs resets expanded rows to their default (Carl's rule). + clearPersistedDetails(); // Persist in localStorage too — survives the reload, restored before paint. try { localStorage.setItem('ccu.activeTab', tabName); } catch (e) {} } else { @@ -4577,6 +5793,15 @@ window.closeAllMonthlyDetails = closeAllMonthlyDetails; window.addEventListener('message', function(event) { const message = event.data; + if (message.command === 'shareCardResult') { + const prev = document.getElementById('scPreview'); + if (prev) { + prev.innerHTML = message.error + ? '

Could not build the card: ' + message.error + '

' + : (message.svg || ''); + } + } + if (message.command === 'hourlyDataResponse') { const container = document.getElementById('hourly-detail-' + message.date); if (container && message.data) { @@ -4880,6 +6105,52 @@ function formatValue(value, metric) { } } +// Cache hit rate for a drill-down row: cacheRead / (input + cacheWrite + cacheRead). +function cacheHitPct(d) { + var den = d.totalInputTokens + d.totalCacheCreationTokens + d.totalCacheReadTokens; + return den > 0 ? (d.totalCacheReadTokens / den * 100).toFixed(0) + '%' : '-'; +} + +// Client-side stacked token-composition chart (mirrors the server-rendered +// renderCompositionChart, same CSS classes). Used when a month is expanded to +// show its per-day composition. items: [{ label, data }]. +function compositionHtml(items) { + if (!items || !items.length) { return ''; } + var maxH = 120; + var totals = items.map(function(it) { + var d = it.data; + return d.totalInputTokens + d.totalOutputTokens + d.totalCacheCreationTokens + d.totalCacheReadTokens; + }); + var maxTotal = Math.max.apply(null, totals.concat([1])); + var fmt = function(n) { return n.toLocaleString(__locale); }; + var bars = items.map(function(it, idx) { + var d = it.data, total = totals[idx], barH = (total / maxTotal) * maxH; + var seg = function(v, cls, label) { + var h = total > 0 ? (v / total) * barH : 0; + return '
'; + }; + return '
' + + seg(d.totalInputTokens, 'seg-input', '${I18n.t.popup.inputTokens}') + + seg(d.totalCacheReadTokens, 'seg-cache-read', '${I18n.t.popup.cacheRead}') + + seg(d.totalCacheCreationTokens, 'seg-cache-creation', '${I18n.t.popup.cacheCreation}') + + seg(d.totalOutputTokens, 'seg-output', '${I18n.t.popup.outputTokens}') + + '
'; + }).join(''); + var xlabels = items.map(function(it) { return '
' + it.label + '
'; }).join(''); + var dot = function(cls, label) { return '' + label + ''; }; + return '

${I18n.t.popup.tokenComposition}

' + + '
' + + dot('seg-input', '${I18n.t.popup.inputTokens}') + dot('seg-cache-read', '${I18n.t.popup.cacheRead}') + + dot('seg-cache-creation', '${I18n.t.popup.cacheCreation}') + dot('seg-output', '${I18n.t.popup.outputTokens}') + + '
' + + '
' + + '' + fmt(maxTotal) + '' + fmt(Math.round(maxTotal / 2)) + '0' + + '
' + + '
' + + '
' + bars + '
' + + '
' + xlabels + '
'; +} + function renderHourlyData(hourlyData, date) { if (!hourlyData || hourlyData.length === 0) { return '
${I18n.t.popup.noDataMessage}
'; @@ -4910,6 +6181,7 @@ function renderHourlyData(hourlyData, date) { html += '
'; html += ''; html += ''; + html += ''; html += ''; html += ''; @@ -4921,6 +6193,7 @@ function renderHourlyData(hourlyData, date) { html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; }); @@ -4953,6 +6226,15 @@ function renderDailyData(dailyData, monthDate) { html += renderDailyChart(dailyData, 'cost'); html += ''; + // Per-day token composition for this month (the drill-down of the all-time + // monthly composition the user clicked). + html += compositionHtml(dailyData.map(function(item) { + return { + label: new Date(item.date).toLocaleDateString(__locale, __dateOpts({ month: 'numeric', day: 'numeric' })), + data: item.data + }; + })); + html += '
${I18n.t.popup.outputTokens} ${I18n.t.popup.cacheCreation} ${I18n.t.popup.cacheRead}${I18n.t.popup.cacheHitRate} ${I18n.t.popup.messages}
${I18n.formatNumber(data.totalOutputTokens)} ${I18n.formatNumber(data.totalCacheCreationTokens)} ${I18n.formatNumber(data.totalCacheReadTokens)}${this.formatPercent(this.cacheHitRate(data))} ${I18n.formatNumber(data.messageCount)}
${I18n.formatNumber(data.totalOutputTokens)} ${I18n.formatNumber(data.totalCacheCreationTokens)} ${I18n.formatNumber(data.totalCacheReadTokens)}${this.formatPercent(this.cacheHitRate(data))} ${I18n.formatNumber(data.messageCount)}
' + @@ -1341,20 +1770,32 @@ export class UsageWebviewProvider { '' + I18n.formatNumber(d.totalCacheReadTokens) + '' + I18n.formatNumber(s.peakContextTokens) + '' + - (thinkingShare !== null ? this.formatPercent(thinkingShare) + (thinkingShare > 0.6 ? ' ⚠' : '') : '-') + + this.escapeHtml(thinkingCell) + '' + I18n.formatNumber(d.messageCount) + '' + + this.escapeHtml(this.formatDurationMs(activeMap[s.sessionId] || 0)) + + '' + this.escapeHtml(this.formatDuration(s.startTime, s.endTime)) + '' + '' + - '' + - '' + + (showViewer + ? '' + : '') + + (showActions + ? '' + : '') + + (showActions + ? '' + : '') + '
' + t.activeDuration + '' + t.sessionActions + '
' + + (d.messageCount > 0 ? I18n.formatCurrency(d.totalCost / d.messageCount) : '—') + + '' + I18n.formatCurrency(d.totalCost) + '' + I18n.formatNumber(d.totalOutputTokens) + '' + I18n.formatNumber(d.totalCacheCreationTokens) + '' + I18n.formatNumber(d.totalCacheReadTokens) + '' + I18n.formatNumber(d.messageCount) + '' + I18n.formatNumber(d.messageCount) + 'Cost/msg
${I18n.t.popup.outputTokens}${I18n.t.popup.cacheCreation}${I18n.t.popup.cacheRead}${I18n.t.popup.cacheHitRate}${I18n.t.popup.messages}
' + item.data.totalOutputTokens.toLocaleString(__locale) + '' + item.data.totalCacheCreationTokens.toLocaleString(__locale) + '' + item.data.totalCacheReadTokens.toLocaleString(__locale) + '' + cacheHitPct(item.data) + '' + item.data.messageCount.toLocaleString(__locale) + '
'; html += ''; html += ''; @@ -4960,6 +6242,7 @@ function renderDailyData(dailyData, monthDate) { html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; @@ -4974,6 +6257,7 @@ function renderDailyData(dailyData, monthDate) { html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; }); From 0b4b7501d081ef5de622bffa19002efbc2180429 Mon Sep 17 00:00:00 2001 From: Carl <74130028+Carl723000@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:34:01 +0800 Subject: [PATCH 12/12] fix: refresh status bar when a window regains focus (#55) (#69) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each VS Code window is a separate extension host, and Electron throttles both timers and fs.watch delivery in unfocused/hidden windows — so with several windows open, only the one you last worked in keeps its status bar current; the others lag or stop updating. Now, when a window regains focus, we force an immediate refresh and re-arm the timer + file watcher, so a background window catches up at once instead of after the next (throttled) tick — or never. --- src/extension.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 5e08f534..8d050bf1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -125,6 +125,7 @@ export class ClaudeCodeUsageExtension { this.startAutoRefresh(); this.refreshData().then(() => this.startFileWatching()); this.startCredentialsWatching(); + this.startWindowFocusRefresh(); this.maybeAnnounceWhatsNew(); console.log('Claude Code Usage Extension: Initialization complete'); } @@ -900,6 +901,26 @@ export class ClaudeCodeUsageExtension { return Date.now() - this.lastActivityAt < 60000; } + /** Refresh when this window regains focus (#55). Each VS Code window is a + * separate extension host; Electron heavily throttles timers AND fs.watch + * delivery in unfocused/hidden windows, so a background window's status bar + * goes stale (only the window you last worked in keeps up). On focus we force + * an immediate refresh and re-arm the timer + watcher so the window catches up + * at once instead of after the next (throttled) tick — or never. */ + private startWindowFocusRefresh(): void { + let wasFocused = vscode.window.state.focused; + this.context.subscriptions.push( + vscode.window.onDidChangeWindowState((state) => { + if (state.focused && !wasFocused) { + this.startAutoRefresh(); // reset a cadence that may have been throttled + this.startFileWatching(); // re-attach the watcher if it was dropped + this.refreshData(); // catch up now (updates the status bar immediately) + } + wasFocused = state.focused; + }) + ); + } + private startAutoRefresh(): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer);
${I18n.t.popup.date}${I18n.t.popup.cost}${I18n.t.popup.outputTokens}${I18n.t.popup.cacheCreation}${I18n.t.popup.cacheRead}${I18n.t.popup.cacheHitRate}${I18n.t.popup.messages}
' + item.data.totalOutputTokens.toLocaleString(__locale) + '' + item.data.totalCacheCreationTokens.toLocaleString(__locale) + '' + item.data.totalCacheReadTokens.toLocaleString(__locale) + '' + cacheHitPct(item.data) + '' + item.data.messageCount.toLocaleString(__locale) + '