diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5279f478..68e3d61d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,7 +30,7 @@ Keep the section headings; fill what applies, delete what doesn't. - [ ] `npm run compile` is clean - [ ] User-facing strings go through `I18n` with **all eight UI locales** filled -- [ ] Behaviour/documentation changes update **all seven README editions** together +- [ ] Behaviour/documentation changes update **all nine README files** together (main page and eight locale editions) - [ ] `CHANGELOG.md` updated (user-visible changes) - [ ] `README.md` updated if behaviour/settings changed - [ ] New settings default to existing behaviour (opt-in) diff --git a/.github/scripts/automationPolicy.test.mjs b/.github/scripts/automationPolicy.test.mjs index 176e123e..2031643d 100644 --- a/.github/scripts/automationPolicy.test.mjs +++ b/.github/scripts/automationPolicy.test.mjs @@ -127,13 +127,35 @@ test('release delivery retries safely and does not let one registry block the ot assert.doesNotMatch(workflow, /if gh release download/); assert.equal((workflow.match(/steps\.restore_package\.outputs\.restored != 'true'/g) ?? []).length, 4); assert.match(workflow, /tag_name: \$\{\{ env\.RELEASE_TAG \}\}/); - assert.match(workflow, /@vscode\/vsce@3\.9\.2 publish[\s\S]*?--skip-duplicate/); + assert.match(workflow, /@vscode\/vsce@4\.0\.0 publish[\s\S]*?--skip-duplicate/); assert.match(workflow, /ovsx@1\.2\.0 publish[\s\S]*?--skip-duplicate/); assert.equal((workflow.match(/continue-on-error: true/g) ?? []).length, 3); assert.equal((workflow.match(/for attempt in 1 2 3/g) ?? []).length, 2); assert.equal((workflow.match(/timeout-minutes: 12/g) ?? []).length, 2); }); +test('Marketplace recovery reconciles the newest stable release without rebuilding it', () => { + const publish = read('.github/workflows/publish.yml'); + const recovery = read('.github/workflows/marketplace-recovery.yml'); + + assert.match(publish, /concurrency:\s*\n\s+group: extension-registry-delivery/); + assert.match(recovery, /schedule:[\s\S]*?- cron: '37 \* \* \* \*'/); + assert.match(recovery, /workflow_dispatch:/); + assert.match(recovery, /permissions:\s*\n\s+contents: read/); + assert.match(recovery, /concurrency:\s*\n\s+group: extension-registry-delivery/); + assert.match(recovery, /releases\/latest/); + assert.match(recovery, /gh release download[\s\S]*?claude-code-usage\.vsix/); + assert.match(recovery, /verify-vsix\.mjs[\s\S]*?steps\.release\.outputs\.version/); + assert.doesNotMatch(recovery, /npm ci|npm run compile|vsce@4\.0\.0 package/); + assert.match(recovery, /vsce@4\.0\.0 show GrowthJack\.claude-code-usage --json/); + assert.match(recovery, /Microsoft\.VisualStudio\.Services\.VsixSha256/); + assert.match(recovery, /sha256sum claude-code-usage\.vsix/); + assert.match(recovery, /steps\.probe\.outputs\.needed == 'true'/); + assert.match(recovery, /vsce@4\.0\.0 publish[\s\S]*?--skip-duplicate/); + assert.match(recovery, /continue-on-error: true/); + assert.match(recovery, /recovery will retry next hour/i); +}); + test('release draft gets a post-merge reconciliation pass', () => { const workflow = read('.github/workflows/release-drafter.yml'); assert.match( diff --git a/.github/scripts/first-pass-lib.mjs b/.github/scripts/first-pass-lib.mjs index b121bce1..0d7f4c8c 100644 --- a/.github/scripts/first-pass-lib.mjs +++ b/.github/scripts/first-pass-lib.mjs @@ -387,11 +387,17 @@ export function hasTrustedFirstPass(comments, kind) { ); } -export async function resolveFirstPassCandidates({ cheap, pro }) { +export async function resolveFirstPassCandidates({ cheap, pro, onFailure = () => {} }) { let cheapCandidate; try { cheapCandidate = await cheap(); - } catch { + if (!String(cheapCandidate?.reply || '').trim()) { + onFailure({ tier: 'cheap', reason: 'empty-reply' }); + } else if (cheapCandidate.answerable === false) { + onFailure({ tier: 'cheap', reason: 'needs-source' }); + } + } catch (error) { + onFailure({ tier: 'cheap', reason: safeFailureReason(error) }); cheapCandidate = null; } @@ -399,7 +405,11 @@ export async function resolveFirstPassCandidates({ cheap, pro }) { if (!cheapCandidate || needsProFirstPass(cheapCandidate)) { try { proCandidate = await pro(cheapCandidate); - } catch { + if (!String(proCandidate?.reply || '').trim()) { + onFailure({ tier: 'pro', reason: 'empty-reply' }); + } + } catch (error) { + onFailure({ tier: 'pro', reason: safeFailureReason(error) }); proCandidate = null; } } @@ -411,11 +421,18 @@ export async function resolveFirstPassCandidates({ cheap, pro }) { } } +function safeFailureReason(error) { + const status = error?.status; + return Number.isInteger(status) && status >= 400 && status <= 599 + ? `http-${status}` + : 'request-error'; +} + export function chooseFinalReply(cheap, pro) { if (pro && typeof pro.reply === 'string' && pro.reply.trim() !== '') { return { reply: pro.reply.trim(), generator: pro.generator }; } - if (cheap && typeof cheap.reply === 'string' && cheap.reply.trim() !== '') { + if (cheap?.answerable !== false && typeof cheap?.reply === 'string' && cheap.reply.trim() !== '') { return { reply: cheap.reply.trim(), generator: cheap.generator }; } throw new Error('No non-empty model reply'); diff --git a/.github/scripts/first-pass.mjs b/.github/scripts/first-pass.mjs index 9fddcf0d..ebf20237 100644 --- a/.github/scripts/first-pass.mjs +++ b/.github/scripts/first-pass.mjs @@ -27,7 +27,7 @@ const REPO_ROOT = resolve(process.cwd()); const env = process.env; const base = (env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com').replace(/\/$/, ''); const githubApi = (env.GITHUB_API_URL || 'https://api.github.com').replace(/\/$/, ''); -const model = env.CCU_BOT_MODEL || 'deepseek-v4-flash'; +const model = env.CCU_BOT_MODEL || 'deepseek-flash'; const modelPro = env.CCU_BOT_MODEL_PRO || 'deepseek-v4-pro'; const fail = (message) => { @@ -119,14 +119,20 @@ const buildUser = (extraFiles) => `\n\n--- PROJECT DOCS ---\n${docs}` + (extraFiles ? `\n\n--- REPO SOURCE FILES (read-only) ---\n${extraFiles}` : ''); -async function askModel(useModel, system, userText, think = false) { +async function askModel(useModel, generator, system, userText, think = false) { const body = { model: useModel, - max_tokens: 1400, + max_tokens: think ? 8192 : 1800, system, messages: [{ role: 'user', content: userText }], }; - if (think) body.thinking = { type: 'enabled', budget_tokens: 6000 }; + // DeepSeek defaults to reasoning even on the cheap tier. Reserve that tier's + // output budget for the actual reply; the pro tier may reason first. + if (generator.id === 'deepseek') { + body.thinking = { type: think ? 'enabled' : 'disabled' }; + } else if (think) { + body.thinking = { type: 'enabled', budget_tokens: 6000 }; + } let response = await fetch(`${base}/v1/messages`, { method: 'POST', @@ -151,11 +157,11 @@ async function askModel(useModel, system, userText, think = false) { body: JSON.stringify(body), }); } else { - throw new Error(`Model API error ${response.status}: ${errorText.slice(0, 500)}`); + throw Object.assign(new Error('Model API request failed'), { status: response.status }); } } if (!response.ok) { - throw new Error(`Model API error ${response.status}: ${(await response.text()).slice(0, 500)}`); + throw Object.assign(new Error('Model API request failed'), { status: response.status }); } const data = await response.json(); return (data.content || []) @@ -186,20 +192,22 @@ try { fail(`Comment lookup failed: ${error.message}`); } +const modelFailures = []; const selected = await resolveFirstPassCandidates({ cheap: async () => ({ - ...parseFirstPassResponse(await askModel(model, buildSystem(false), buildUser(''))), + ...parseFirstPassResponse(await askModel(model, cheapGenerator, buildSystem(false), buildUser(''))), generator: cheapGenerator, }), pro: async (cheapCandidate) => { const extra = repoReader.read(cheapCandidate?.want_files || []).text; return { ...parseFirstPassResponse( - await askModel(modelPro, buildSystem(true), buildUser(extra), true), + await askModel(modelPro, proGenerator, buildSystem(true), buildUser(extra), true), ), generator: proGenerator, }; }, + onFailure: (event) => modelFailures.push(event), }); let commentBody; @@ -213,11 +221,17 @@ if (selected) { fail(`First-pass formatting failed: ${error.message}`); } } else { + const summary = ['cheap', 'pro'] + .map((tier) => `${tier}=${modelFailures.find((event) => event.tier === tier)?.reason || 'no-usable-reply'}`) + .join(' '); + if (isPr) { + fail(`::error::First-pass PR review unavailable: ${summary}. No comment was posted.`); + } const authorWroteInChinese = /[\u3400-\u9fff]/u.test( `${env.ITEM_TITLE || ''}\n${env.ITEM_BODY || ''}`, ); commentBody = formatFirstPassFallback({ kind, isChinese: authorWroteInChinese }); - console.warn(`Both model tiers failed to return a usable first-pass ${kind}; posting fallback.`); + console.warn(`Both model tiers failed to return a usable first-pass ${kind} (${summary}); posting fallback.`); } try { diff --git a/.github/scripts/first-pass.test.mjs b/.github/scripts/first-pass.test.mjs index 21ac6bac..be108e5c 100644 --- a/.github/scripts/first-pass.test.mjs +++ b/.github/scripts/first-pass.test.mjs @@ -28,14 +28,18 @@ import { const ANTHROPIC_MESSAGES = 'anthropic-messages'; const REPO_ROOT = resolve(import.meta.dirname, '..', '..'); -async function runFallbackScenario(eventKind) { +async function runFallbackScenario(eventKind, modelResponse = { content: [] }) { const postedComments = []; let modelCalls = 0; + const modelRequests = []; const server = createServer(async (request, response) => { if (request.method === 'POST' && request.url === '/v1/messages') { modelCalls += 1; - response.writeHead(200, { 'content-type': 'application/json' }); - response.end(JSON.stringify({ content: [] })); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + modelRequests.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + response.writeHead(modelResponse.status || 200, { 'content-type': 'application/json' }); + response.end(JSON.stringify(modelResponse.body || modelResponse)); return; } if (request.method === 'GET' && request.url?.endsWith('/comments?per_page=100')) { @@ -90,25 +94,49 @@ async function runFallbackScenario(eventKind) { child.stdout.on('data', (chunk) => { stdout += chunk; }); child.stderr.on('data', (chunk) => { stderr += chunk; }); const exitCode = await new Promise((accept) => child.on('close', accept)); - return { exitCode, modelCalls, postedComments, stdout, stderr }; + return { exitCode, modelCalls, modelRequests, postedComments, stdout, stderr }; } finally { rmSync(fixtureRoot, { recursive: true, force: true }); await new Promise((accept) => server.close(accept)); } } -test('shared runner posts a deterministic fallback for issue and PR model failures', async () => { - for (const eventKind of ['issue', 'pr']) { - const result = await runFallbackScenario(eventKind); - assert.equal(result.exitCode, 0, result.stderr); - assert.equal(result.modelCalls, 2); - assert.equal(result.postedComments.length, 1); - assert.match( - result.postedComments[0], - new RegExp(`^🤖 Automated first-pass ${eventKind === 'issue' ? 'reply' : 'review'}`), - ); - assert.match(result.postedComments[0], /not a maintainer decision/); - } +test('issue runner retains its fallback, but failed PR review posts nothing and fails visibly', async () => { + const issue = await runFallbackScenario('issue'); + assert.equal(issue.exitCode, 0, issue.stderr); + assert.equal(issue.modelCalls, 2); + assert.match(issue.postedComments[0], /^🤖 Automated first-pass reply/); + + const pr = await runFallbackScenario('pr'); + assert.notEqual(pr.exitCode, 0); + assert.equal(pr.modelCalls, 2); + assert.equal(pr.postedComments.length, 0); + assert.match(pr.stderr, /cheap=empty-reply.*pro=empty-reply/); + assert.ok(pr.modelRequests[1].max_tokens > 6000); + assert.equal(pr.modelRequests[1].thinking?.type, 'enabled'); + assert.doesNotMatch(pr.stderr, /test-model-key|test-github-token/); +}); + +test('API failures report status and tier, never remote body or credentials', async () => { + const result = await runFallbackScenario('pr', { + status: 401, + body: { error: 'sensitive untrusted upstream response' }, + }); + assert.notEqual(result.exitCode, 0); + assert.equal(result.postedComments.length, 0); + assert.match(result.stderr, /cheap=http-401.*pro=http-401/); + assert.doesNotMatch(result.stderr, /sensitive untrusted|test-model-key|test-github-token/); +}); + +test('DeepSeek tiers have compatible output budgets and reserve the cheap tier for a reply', async () => { + const result = await runFallbackScenario('pr', { + content: [{ type: 'text', text: '{"answerable":true}Specific finding in src/a.ts.' }], + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.modelCalls, 1); + assert.equal(result.modelRequests[0].thinking?.type, 'disabled'); + assert.ok(result.modelRequests[0].max_tokens >= 1400); + assert.match(result.postedComments[0], /Specific finding in src\/a\.ts/); }); test('empty cheap replies escalate even when the control says answerable', () => { @@ -169,6 +197,20 @@ test('candidate resolution returns null instead of throwing when both tiers fail assert.equal(selected, null); }); +test('an unanswered cheap draft is not posted when the pro tier fails', async () => { + const failures = []; + const selected = await resolveFirstPassCandidates({ + cheap: async () => ({ reply: 'Need source', answerable: false }), + pro: async () => { throw new Error('secret remote text'); }, + onFailure: (event) => failures.push(event), + }); + assert.equal(selected, null); + assert.deepEqual(failures, [ + { tier: 'cheap', reason: 'needs-source' }, + { tier: 'pro', reason: 'request-error' }, + ]); +}); + test('current transport truthfully supports DeepSeek and Claude per tier', () => { const cheap = resolveGeneratorAttribution(undefined, 'deepseek-v4-flash', ANTHROPIC_MESSAGES); const pro = resolveGeneratorAttribution('claude', 'claude-sonnet-4-5', ANTHROPIC_MESSAGES); diff --git a/.github/workflows/marketplace-recovery.yml b/.github/workflows/marketplace-recovery.yml new file mode 100644 index 00000000..1dad3806 --- /dev/null +++ b/.github/workflows/marketplace-recovery.yml @@ -0,0 +1,145 @@ +# The VS Code Marketplace Gallery API can remain unavailable for several hours. +# Keep the release workflow honest (it still fails when delivery fails), then +# reconcile the newest stable GitHub Release in the background. The canonical +# Release VSIX is reused and verified; no package is rebuilt here. + +name: Recover VS Code Marketplace delivery + +on: + schedule: + # Avoid the top of the hour, when scheduled Actions and public services are + # commonly busiest. A successful no-op probe is cheap and idempotent. + - cron: '37 * * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: extension-registry-delivery + cancel-in-progress: false + +jobs: + recover: + runs-on: ubuntu-latest + steps: + - name: Checkout current release policy + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '22' + + - name: Resolve newest stable GitHub Release + id: release + run: | + TAG="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq '.tag_name')" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unexpected latest stable release tag: $TAG" >&2 + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Download canonical Release VSIX + run: | + gh release download "${{ steps.release.outputs.tag }}" \ + --pattern claude-code-usage.vsix \ + --clobber + test -s claude-code-usage.vsix + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify canonical Release VSIX + run: >- + node .github/scripts/verify-vsix.mjs + claude-code-usage.vsix + "${{ steps.release.outputs.version }}" + + - name: Probe Marketplace delivery + id: probe + run: | + set +e + npx -y @vscode/vsce@4.0.0 show GrowthJack.claude-code-usage --json \ + > marketplace-extension.json 2> marketplace-probe.err + probe_status=$? + set -e + + if [[ "$probe_status" != "0" ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + echo "needed=false" >> "$GITHUB_OUTPUT" + echo "::warning::Marketplace probe is unavailable; recovery will retry next hour." + cat marketplace-probe.err >&2 + exit 0 + fi + + echo "available=true" >> "$GITHUB_OUTPUT" + version='${{ steps.release.outputs.version }}' + if ! jq -e --arg version "$version" \ + '[.versions[]? | select(.version == $version)] | length > 0' \ + marketplace-extension.json >/dev/null; then + echo "needed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + published_sha="$(jq -r --arg version "$version" ' + [.versions[]? | select(.version == $version) | .properties[]? | + select(.key == "Microsoft.VisualStudio.Services.VsixSha256") | .value][0] // empty + ' marketplace-extension.json)" + local_sha="$(sha256sum claude-code-usage.vsix | cut -d' ' -f1)" + if [[ -z "$published_sha" ]]; then + echo "Marketplace version $version has no VSIX SHA-256 property" >&2 + exit 1 + fi + if [[ "${published_sha,,}" != "${local_sha,,}" ]]; then + echo "Marketplace VSIX SHA-256 mismatch for $version" >&2 + echo "release: $local_sha" >&2 + echo "marketplace: $published_sha" >&2 + exit 1 + fi + + echo "needed=false" >> "$GITHUB_OUTPUT" + echo "Marketplace already contains the canonical $version VSIX." + + - name: Recover missing Marketplace version + id: publish + if: steps.probe.outputs.available == 'true' && steps.probe.outputs.needed == 'true' + continue-on-error: true + timeout-minutes: 5 + run: >- + npx -y @vscode/vsce@4.0.0 publish + --packagePath claude-code-usage.vsix + --skip-duplicate + --pat "$VSCE_PAT" + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + + - name: Record recovery result + if: always() + run: | + if [[ "$PROBE_OUTCOME" != "success" ]]; then + echo "### Marketplace recovery preflight failed" >> "$GITHUB_STEP_SUMMARY" + echo "The canonical asset or integrity check failed; no publish was attempted." >> "$GITHUB_STEP_SUMMARY" + elif [[ "$PROBE_AVAILABLE" != "true" ]]; then + echo "### Marketplace recovery deferred" >> "$GITHUB_STEP_SUMMARY" + echo "The Gallery API probe was unavailable. The next hourly run will retry." >> "$GITHUB_STEP_SUMMARY" + elif [[ "$PUBLISH_NEEDED" != "true" ]]; then + echo "### Marketplace delivery is current" >> "$GITHUB_STEP_SUMMARY" + echo "The latest stable GitHub Release is already published with the expected SHA-256." >> "$GITHUB_STEP_SUMMARY" + elif [[ "$PUBLISH_OUTCOME" == "success" ]]; then + echo "### Marketplace delivery recovered" >> "$GITHUB_STEP_SUMMARY" + echo "Published $RELEASE_TAG from its canonical GitHub Release VSIX." >> "$GITHUB_STEP_SUMMARY" + else + echo "### Marketplace recovery will retry" >> "$GITHUB_STEP_SUMMARY" + echo "The publish attempt failed. The next hourly run will reuse the same verified VSIX." >> "$GITHUB_STEP_SUMMARY" + echo "::warning::Marketplace publish failed; scheduled recovery will retry next hour." + fi + env: + PROBE_OUTCOME: ${{ steps.probe.outcome }} + PROBE_AVAILABLE: ${{ steps.probe.outputs.available }} + PUBLISH_NEEDED: ${{ steps.probe.outputs.needed }} + PUBLISH_OUTCOME: ${{ steps.publish.outcome }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} diff --git a/.github/workflows/pr-first-pass.yml b/.github/workflows/pr-first-pass.yml index 8c2db602..917d0664 100644 --- a/.github/workflows/pr-first-pass.yml +++ b/.github/workflows/pr-first-pass.yml @@ -1,9 +1,9 @@ -# 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 +# Auto first-pass review on every NEW external pull request — a CONTROLLED +# cheap/pro model sequence (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, and posts one comment. Public text remains untrusted; the # fixed runner has no tools, reads only validated base-repo text, and model -# output can still be wrong. +# output can still be wrong. Failed analysis leaves no generic PR comment. # # INERT until AUTOMATION_ENABLED = 'true' + the ANTHROPIC_API_KEY secret. # Optional repo vars: ANTHROPIC_BASE_URL, CCU_BOT_MODEL / CCU_BOT_MODEL_PRO and diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d62a3384..3fb13bd3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,6 +45,10 @@ on: permissions: contents: write # needed by softprops/action-gh-release to attach the .vsix asset +concurrency: + group: extension-registry-delivery + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest @@ -115,7 +119,7 @@ jobs: - name: Package .vsix if: github.event_name == 'release' || steps.restore_package.outputs.restored != 'true' - run: npx -y @vscode/vsce@3.9.2 package --out claude-code-usage.vsix + run: npx -y @vscode/vsce@4.0.0 package --out claude-code-usage.vsix # Some historical release tags predate the package verifier. Load the # current workflow's policy only after packaging so policy files can @@ -155,7 +159,7 @@ jobs: timeout-minutes: 12 run: | for attempt in 1 2 3; do - if npx -y @vscode/vsce@3.9.2 publish --packagePath claude-code-usage.vsix --skip-duplicate --pat "$VSCE_PAT"; then + if npx -y @vscode/vsce@4.0.0 publish --packagePath claude-code-usage.vsix --skip-duplicate --pat "$VSCE_PAT"; then exit 0 fi if [[ "$attempt" == "3" ]]; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cd68a11..55796928 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,13 +70,14 @@ jobs: - name: Setup Node.js uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: - node-version: '20' + # @vscode/vsce 4 requires Node >=22. + node-version: '22' cache: 'npm' - name: Install dependencies run: npm ci - name: Compile run: npm run compile - name: Package smoke VSIX - run: npx -y @vscode/vsce@3.9.2 package --out /tmp/claude-code-usage-ci.vsix + run: npx -y @vscode/vsce@4.0.0 package --out /tmp/claude-code-usage-ci.vsix - name: Verify smoke VSIX run: node .github/scripts/verify-vsix.mjs /tmp/claude-code-usage-ci.vsix diff --git a/AGENTS.md b/AGENTS.md index 3ed71d86..fead8850 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,9 +150,9 @@ A static assertion of the first render is not acceptance evidence. - Every user-facing string goes through `I18n` with all eight UI locales: `en`, `de-DE`, `zh-TW`, `zh-CN`, `ja`, `ko`, `pt-BR`, and `id`. -- Update all seven README files together: `README.md`, `README-en.md`, - `README-zh-CN.md`, `README-zh-TW.md`, `README-ja.md`, `README-ko.md`, and - `README-id.md`. +- Update all nine README files together: `README.md`, `README-en.md`, + `README-de-DE.md`, `README-zh-CN.md`, `README-zh-TW.md`, `README-ja.md`, + `README-ko.md`, `README-pt-BR.md`, and `README-id.md`. - For every artifact submitted for maintainer review—including a spec, implementation plan, design, release checklist, policy, or substantial contributor-document change—provide a faithful Chinese sibling or review @@ -204,7 +204,7 @@ An unreviewed automated Codex first pass uses exactly: - Attribution must name the actual generator. The current first-pass runner defaults to DeepSeek and must not be relabelled Codex. - The wrapper, not model output, owns the trusted footer and emits exactly one. -- Credit Claude Code and OpenAI Codex as development tools in all seven README +- Credit Claude Code and OpenAI Codex as development tools in all nine README files. Do not put tools in Release Drafter's human contributor list. A commit mainly written by OpenAI Codex must include this exact trailer: diff --git a/AGENTS.zh-CN.md b/AGENTS.zh-CN.md index 86e2e667..38ae0bb0 100644 --- a/AGENTS.zh-CN.md +++ b/AGENTS.zh-CN.md @@ -126,8 +126,9 @@ webview 刷新为整页替换。任何新增的用户可交互状态(展开/ - 每个用户可见字符串都通过 `I18n` 覆盖八个 UI locale:`en`、`de-DE`、 `zh-TW`、`zh-CN`、`ja`、`ko`、`pt-BR`、`id`。 -- 七份 README 必须一起更新:`README.md`、`README-en.md`、`README-zh-CN.md`、 - `README-zh-TW.md`、`README-ja.md`、`README-ko.md`、`README-id.md`。 +- 九份 README 必须一起更新:`README.md`、`README-en.md`、`README-de-DE.md`、 + `README-zh-CN.md`、`README-zh-TW.md`、`README-ja.md`、`README-ko.md`、 + `README-pt-BR.md`、`README-id.md`。 - 每份交给维护者审阅的 artifact(包括 spec、implementation plan、design、 release checklist、policy 或大幅 contributor 文档变更),都提供忠实中文副本 或中文 review companion,并确保交付时中文链接排在英文链接之前。 @@ -168,7 +169,7 @@ webview 刷新为整页替换。任何新增的用户可交互状态(展开/ - 归因必须写实际 generator。当前 first-pass runner 默认使用 DeepSeek,不得改标 Codex。 - trusted footer 由 wrapper 而不是模型输出负责,并且只能出现一次。 -- 在七份 README 中把 Claude Code 与 OpenAI Codex 记为开发工具;不得把工具放入 +- 在九份 README 中把 Claude Code 与 OpenAI Codex 记为开发工具;不得把工具放入 Release Drafter 的人类 contributor 列表。主要撰写代码的 OpenAI Codex commit 必须 带以下精确 trailer: diff --git a/ARCHITECTURE-zh-CN.md b/ARCHITECTURE-zh-CN.md index 53af4e25..a42fd6a9 100644 --- a/ARCHITECTURE-zh-CN.md +++ b/ARCHITECTURE-zh-CN.md @@ -13,15 +13,19 @@ v2.3.0 保留完整 Claude 体验,并增加 provider-specific 的 Codex Beta v2.3.1 候选在不改变 provider 计量口径的前提下,增加一套默认关闭、 local-first 的建议有效性闭环、唯一的显式 BYOK 请求边界、可持久的历史任务状态、 Codex 有界的本地每周重置观测历史,以及滚动 30 天的日期→小时投影。 +v2.4 第一阶段把既有分享界面收敛为一个预览优先的工作台。呈现方式选择器可切换 +综合活动热力图、旧版 Claude 分享卡与仅 Claude 的 token 热力图;全宽导出预览始终位于控制项之前。 - Claude:精确的本地 token bucket、模型成本估算和 Anthropic OAuth 5 小时/每周配额。 -- Codex Beta:本地 processed/fresh/cache/output/reasoning 指标、模型与 effort 拆分、 +- Codex:本地 processed/fresh/cache/output/reasoning 指标、模型与 effort 拆分、 thread 结构、索引 coverage、quality flag 与结构化优化建议。已知模型还会显示明确限定的 API 等效成本估算;它绝不是账单或订阅扣费,未知模型保持未定价。 - Compare:只并列可比指标,绝不把不同 provider 的成本、配额或 token 求和为误导性总量。 -完整账单/发票对账、驱动任一 coding agent 与后台 telemetry 不在范围内。 -Opt-in GitHub 认证和跨设备聚合同步延后到 v2.4.x,届时单独做隐私审阅。 +完整账单/发票对账、驱动任一 coding agent、后台 telemetry 与跨设备聚合同步不在范围内。 +既有 Claude 热力图发布仍是用户明确触发、仅公开仓库且需精确确认目标的 GitHub 动作; +打开、切换、预览以及本地 SVG/Markdown 导出严格不联网,也不请求 GitHub 身份; +只有独立的发布动作可以执行该网络操作。 ## 模块地图(`src/`) @@ -50,7 +54,7 @@ Opt-in GitHub 认证和跨设备聚合同步延后到 v2.4.x,届时单独做 | `codexView.ts` / `codexViewComponents.ts` | Codex 本地化文案与默认 provider contract;不负责 HTML renderer、client script 或 CSS。 | | `settings.ts` | 权威 `SETTINGS` catalog 和 `SettingsStore`;普通值进入 configuration/globalState,BYOK 凭证只进入 SecretStorage,且绝不进入 Webview snapshot。不得散落直接读取。 | | `statusBar.ts` / `codexStatus.ts` | Provider-specific 状态展示和通用 Claude 配额格式化。 | -| `webview.ts` | Claude/Codex 唯一一套 provider-aware dashboard shell、共享 render function、共享 client 行为、provider tab 与 Compare 展示。 | +| `webview.ts` | Claude/Codex 唯一一套 provider-aware dashboard shell、共享 render function、共享 client 行为、provider tab、Compare 展示与统一分享工作台。 | | `i18n.ts` | 八个 UI locale 的全部用户可见文案。 | | `types.ts` | 共享 extension 和 Claude contract。 | @@ -80,6 +84,12 @@ Claude JSONL Claude aggregate + Codex scope ──> 同一套 `webview.ts` dashboard render stack Claude aggregate + Codex scope ──> 并列 Compare(不跨 provider 求和) +已物化 Claude + Codex 每日聚合 + ──> 统一分享选择器 ──> 综合活动预览 ──> 显式本地 SVG/Markdown 导出 +已物化 Claude 聚合 + ──> 统一分享选择器 ──> 旧版分享卡或 Claude token 热力图预览 + ──> 显式本地导出,或仅公开仓库且精确确认目标的 Claude 热力图 GitHub 发布 + 已物化 provider snapshot ──> 隐私重建型 advice adapter ──> 本地证据 + 确定性建议 @@ -107,6 +117,24 @@ Provider panel 的实时 patch 会保留页面 anchor,以及标签栏、图表 驻留内存的 privacy-safe 结构 key;不会逐帧写入 Webview state,也不会发送给 Extension Host。Compare 显示的更新时间绑定到稳定的已渲染数据 snapshot,因此 数据未变化的 refresh 保持 byte-identical,不会替换整份文档。 +用户连续滚动时,Webview 会将 provider panel 的更新合并到 120 ms 的静默间隔, +并以 500 ms 为最长等待边界;只应用并确认最新修订。队列只驻留内存,既不重读 +源日志,也不逐帧持久化滚动位置。 + +分享工作台只渲染一次:两个 provider 都有数据时位于「对比」,否则作为 Claude「所有」的 +回退入口。`enableShareCard` 是唯一可见的分享开关。公共命令 ID `exportShareCard`、 +`exportHeatmap` 与 `publishHeatmapToGitHub` 至少保留一个版本并打开对应呈现方式,不绕过预览。 +退役的 `showHeatmap` 仍保留在 catalog 中并可被清除,但隐藏且不再创建重复面板。所选呈现方式 +只是本地 UI 偏好,不改变 provider 统计口径。显式兼容命令会生成 provider 生命周期内单调递增的 +revision,并把它作为独立的 live intent;Webview 应用后 ACK 精确 revision,host 随即删除 live intent, +但不回退 revision 历史。因此,已确认的命令不会在普通重绘、分享重置或同一 provider 的 +dispose/reopen 后重放。「重置分享偏好」复用既有的确认式本地数据动作:host 发送带 request-id 的 +client action,Webview 验证八个 allowlist localStorage key 均已删除;只有匹配 ACK 为 true 才算成功, +否则 host 报错并保留恢复 tombstone。 + +在窄 Webview 中,1200×680 Claude 分享卡在可键盘聚焦的局部横向滚动区内保留原始宽度, +不缩小到无法阅读。Renderer 测试逐一审计所有普通字号 SVG 标签,保证至少 4.5:1;浏览器 geometry +测试保证标签不越出各自 viewBox,且完整产物仍包含在外围 Axe 扫描中。 ## Token 与 limit 语义 @@ -166,6 +194,12 @@ watcher/coalescing 计数,以及操作系统未提供 filename 的 quota watch diagnostics 还会记录实际 refresh trigger、watcher/debounce 计数、历史 backfill 模式与 foreground/background worker profile;通用失败路径无法确认模式时明确记为 `unknown`,不作猜测。 +分享预览只消费已经物化的聚合。综合活动仅在明确的活动可视化中把 Claude processed 与 +Codex processed 相加;Codex cached input 与 reasoning 子集不会重复计入,也不声称成本、额度、 +能力或生产率等价。Claude 分享卡与 Claude 热力图仍只属于 Claude。本地预览/导出不联网; +也不存在 GitHub authentication、profile、头像或名称查询。Claude 热力图发布是独立的显式动作, +继续遵守本地数据契约中的公开仓库探测,以及精确 branch/path 与创建/覆盖确认边界。 + ### Schema 3 索引契约 内部 schema 3 继续使用既有的 `globalStorage` 文件名 `codex-index-v1.json`;文件名是兼容路径, @@ -307,7 +341,7 @@ v2.3.0 不推断 20、100 或 200 美元的订阅档位。本地 Codex 日志没 - 根据变更风险执行 strict TypeScript、red-green TDD、完整 `node:test`、F5 smoke test 和安装 VSIX smoke test。 - 用户可见字符串覆盖 `en`、`de-DE`、`zh-TW`、`zh-CN`、`ja`、`ko`、`pt-BR`、`id`; - 七份 README 同步。 + 九份 README(首页加八份语言版)同步。 - dormant 的 preparation/experiment 模块与仅供评审的 v2.3.1 文档必须保持不可从生产 command graph 到达,并排除出 VSIX。 - 不手工修改 `package.json` 版本。发布已审阅的 Release Drafter draft 后才创建 tag, publish workflow 再从 tag 写入包版本。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index eb876015..22db89e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,10 +17,14 @@ The v2.3.1 candidate adds a default-off, local-first advice-effectiveness loop, a single explicit BYOK request boundary, durable historical-work state, a bounded local history of Codex weekly reset observations, and a rolling 30-day Codex date/hour projection without changing provider accounting. +v2.4 phase 1 consolidates the existing sharing surfaces into one preview-first +workspace. A presentation selector switches among the combined activity +heatmap, the legacy Claude Share Card, and the Claude-only token heatmap; the +full-width export preview always precedes its controls. - Claude: exact local token buckets, model pricing estimates, and Anthropic OAuth 5-hour/weekly quota. -- Codex Beta: local processed/fresh/cache/output/reasoning metrics, model and +- Codex: local processed/fresh/cache/output/reasoning metrics, model and effort breakdowns, thread structure, index coverage, quality flags, and structural optimization guidance. Known models also receive a clearly qualified API-equivalent cost estimate; it is never a bill or subscription @@ -28,9 +32,12 @@ Codex date/hour projection without changing provider accounting. - Compare: side-by-side compatible metrics only. It never sums provider cost, quota, or tokens into a misleading combined total. -Full invoice reconciliation, driving either coding agent, and background -telemetry are out of scope. Opt-in GitHub authentication and cross-device -aggregate sync are deferred to v2.4.x after a separate privacy review. +Full invoice reconciliation, driving either coding agent, background telemetry, +and cross-device aggregate sync are out of scope. Existing Claude heatmap +publication remains an explicit, public-repository-only GitHub action with an +exact-target confirmation. Opening, switching, previewing, and local SVG/ +Markdown export are strictly network-free and never request GitHub identity; +only the separate publish action can perform that network operation. ## Module map (`src/`) @@ -59,7 +66,7 @@ aggregate sync are deferred to v2.4.x after a separate privacy review. | `codexView.ts` / `codexViewComponents.ts` | Codex localized-copy and default-provider contracts; no HTML renderer, client script, or CSS ownership. | | `settings.ts` | Canonical `SETTINGS` catalog and `SettingsStore`; ordinary values use configuration/globalState, while BYOK credentials use SecretStorage and never enter Webview snapshots. Do not scatter direct reads. | | `statusBar.ts` / `codexStatus.ts` | Provider-specific status presentation and generic Claude quota formatting. | -| `webview.ts` | Single provider-aware Claude/Codex dashboard shell, shared render functions, shared client behavior, provider tabs, and Compare presentation. | +| `webview.ts` | Single provider-aware Claude/Codex dashboard shell, shared render functions, shared client behavior, provider tabs, Compare presentation, and the unified sharing workspace. | | `i18n.ts` | All user-facing copy for all eight UI locales. | | `types.ts` | Shared extension and Claude contracts. | @@ -90,6 +97,12 @@ allowlisted Codex JSONL Claude aggregates + Codex scopes ──> one `webview.ts` dashboard render stack Claude aggregates + Codex scopes ──> side-by-side Compare (no cross-provider totals) +materialized Claude + Codex daily aggregates + ──> unified sharing selector ──> combined activity preview ──> explicit local SVG/Markdown export +materialized Claude aggregates + ──> unified sharing selector ──> legacy Share Card or Claude token heatmap preview + ──> explicit local export, or public-only exact-target GitHub publication for the Claude heatmap + materialized provider snapshots ──> privacy-rebuilding advice adapters ──> local evidence + deterministic recommendations @@ -129,6 +142,34 @@ privacy-safe structural keys held only for the in-flight patch; they are not written per frame to Webview state or sent to the Extension Host. Compare's displayed update time is tied to its stable rendered data snapshot, so an unchanged refresh remains byte-identical and does not replace the document. +While the user is actively scrolling, the Webview coalesces provider-panel +patches until a 120 ms quiet interval, with a 500 ms upper bound. The newest +revision alone is applied and acknowledged; the live data queue stays in +memory and never reads source logs or persists scroll state per frame. + +The sharing workspace is rendered once: in Compare when both providers have +data, otherwise as a Claude All-time fallback. `enableShareCard` is the only +visible sharing on/off control. The public command IDs `exportShareCard`, +`exportHeatmap`, and `publishHeatmapToGitHub` remain registered for compatibility +and open the matching presentation; they do not bypass preview. The retired +`showHeatmap` setting remains catalogued and clearable for one release but is +hidden and no longer creates a duplicate panel. The selected presentation is a +local UI preference and does not alter provider accounting. An explicit +compatibility command issues a provider-lifetime monotonically increasing +revision as a separate live intent. The Webview applies it and acknowledges the +exact revision; the host then removes the live intent without rewinding the +revision history, so an acknowledged command cannot replay on a normal render, +sharing reset, or dispose/reopen of the same provider. Reset Sharing +Preferences uses the existing confirmed local-data action: the host sends a +request-id client action, the Webview verifies deletion of the eight allowlisted +localStorage keys, and the host reports failure (with its recovery tombstone) +unless the matching ACK is true. + +At narrow Webview widths, the 1200×680 Claude Share Card retains its intrinsic +width inside a keyboard-focusable local horizontal scroller instead of being +scaled into illegibility. Renderer tests audit every normal-size SVG label at +4.5:1 or better, while browser geometry tests keep labels inside each viewBox; +the complete artifact remains inside the surrounding Axe scan. ## Token and limit semantics @@ -205,6 +246,18 @@ the actual refresh trigger, watcher/debounce counts, historical-backfill mode, and foreground/background worker profile; generic failure paths use `unknown` rather than infer a mode that was not observed. +Sharing previews are produced from already materialized aggregates. Combined +activity adds Claude processed volume to Codex processed volume only for the +explicit activity visualization; Codex cached input and reasoning subsets are +not added twice, and no cost, quota, capability, or productivity equivalence is +claimed. The Claude Share Card and Claude heatmap remain Claude-only. Local +preview/export performs no network request and has no GitHub authentication, +profile, avatar, or name lookup. Claude heatmap publication is a distinct +explicit action and keeps the public-repository probe plus exact branch/path and +create-or-overwrite confirmation described in the local data contract. A +successful remote write is authoritative; its exact result is reported even if +the single versioned destination-preference object cannot be persisted. + ### Schema 3 index contract Schema 3 deliberately keeps the established `globalStorage` filename @@ -416,7 +469,8 @@ use an explicit opt-in account mapping rather than attaching prices by guess. - Strict TypeScript, red-green TDD, full `node:test`, F5 smoke test, and installed VSIX smoke test are required in proportion to the change. - User-visible strings cover `en`, `de-DE`, `zh-TW`, `zh-CN`, `ja`, `ko`, - `pt-BR`, and `id`; all seven README editions move together. + `pt-BR`, and `id`; all nine README files (the main page plus eight locale + editions) move together. - Dormant preparation/experiment modules and review-only v2.3.1 documents stay unreachable from the production command graph and are excluded from VSIX. - `package.json` is not manually version-bumped. Publishing the reviewed Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe29963..7fc8a507 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,132 @@ All notable changes to this fork compared to upstream [`ClaudeCodeUsage/ClaudeCodeUsage`](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage) (last upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangelog.com). -## [2.3.3] — Unreleased +## [Unreleased] + +### Added +- **Documentation in every supported UI language** — German and Brazilian + Portuguese now have concise READMEs alongside the existing editions. The + Marketplace README links all eight language variants; English and Chinese + retain the fuller references. The extension description and tags identify + its Claude/Codex token and quota use cases without presenting estimates as + billing data. +- **`statusBarQuotaFormat`** (default empty) — name the quota windows in the + status bar yourself when you want a different set, order or separators than + the built-in `5h 6% · wk 1%`: `{5h.pct}`, `{wk.pct}` (or `{7d.pct}`) and + `{model:Fable.pct}`, each also taking `.reset` and `.label`. `.reset` follows + `resetCountdownFormat` unless it names its own style, so one bar can mix them: + `{5h.reset:units}`, `:decimal`, `:clock` or `:at` (wall clock, `Thu 16:59`). + Empty keeps the built-in layout, and a template supersedes + `quotaFiveHourOnly` and `showScopedWeekly` since it names its windows itself. + A segment whose window your account does not report is dropped along with its + separator. The dashboard keeps this optional feature compact: a Built-in, + 5-hour-only, Weekly-only, or Custom dropdown reveals the template field only + when Custom is selected. + +### Changed +- **Codex graduates from Beta** — after the shared interaction, accessibility, + performance, localization, package, and installed-extension gates passed, + current UI and documentation now present Codex as a first-class provider. + Historical v2.3 release notes retain their original Beta wording. +- **One preview-first sharing workspace** — the full-width export preview is + now the visual focus, with controls below it and one presentation selector + for the combined Claude + Codex activity heatmap, the legacy Claude Share + Card, and the Claude-only token heatmap. Provider accounting and labels remain + distinct: the combined view is activity volume, while the two legacy views + continue to use Claude aggregates only. +- **Command and setting compatibility** — `exportShareCard`, `exportHeatmap`, + and `publishHeatmapToGitHub` remain registered and open their matching preview + instead of bypassing the workspace. `enableShareCard` remains the one visible + on/off control and still defaults to `true`; retired `showHeatmap` state stays + catalogued and clearable for one release without creating a duplicate panel. +- **Strict local-artifact boundary** — previews and local SVG/Markdown exports + use only materialized aggregates and cannot request GitHub authentication, + profile, avatar, or name data. Claude heatmap publication is the only sharing + network path; it remains a separate explicit public-repository action with + exact repository/branch/path and create-or-overwrite confirmation. +- **Codex status bar** — newly defaulted metric is today's processed tokens; + explicit uncached/output choices remain available. The weekly indicator + continues to show remaining capacity, not used tokens. +- **Smooth live scrolling** — during an active scroll burst, the newest + provider-panel refresh waits for a short quiet interval before replacing + its DOM. A 500 ms ceiling prevents continuous scrolling from starving live + updates; the existing scroll/focus preservation and single-flight index + boundaries remain in place. +- **Bounded sharing refreshes** — only the selected presentation is rendered; + hidden Share Card and heatmap artifacts are not rebuilt on every refresh. + Materialized daily aggregates are reused while their input and pricing + identity is unchanged. ### Fixed +- **Claude all-time drill-down cost on large histories** — month → day uses a + materialized configured-timezone daily aggregate built by the incremental + index, rather than rescanning retained records when a month is opened. + Eligible recent days can continue to the existing hour detail without any + source-log read. +- **Raw Share Card scope persisted in browser storage** — project paths and + session identifiers now remain memory-only; only non-identifying display + controls survive a Webview reload. +- **Large Claude transcript buffers retained in memory** — dedup now keeps + fixed-size UTF-16-accurate digests, bounded prompt/task excerpts are detached, + and usage-bearing assistant rows retain only the fields needed for usage + calculations after content analysis. An opt-in 2.4 GiB synthetic corpus with + content analysis enabled showed sampled peak RSS falling from about 3.04 GB + to 360 MB for long user prompts; a separate long assistant-body fixture also + stays below 350 MB. Unchanged files are not reread. This does not establish + Windows or real-history smoothness; #99 remains open. +- **PR first-pass review that only posted boilerplate (#108–#112)** — a failed + model request or empty answer now fails the PR workflow with a safe tier/status + diagnostic and posts no comment; issue triage retains its explicit fallback. + DeepSeek's cheap tier disables default thinking to preserve reply tokens, the + escalation tier has an output cap above its thinking budget, and the default + flash identifier tracks the current supported model. A cheap reply marked + as needing source is never posted when escalation fails. Existing bot + comments are not edited retroactively. +- **Full rebuild whenever a new transcript appeared (#99)** — a file that had + just been created was treated as an unsafe mutation, so every new session + rebuilt every contribution in the corpus. A new file is now read in full on + its own while the established files stay untouched; ordering state is + recomputed from metadata, and a new file carrying an already-owned UUID still + falls back to the full rebuild. Measured on 15 real transcripts with two + appends and one new file: 16 body reads and 3042 ms became 3 body reads and + 567 ms. +- **Full re-read when several sessions append at once (#99)** — the content + analysis fast path required exactly one changed file, so a machine running + more than one agent never took it: every refresh rebuilt all contributions + from every file. Any number of pure tail appends now stays incremental. + Appends are parsed in full-scan order and new-UUID ownership is attributed to + the owning file, so results match the full loader. Measured on 15 real + transcripts (34 MB) with three files appended: 15 body reads and 8.7 s became + 3 body reads and 0.6 s. +- **High CPU during indexing (#99)** — day, month and hour bucketing no longer + constructs a fresh `Intl.DateTimeFormat` for every ingested record. The + resolved zone and both formatters are memoised per zone, so a large local + history is indexed without pinning a core. On a 1.4 GB history the key + derivation went from ~1.1k to ~168k records/second (158x) with identical + keys; invalid and empty zones still fall back exactly as before. +- **Dashboard date labels rebuilt a formatter for every row (#99)** — the same + per-call `Intl.DateTimeFormat` construction, in the table and chart labels: + `toLocaleDateString` built a fresh formatter for every date it rendered. The + formatter is now memoised per locale and options, with output identical to + `toLocaleDateString` for every UI locale; one label went from ~31 µs to ~1 µs. +- **One-shot sharing command intent** — command-opened previews now override a + previously saved Claude tab and presentation exactly once. A provider-local + monotonic revision history is separate from the live intent; the Webview ACK + consumes that intent, so reset, normal rerender, and dispose/reopen cannot + replay an acknowledged command or reuse its revision. +- **Verified sharing reset** — the in-workspace button now uses the same + confirmed host request → request-id client action → ACK protocol as Local Data + controls. Browser-storage deletion failure is reported, and successful reset + defaults survive a full reload. +- **Readable and accessible SVG previews** — the 1200×680 Share Card keeps its + intrinsic width inside a local horizontal scroller at 360px. Deterministic + inner-SVG geometry and contrast tests cover every normal label at 4.5:1 or + better without excluding the artifact from the surrounding Axe scan. + +## [2.3.3] — 2026-09-16 + +### Fixed + - **Startup with legacy workspace advice keys (#105)** — a workspace-scoped plaintext BYOK key or unavailable SecretStorage no longer prevents the usage status bar, commands, and dashboard from activating. The old key remains diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7aca9ba..3dc5c4e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,8 +7,8 @@ it grows. ## Project scope Before proposing a feature, it helps to know the positioning: this extension is -provider-aware and lightweight, supporting Claude Code and **Codex Beta**. -Codex Beta is enabled by default and can be turned off in provider settings. +provider-aware and lightweight, supporting Claude Code and **Codex**. +Codex is enabled by default and can be turned off in provider settings. The emphasis is truthful local usage and token attribution rather than billing reconciliation. Features that sharpen attribution, privacy, or the advice experience are the best fit. @@ -127,6 +127,18 @@ The runner checks out only the base repository, never executes contributor code, reads at most six allowlisted text files within byte budgets, and can only post one comment. Public input remains untrusted and model output may be wrong. A missing or empty PR diff stops the workflow without posting a review. +When both model tiers fail or return no usable PR analysis, the workflow fails +with a status-only diagnostic and **does not post a generic review**. Inspect +the Actions log before retrying; API response bodies, credentials, PR text, +and model replies are not included in this diagnostic. Issues retain a clearly +labelled, provider-neutral triage fallback. Historical fallback comments are +not automatically replaced. The bot only runs on `opened`, so a later retry +requires an explicitly arranged workflow run, not a fresh push to the PR. + +中文说明:外部 PR 的自动初审若两层模型都失败,会在 Actions 中显示安全的 +状态分类并停止,不再发布泛泛的模板评论;日志不会包含密钥、接口原文或 PR +内容。Issue 的分流兜底保持不变。旧评论不会自动改写,给 PR 推新提交也不会 +触发本工作流再次运行,是否重试由维护者决定。 Codex automatic attribution is not enabled in v2.2.1. It requires a separately implemented and trusted OpenAI/Codex transport; a model name or repository diff --git a/LOCAL-DATA.md b/LOCAL-DATA.md index 65785be4..55dedf7c 100644 --- a/LOCAL-DATA.md +++ b/LOCAL-DATA.md @@ -15,16 +15,18 @@ v2.3.1 normative field-level contract is in | Claude usage records | Claude Code's local `projects/**/*.jsonl` | Runtime usage records and an in-memory per-file index; bounded prompt samples only when content analysis is enabled | Released on window reload/exit. Data controls never touch source logs; the separately opt-in Session Actions feature may move one exactly confirmed log to the OS trash (recoverable) | None unless the user separately previews and sends an advice request | | Codex usage records | `$CODEX_HOME/sessions/**/*.jsonl` and `archived_sessions/**/*.jsonl` | A versioned incremental index of pseudonymous file keys, offsets, numeric usage, dates, model/effort, and sanitized structural aggregates | Until rebuild, explicit clear, schema replacement, or host removal | None | | Codex titles | Exactly `$CODEX_HOME/session_index.jsonl` | `id → thread_name` is streamed for runtime display; titles are not written to the index | Runtime only | None | -| Quota observations | Claude's official quota response or structured Codex rate-limit events | Provider, machine-local anonymous account epoch, observed/reset time, period, used/remaining fraction, anonymous window identity, source, confidence, and quality flags | At most 180 days and 512 observations per provider/account/period after boundary-preserving compaction; clear separately or with all derived data | Claude quota lookup contacts Anthropic when enabled; Codex quota evidence is local | -| Settings and UI preferences | User choices | Typed extension settings, selected tab/filters, bounded background-work state, heatmap title/range/privacy preview, and the optional display-currency preset | Until reset, clear, or uninstall; ordinary VS Code settings may participate in Settings Sync | No extension-initiated transfer; no exchange-rate lookup | -| Share destination | User-entered GitHub target | Optional `owner/repository/path`; no GitHub credential | Until sharing preferences are reset | Only after an explicit publish action and exact destination confirmation | +| Quota observations | Claude's official quota response or structured Codex rate-limit events | Provider, machine-local anonymous account epoch, observed/reset time, period, used/remaining fraction, anonymous window identity, source, confidence, and quality flags | At most 180 days and 512 observations per provider/account/period after boundary-preserving compaction; clear separately or with all derived data | Claude quota lookup contacts Anthropic when enabled; an expired Claude token is refreshed with Anthropic and written back to its credential source. Codex quota evidence is local | +| Settings and UI preferences | User choices | Typed extension settings, selected tab/filters, bounded background-work state, sharing presentation, Share Card draft controls, heatmap title/range/privacy preview, and the optional display-currency preset | Until reset, clear, or uninstall; ordinary VS Code settings may participate in Settings Sync | No extension-initiated transfer; no exchange-rate lookup | +| Share destination | User-entered GitHub target | One versioned `ccu.heatmapDestination.v1` object containing optional `owner/repository/path`; no GitHub credential | Until sharing preferences are reset | Only after an explicit publish action and exact destination confirmation. A successful write is reported even if saving this convenience preference fails | | Advice evidence | Derived local aggregates and explicit feedback | Coarse observations, recommendations, ratings, snoozes, comparable-task metrics, coverage, and versions | Bounded local ledger; clear independently | Nothing by default; only the exact previewed request is sent after a separate action | | Advice API key | User-provided secret | Secret value in VS Code SecretStorage only | Until the key or all derived data is cleared | Used only as the authorization credential for the explicitly configured endpoint | ## What is never cached - OAuth access or refresh tokens, cookies, authorization headers, or GitHub - credentials. + credentials in extension-owned derived storage. Claude's provider-owned + credential file or macOS Keychain item may be updated when its OAuth token + needs refreshing for a quota lookup. - API keys outside VS Code SecretStorage. - Full account identifiers, email addresses, display names, or subscription names. Account continuity uses a machine-local HMAC fingerprint or an @@ -32,6 +34,13 @@ v2.3.1 normative field-level contract is in - Raw prompt/response bodies, tool arguments, commands, complete CLI output, absolute source-log paths, repository remotes, or persistent thread titles. +When Claude quota tracking is enabled, the extension uses the selected Claude +profile's existing access token to query `api.anthropic.com/api/oauth/usage`. +If it is expired, the existing refresh token is sent to +`console.anthropic.com/v1/oauth/token`; the replacement access token is written +back to that profile's credential file or macOS Keychain item. This is separate +from optional BYOK AI advice, which never uses Claude subscription credentials. + Different anonymous account fingerprints are not merged. If local Codex logs cannot establish one coherent account/window, the dashboard shows only used API-equivalent value and explains why it cannot claim an account-level unused @@ -45,10 +54,24 @@ Converted estimates carry `≈`; provider-native money such as actual usage credits remains in its reported currency. The snapshot derives currency-per-USD values from the [ECB euro reference rates](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html). -## Combined heatmap and exports - -The Compare heatmap reuses daily provider aggregates; it does not scan logs -again or create another statistics cache. +## Sharing workspace and exports + +The preview-first sharing workspace reuses materialized provider aggregates; it +does not scan logs again or create another statistics cache. Its remembered +presentation is one local enum (`ccu.sharing.template`): combined activity +heatmap, Claude Share Card, or Claude token heatmap. `enableShareCard` is the +only visible on/off setting. The retired `showHeatmap` value remains catalogued +for bounded clearing and one-release compatibility, but it no longer creates a +second panel. Explicit legacy-command coordination is runtime-only: the host +keeps a monotonically increasing provider-lifetime revision separately from the +single live intent, and removes that intent after the Webview acknowledges its +exact revision. No command revision is stored in browser storage. + +Share Card project and session selections can contain raw local paths or +session identifiers, so those values remain memory-only and disappear when the +Webview closes. Browser storage keeps only non-identifying presentation +controls such as range, theme, visible sections, number format, and the +selected presentation. ```text Claude processed = input + cache creation + cache read + output @@ -64,13 +87,18 @@ equivalence. Local SVG and Markdown exports contain only a bounded title, selected date range, daily Claude/Codex/combined aggregates, labels, and caveats. They do not contain accounts, projects, thread titles, paths, prompts, or log content. +Claude Share Card and Claude token heatmap presentations consume Claude +aggregates only; they do not imply that Codex exposes equivalent fields. +Preview and local export are strictly network-free and do not request GitHub +authentication, profile, avatar, or name data. -Direct GitHub publication is optional and never runs in the background. v2.3.1 -requests `public_repo`, verifies the repository is public and resolves its +Direct GitHub publication is a separate optional action and never runs in the +background. It requests `public_repo`, verifies the repository is public and resolves its default branch, then asks the user to confirm the exact `owner/repository/branch/path` and whether the file will be created or replaced. Private repositories use local SVG plus Markdown instead of silently requesting -broader access. +broader access. After GitHub accepts the write, success is authoritative and is +reported before any warning that the atomic destination preference could not be saved. ## Clear and migration behavior @@ -95,7 +123,10 @@ operation, leaves existing data intact, and is reported for manual removal. Queued activation writes are drained before deletion and exact postconditions are verified afterward. If the Webview cannot acknowledge its allowlisted browser-state reset, a value-free pending action marker is retried on the next -panel open instead of reporting a false success. +panel open instead of reporting a false success. The reset button inside the +sharing workspace uses this same host request → request-id client reset → ACK +path; it reports browser-storage deletion failures and applies defaults only +after the allowlisted deletion succeeds. This clearing boundary is separate from the opt-in Session Actions feature. That feature can move one user-selected Claude session log to the OS trash only diff --git a/LOCAL-DATA.zh-CN.md b/LOCAL-DATA.zh-CN.md index 16fc4b0b..99fb015c 100644 --- a/LOCAL-DATA.zh-CN.md +++ b/LOCAL-DATA.zh-CN.md @@ -14,21 +14,29 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 | Claude 用量记录 | Claude Code 本地 `projects/**/*.jsonl` | 运行时用量记录和内存中的逐文件索引;仅在启用内容分析时保留有界提示样本 | 窗口重载/退出即释放。本地数据控制绝不触碰源日志;另行启用的“会话操作”可把一个精确确认的日志移入系统回收站(可恢复) | 默认无;只有用户另行预览并发送建议请求时才可能包含获准内容 | | Codex 用量记录 | `$CODEX_HOME/sessions/**/*.jsonl` 与 `archived_sessions/**/*.jsonl` | 版本化增量索引:匿名文件键、偏移量、数值用量、日期、模型/effort 和已净化结构聚合 | 保留到重建、明确清除、schema 替换或宿主移除 | 无 | | Codex 标题 | 仅 `$CODEX_HOME/session_index.jsonl` | 流式读取 `id → thread_name` 供运行时显示;标题不写入索引 | 仅运行时 | 无 | -| 额度观测 | Claude 官方额度响应或 Codex 结构化 rate-limit 事件 | 供应商、本机匿名账号 epoch、观测/重置时间、周期、已用/剩余比例、匿名窗口标识、来源、可信度和质量标志 | 保留不超过 180 天;边界保护压缩后每个供应商/账号/周期最多 512 条;可单独或随全部派生数据清除 | 启用时 Claude 额度查询会访问 Anthropic;Codex 额度证据只来自本地 | -| 设置与 UI 偏好 | 用户选择 | 类型化设置、标签页/筛选、有限后台任务状态、热力图标题/范围/隐私预览,以及可选显示币种预设 | 保留到重置、清除或卸载;普通 VS Code 设置可能参与 Settings Sync | 插件不主动传输,也不查询汇率 | -| 分享目标 | 用户输入的 GitHub 目标 | 可选 `owner/repository/path`;不保存 GitHub 凭据 | 保留到重置分享偏好 | 仅在用户明确发布并确认精确目标后发生 | +| 额度观测 | Claude 官方额度响应或 Codex 结构化 rate-limit 事件 | 供应商、本机匿名账号 epoch、观测/重置时间、周期、已用/剩余比例、匿名窗口标识、来源、可信度和质量标志 | 保留不超过 180 天;边界保护压缩后每个供应商/账号/周期最多 512 条;可单独或随全部派生数据清除 | 启用时 Claude 额度查询会访问 Anthropic;过期 token 会经 Anthropic 刷新并写回原凭证来源。Codex 额度证据只来自本地 | +| 设置与 UI 偏好 | 用户选择 | 类型化设置、标签页/筛选、有限后台任务状态、分享呈现方式、分享卡未应用的控件草稿、热力图标题/范围/隐私预览,以及可选显示币种预设 | 保留到重置、清除或卸载;普通 VS Code 设置可能参与 Settings Sync | 插件不主动传输,也不查询汇率 | +| 分享目标 | 用户输入的 GitHub 目标 | 单个版本化 `ccu.heatmapDestination.v1` 对象,包含可选 `owner/repository/path`;不保存 GitHub 凭据 | 保留到重置分享偏好 | 仅在用户明确发布并确认精确目标后发生;即使保存此便利偏好失败,成功写入仍会如实报告 | | 建议证据 | 本地派生聚合与明确反馈 | 粗粒度观察、建议、评分、暂停、可比任务指标、覆盖率和版本 | 有界本地台账;可独立清除 | 默认不发送;只有单独确认后才发送精确预览过的请求 | | 建议 API Key | 用户自备密钥 | 仅存于 VS Code SecretStorage | 保留到清除密钥或全部派生数据 | 只作为用户明确配置端点的授权凭据 | ## 绝不缓存什么 -- OAuth access/refresh token、cookie、authorization header 或 GitHub 凭据。 +- 插件自有派生存储中不会缓存 OAuth access/refresh token、cookie、authorization + header 或 GitHub 凭据。为查询 Claude 额度而刷新 OAuth token 时,Claude + 自有的凭证文件或 macOS 钥匙串项目可能会被更新。 - VS Code SecretStorage 之外的 API key。 - 完整账号标识、邮箱、显示名或订阅名称。账号连续性只用本机 HMAC fingerprint 或隔离的未归属 epoch。 - 原始提示/响应正文、工具参数、命令、完整 CLI 输出、源日志绝对路径、 仓库 remote 或持久化线程标题。 +启用 Claude 额度跟踪后,插件用所选 Claude 资料的现有 access token 查询 +`api.anthropic.com/api/oauth/usage`。如该 token 过期,会把现有 refresh token +发送到 `console.anthropic.com/v1/oauth/token`,并将新 access token 写回该资料的 +凭证文件或 macOS 钥匙串项目。这与可选的自备密钥 AI 建议分开;后者不会使用 +Claude 订阅凭据。 + 不同匿名账号 fingerprint 不会合并。如果本地 Codex 日志无法证明属于同一账号 和同一窗口,仪表盘只显示已用 API 等效价值,并说明为何不能声称账号级未用额度。 @@ -39,9 +47,19 @@ Claude Code Usage 采用本地优先设计。插件只读取供应商拥有的 [欧洲央行欧元参考汇率](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html) 换算得到。 -## 综合热力图与导出 +## 分享工作台与导出 + +预览优先的分享工作台直接复用已物化的供应商聚合,不会再次扫描日志,也不会新增第二套 +统计缓存。记住的呈现方式只是本地枚举 `ccu.sharing.template`:综合活动热力图、Claude +分享卡或 Claude token 热力图。`enableShareCard` 是唯一可见的开关。退役的 `showHeatmap` +仍保留在 catalog 中,以便执行有界清除并维持一个版本的兼容性,但不再生成第二个面板。 +显式旧命令的协调仅存在于运行时:host 把 provider 生命周期内单调递增的 revision 与唯一 +live intent 分开保存,并在 Webview ACK 精确 revision 后删除 live intent。浏览器存储不保存 +命令 revision。 -“对比”热力图直接复用供应商日聚合,不会再次扫描日志,也不会新增第二套统计缓存。 +分享卡的项目或会话范围可能包含原始本地路径或会话标识,因此这些值只保留在内存中, +Webview 关闭即消失。浏览器存储只保留不具识别性的呈现控件,例如范围、主题、显示区块、 +数字格式和所选呈现方式。 ```text Claude processed = input + cache creation + cache read + output @@ -55,11 +73,14 @@ Codex 的 cached input 已包含在 input total 中,reasoning 已包含在 out 本地 SVG 与 Markdown 导出只含有界标题、所选日期范围、每日 Claude/Codex/综合聚合、 标签和免责声明;不含账号、项目、线程标题、路径、提示或日志内容。 +Claude 分享卡和 Claude token 热力图只消费 Claude 聚合,不暗示 Codex 具有等价字段。 +预览与本地导出严格不联网,也不请求 GitHub authentication、profile、头像或名称数据。 -直接发布到 GitHub 是可选动作,且绝不在后台运行。v2.3.1 仅请求 `public_repo`, +直接发布到 GitHub 是独立的可选动作,且绝不在后台运行。它仅请求 `public_repo`, 先验证仓库公开性和默认分支,再要求用户确认精确的 `owner/repository/branch/path` 以及创建或覆盖动作。私有仓库应改用本地 SVG + -Markdown,不会静默索取更宽权限。 +Markdown,不会静默索取更宽权限。GitHub 接受写入后,插件会先如实报告成功;若原子化 +目的地偏好保存失败,再另行显示警告。 ## 清除与迁移 @@ -78,7 +99,9 @@ UI 或分享偏好、移除 BYOK 密钥。“清除全部扩展派生数据” 若发现 VS Code 已无法安全更新的未注册旧设置,整个操作会在零删除状态下停止,并提示 手动移除。激活期排队写入会先排空,删除后再验证精确后置条件。若 Webview 未能确认其 白名单浏览器状态已清除,插件会保存一个不含数据值的待重试动作,在下次打开面板时重放, -而不会误报成功。 +而不会误报成功。分享工作台内部的重置按钮也复用同一条 host request → 带 request-id 的 +client reset → ACK 路径;浏览器存储删除失败会明确报错,且只有 allowlist 删除成功后才应用 +默认值。 这个清除边界与另行选择启用的“会话操作”相互独立。后者只有在用户针对单个 Claude 会话日志完成模态确认后,才会把它移入系统回收站(可恢复);它不会参与“清除全部”。 diff --git a/README-de-DE.md b/README-de-DE.md new file mode 100644 index 00000000..89ef1cad --- /dev/null +++ b/README-de-DE.md @@ -0,0 +1,61 @@ +# Claude Code Usage + +🌐 **Sprache**: [Hauptseite](README.md) · [English](README-en.md) · **Deutsch** · [繁體中文](README-zh-TW.md) · [简体中文](README-zh-CN.md) · [日本語](README-ja.md) · [한국어](README-ko.md) · [Português (Brasil)](README-pt-BR.md) · [Bahasa Indonesia](README-id.md) + +**Lokale Nutzungsübersicht für Claude Code und Codex in der VS-Code-Statusleiste.** +Die Erweiterung liest lokale Nutzungsprotokolle und zeigt Anbieter-Daten mit +jeweils passender Bedeutung an. Sie ist **kein Abrechnungswerkzeug**. + +## Auf einen Blick + +- **Claude Code:** geschätzte Token-Kosten und, bei gültiger Anmeldung, die + offiziellen 5-Stunden- und Wochenquoten des aktiven Claude-Profils. +- **Codex:** heute verarbeitete Token und die zuletzt beobachtete + verbleibende Wochenquote. Die Quote ist kein garantierter Live-Kontostand. +- **Dashboard:** Tages-, 30-Tage- und Gesamtansicht sowie Sitzungen, Projekte + und aufbereitete lokale Empfehlungen. Diagramme lassen sich nach Monat, + Tag und Stunde aufklappen, ohne beim Klick die Protokolle erneut zu lesen. +- **Teilen:** Ein gemeinsamer Arbeitsbereich mit Vorschau vor dem Export. + Lokaler SVG-/Markdown-Export bleibt lokal; eine Veröffentlichung auf GitHub + erfordert eine gesonderte Bestätigung. Die Funktion ist standardmäßig an + und lässt sich in den Einstellungen ausschalten. + +![Codex-Übersicht, synthetische Daten](images/v2.3.1/codex-overview-zh-CN-dark.png) + +Das Bild nutzt synthetische Daten, nicht das Konto einer Person. Weitere +Abbildungen und Details stehen in der [ausführlichen englischen Hauptseite](README.md). + +## Daten richtig lesen + +Bei Codex gilt **verarbeitet = Eingabe + Ausgabe** und **nicht gecachte Nutzung += nicht gecachte Eingabe + Ausgabe**. Gecachte Eingabe ist bereits Teil der +Eingabe; Reasoning ist bereits Teil der Ausgabe. Diese Werte dürfen nicht +nochmals addiert werden. Angezeigte API-Äquivalentkosten sind Schätzungen +anhand bekannter Modellpreise, **keine Rechnung oder Abo-Belastung**. +Claude- und Codex-Kosten oder Quoten werden nicht zusammengerechnet. +Fehlende oder noch nicht indizierte Protokolle können die Anzeige verkleinern; +die Codex-Quote bleibt die letzte lokale Beobachtung. + +## Installation und Datenschutz + +In VS Code unter **Erweiterungen** nach `Claude Code Usage` suchen oder +`ext install GrowthJack.claude-code-usage` ausführen. Das Dashboard öffnet +sich über die Statusleiste oder den Befehl **Show Usage Details**. +Sprache, Datenverzeichnis und Anzeigeoptionen können in den Einstellungen +angepasst werden. Claude-Protokolle werden lokal gelesen; der Codex-Index +speichert abgeleitete, pseudonyme Nutzungsdaten statt Gesprächsinhalten. +KI-Beratung ist optional und sendet erst nach Vorschau und ausdrücklichem +Senden mit einem eigenen API-Schlüssel. Einzelheiten und Löschwege: +[Lokale Daten und Datenschutz](LOCAL-DATA.md). + +Die vollständige Funktionsbeschreibung steht auf [Englisch](README.md) +und [vereinfachtem Chinesisch](README-zh-CN.md). Fehler und Vorschläge sind +unter [Issues](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues) willkommen. + +## Credits und Lizenz + +MIT-Lizenz. Die Pflege verwendet [Claude Code](https://claude.com/claude-code) +und [OpenAI Codex](https://developers.openai.com/codex/) als Werkzeuge; +menschliche Beiträge stehen im [Changelog](CHANGELOG.md). + +[MIT-Lizenz](LICENSE) diff --git a/README-en.md b/README-en.md index b8bf7eb0..70b2177d 100644 --- a/README-en.md +++ b/README-en.md @@ -1,14 +1,14 @@ # Claude Code Usage -🌐 **Language**: [🏠 Main](README.md) | **English** | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) +🌐 **Language**: [🏠 Main](README.md) | **English** | [Deutsch](README-de-DE.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Português (Brasil)](README-pt-BR.md) | [Bahasa Indonesia](README-id.md) --- -**The local Claude Code and Codex usage coach in your status bar.** Not a billing tool. Claude retains its cost and quota views; Codex Beta adds token and behaviour insights with Codex-native semantics. +**The local Claude Code and Codex usage coach in your status bar.** Not a billing tool. Claude retains its cost and quota views; Codex adds token and behaviour insights with Codex-native semantics. -> **What it is:** a VS Code status-bar monitor that reads your local Claude Code conversation logs and shows **token-derived** usage and cost estimates — plus an optional AI advisor that suggests how to improve your prompts and reduce waste. +> **What it is:** a VS Code status-bar monitor that reads local Claude Code and Codex usage logs, shows provider-appropriate token and quota views, and offers optional advice for reducing avoidable overhead. > -> **What it is _not_:** a billing tool. All amounts are estimates based on public per-million-token rates. Refer to your Anthropic account for actual charges. +> **What it is _not_:** a billing tool. Claude costs and Codex API-equivalent values are estimates, not subscription charges or invoices. Refer to the relevant provider account for billing truth. > Screenshots include English and Simplified Chinese. See the [main README](README.md) for the full feature reference. @@ -48,6 +48,7 @@ These five v2.3 captures use the production renderer, synthetic fixtures, and VS ## Features - **Status bar** — today's cost, current-session cost, and real 5-hour / weekly quota (`5h:N% wk:N%`) read from Claude Code's own OAuth session. Zero configuration. +- **Quota format** — choose Built-in (default), 5-hour only, or Weekly only in ⚙ Settings; Custom reveals the optional template field. - **Dashboard tabs** — Today / Last 30 Days / All Time, plus **Sessions / Projects / Content / Branches**, all sortable. - **Stacked cost-composition charts** with a Y-axis and reference lines — see at a glance how much of each day / month went to input, output, cache-write and cache-read. - **Content tab** — estimates which content consumes your tokens (your prompts vs. tool results vs. assistant output / thinking). @@ -56,6 +57,34 @@ These five v2.3 captures use the production renderer, synthetic fixtures, and VS - **Multi-vendor pricing** — Opus 4.x / Sonnet 4.x / Haiku 4.5 verified against Anthropic's public pricing; reference rates for OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen with family-aware fallback. `Refresh Token Pricing` pulls live LiteLLM data. - **Personalisation** — language, timezone, decimal places, compact numbers, project grouping, dashboard auto-refresh toggle. +## What's new in v2.4 + +- **Codex status and scrolling** — today's processed tokens are the default + compact metric, while weekly quota shows remaining capacity. Live panel + updates briefly defer during scrolling, with a bounded delay. +- **One preview-first sharing workspace** — a full-width export preview now + comes first, with controls below it and one presentation selector for the + **Combined activity heatmap**, **Claude Share Card**, and **Claude token + heatmap**. The combined presentation requires real data from both providers; + both legacy presentations remain Claude-only. +- **Compatibility without duplicate panels** — `exportShareCard`, + `exportHeatmap`, and `publishHeatmapToGitHub` remain available and open the + matching presentation. `enableShareCard` is still the single visible sharing + switch and defaults to on; retired `showHeatmap` state remains only for one + release of compatibility and bounded clearing. +- **Strict local-artifact and provider boundaries** — selecting, previewing, + and local SVG/Markdown export use materialized aggregates only and make no + network request, GitHub sign-in, or profile/avatar/name lookup. Only the + separate Claude-heatmap **Publish to GitHub** action can connect; it remains + public-repository-only and confirms the exact target and create/overwrite + action before writing. Combined activity does not claim billing, + productivity, capability, or provider equivalence. + +![v2.4 combined sharing workspace, light theme](images/v2.4.0/compare-sharing-en-light.png) + +Production renderer, synthetic usage and VS Code theme variables; not an +installed-VSIX or real-account screenshot. + ## What's new in v2.3 - **Refined throughout the v2.3 line** — GPT-6 Astra and Fable 5.1 model @@ -88,14 +117,17 @@ Also on the [Open VSX Registry](https://open-vsx.org/extension/GrowthJack/claude ## Configuration -Open Settings (`Ctrl+,`) and search for **`Claude Code Usage`**. All settings are optional. The most useful: +Open the dashboard's ⚙ Settings tab for most options, including the BYOK API +key, which is stored in SecretStorage and is not synced. VS Code Settings +(`Ctrl+,`) retains only `language`, `dataDirectory`, and +`codex.dataDirectory`. The most useful options are: - `language` — UI language (`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`). - `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. - Each of these status-bar items is opt-out — set `usageLimitTracking`, `showCost`, or `showContext` to `false` to hide just that one. -- `advice.apiKey` — bring-your-own key shared by AI Advice and the Usage Optimizer (Anthropic or OpenAI-compatible endpoint). +- `advice.apiKey` — enter your own key in the dashboard for AI Advice and the Usage Optimizer (Anthropic or OpenAI-compatible endpoint). - `pauseDashboardRefresh` — pause dashboard auto-refresh (also toggleable in the dashboard header). See the [full settings table in the main README](README.md#configuration). diff --git a/README-id.md b/README-id.md index 1bcb16fa..136f9de7 100644 --- a/README-id.md +++ b/README-id.md @@ -1,14 +1,14 @@ # Claude Code Usage -🌐 **Bahasa**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | **Bahasa Indonesia** +🌐 **Bahasa**: [🏠 Main](README.md) | [English](README-en.md) | [Deutsch](README-de-DE.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Português (Brasil)](README-pt-BR.md) | **Bahasa Indonesia** --- -**Pelatih penggunaan lokal Claude Code dan Codex di status bar.** Bukan alat billing. Tampilan biaya / kuota Claude tetap ada; Codex Beta menganalisis token dan perilaku sesuai semantik Codex. +**Pelatih penggunaan lokal Claude Code dan Codex di status bar.** Bukan alat billing. Tampilan biaya / kuota Claude tetap ada; Codex menganalisis token dan perilaku sesuai semantik Codex. -> **Apa ini:** monitor status bar VS Code yang membaca log percakapan Claude Code lokal Anda dan menampilkan estimasi penggunaan serta biaya **berbasis token** — plus penasihat AI opsional yang menyarankan cara memperbaiki prompt Anda dan mengurangi pemborosan. +> **Apa ini:** monitor status bar VS Code yang membaca log penggunaan lokal Claude Code dan Codex, lalu menampilkan token, kuota, dan estimasi sesuai makna data masing-masing penyedia. Saran opsional membantu mengurangi pemakaian yang tidak perlu. -> **ini _Bukanlah_:** alat billing. Semua angka adalah estimasi berdasarkan tarif publik per-juta-token. Rujuk ke akun Anthropic Anda untuk biaya yang sebenarnya. +> **Apa yang bukan:** alat penagihan. Biaya Claude dan biaya setara API Codex hanyalah estimasi, bukan tagihan langganan. Periksa akun penyedia terkait untuk tagihan sebenarnya. Kuota Codex adalah pengamatan lokal terakhir, bukan saldo langsung. > Screenshot mencakup UI bahasa Inggris dan Tionghoa Sederhana. Lihat [README utama](README.md) untuk referensi lengkap. @@ -48,6 +48,7 @@ Lima gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel ## Fitur - **Status bar** — biaya hari ini, biaya sesi saat ini, dan kuota 5-jam / mingguan yang sebenarnya (`5h:N% wk:N%`) dibaca dari sesi OAuth Claude Code sendiri. Tanpa konfigurasi. +- **Format kuota** — pilih Bawaan (default), hanya 5 jam, atau hanya Mingguan di ⚙ Pengaturan; pilihan Kustom baru menampilkan kolom templat. - **Tab dashboard** — Hari Ini / 30 Hari Terakhir / Sepanjang Waktu, plus **Sesi / Proyek / Konten / Branch**, semuanya bisa diurutkan. - **Grafik komposisi biaya bertumpuk** dengan sumbu Y dan garis referensi — lihat sekilas berapa banyak dari tiap hari / bulan yang terpakai untuk masukan, keluaran, cache-write, dan cache-read. - **Tab Konten** — memperkirakan konten mana yang menghabiskan token Anda (prompt Anda vs. hasil tool vs. output / pemikiran asisten). @@ -56,6 +57,30 @@ Lima gambar v2.3 ini menggunakan renderer produksi, data sintetis, dan variabel - **Harga multi-vendor** — Opus 4.x / Sonnet 4.x / Haiku 4.5 diverifikasi terhadap harga publik Anthropic; tarif referensi untuk OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen dengan fallback berbasis family model. `Refresh Token Pricing` menarik data LiteLLM langsung. - **Personalisasi** — bahasa, zona waktu, angka desimal, angka ringkas, pengelompokan proyek, toggle penyegaran otomatis dashboard. +## Yang baru di v2.4 + +- **Status Codex dan pengguliran** — metrik ringkas bawaan menampilkan token + yang diproses hari ini, sedangkan jatah mingguan menampilkan sisanya. + Pembaruan panel ditunda sebentar saat menggulir, dengan batas waktu tunggu. +- **Satu ruang kerja berbagi yang mengutamakan pratinjau** — pratinjau ekspor + selebar penuh menjadi fokus, dengan kontrol di bawahnya. Satu pemilih tampilan + berpindah antara **heatmap aktivitas gabungan**, **Claude Share Card**, dan + **heatmap token Claude**. Tampilan gabungan hanya tersedia saat kedua penyedia + memiliki data nyata; dua tampilan lama tetap khusus Claude. +- **Entri kompatibel tanpa panel ganda** — `exportShareCard`, `exportHeatmap`, + dan `publishHeatmapToGitHub` tetap tersedia dan membuka pratinjau yang sesuai. + `enableShareCard` tetap menjadi satu-satunya sakelar berbagi yang terlihat dan + aktif secara default; `showHeatmap` yang dipensiunkan disimpan hanya untuk satu + rilis kompatibilitas status dan penghapusan terbatas. +- **Batas artefak lokal dan penyedia yang ketat** — memilih tampilan, melihat + pratinjau, serta mengekspor SVG/Markdown lokal hanya memakai agregat yang + sudah tersedia dan tidak melakukan permintaan jaringan, login GitHub, atau + pengambilan profil/avatar/nama. Hanya tindakan terpisah **Publikasikan ke + GitHub** untuk heatmap Claude yang dapat terhubung; tindakan ini tetap khusus + repositori publik dan mengonfirmasi target serta aksi buat/timpa yang tepat + sebelum menulis. Aktivitas gabungan bukan klaim tagihan, produktivitas, + kemampuan, atau kesetaraan penyedia. + ## Yang baru di v2.3 - **Penyempurnaan di seluruh seri v2.3** — metadata model GPT-6 Astra dan diff --git a/README-ja.md b/README-ja.md index 17f90839..8a3a9984 100644 --- a/README-ja.md +++ b/README-ja.md @@ -1,14 +1,14 @@ # Claude Code 使用量モニター -🌐 **言語**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | **日本語** | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) +🌐 **言語**: [🏠 Main](README.md) | [English](README-en.md) | [Deutsch](README-de-DE.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | **日本語** | [한국어](README-ko.md) | [Português (Brasil)](README-pt-BR.md) | [Bahasa Indonesia](README-id.md) --- -**Claude Code と Codex のローカル使用量を改善するステータスバーコーチ。** 請求ツールではありません。Claude のコスト / クォータ表示を維持し、Codex Beta は Codex 固有のトークンと動作の意味で分析します。 +**Claude Code と Codex のローカル使用量を改善するステータスバーコーチ。** 請求ツールではありません。Claude のコスト / クォータ表示を維持し、Codex は Codex 固有のトークンと動作の意味で分析します。 -> **これは何か**:ローカルの Claude Code 会話ログを読み取り、**トークンから算出した**使用量とコストの見積もりを表示する VS Code ステータスバーモニター。さらに、プロンプトの改善と無駄削減を提案する任意の AI アドバイザー付き。 +> **これは何か**:ローカルの Claude Code と Codex の使用ログを読み取り、それぞれの意味に沿ってトークン、利用枠、推定値を表示する VS Code ステータスバーモニター。無駄な消費を減らす任意のアドバイスも提供します。 > -> **これは何ではないか**:請求ツールではありません。表示される金額はすべて公開のトークン単価に基づく見積もりです。実際の請求は Anthropic アカウントをご確認ください。 +> **これは何ではないか**:請求ツールではありません。Claude のコストと Codex の API 相当コストは推定値であり、サブスクリプションの請求額ではありません。実際の請求は各プロバイダーのアカウントで確認してください。Codex の利用枠はローカルで最後に観測した値で、リアルタイム残高ではありません。 > スクリーンショットには英語と簡体字中国語を使用しています。詳細は[メイン README](README.md) を参照してください。 @@ -48,6 +48,7 @@ ## 主な機能 - **ステータスバー** — 本日のコスト、現在のセッションのコスト、そして Claude Code 自身の OAuth セッションから読み取る実際の 5 時間 / 週間クォータ(`5h:N% wk:N%`)。設定不要。 +- **クォータ表示形式** — ⚙ 設定で標準(既定)・5 時間のみ・週間のみを選択できます。「カスタム」を選んだ場合だけテンプレート欄が表示されます。 - **ダッシュボードタブ** — 今日 / 直近 30 日 / 全期間 に加え、**Sessions / Projects / Content / Branches**。すべて並べ替え可能。 - **積み上げコスト構成チャート**(Y 軸と参照線付き)— 各日 / 各月のコストが入力・出力・キャッシュ書き込み・キャッシュ読み取りにどれだけ使われたか一目で分かります。 - **Content タブ** — どの内容がトークンを消費しているか(あなたのプロンプト vs ツール結果 vs アシスタント出力 / 思考)を推定。 @@ -56,6 +57,23 @@ - **マルチベンダー価格** — Opus 4.x / Sonnet 4.x / Haiku 4.5 は Anthropic 公式価格で検証済み。OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen の参考価格(ファミリー認識フォールバック付き)。`Refresh Token Pricing` で LiteLLM の最新価格を取得。 - **パーソナライズ** — 言語、タイムゾーン、小数点桁数、コンパクト数値、プロジェクトのグループ化、ダッシュボード自動更新の切り替え。 +## v2.4 の新機能 + +- **Codex のステータスとスクロール** — 短い表示の既定値は今日の処理済みトークン、 + 週間枠は残量です。スクロール中のパネル更新は短時間だけ延期し、待ち時間には上限を設けています。 +- **単一のプレビュー優先共有ワークスペース** — 全幅の書き出しプレビューを中心に置き、 + コントロールをその下へ統一しました。1 つの表示形式セレクターで**統合アクティビティヒートマップ**、 + **Claude Share Card**、**Claude トークンヒートマップ**を切り替えます。統合表示は両プロバイダーに + 実データがある場合だけ使え、従来の 2 表示は引き続き Claude 専用です。 +- **重複パネルなしの互換入口** — `exportShareCard`、`exportHeatmap`、 + `publishHeatmapToGitHub` は維持され、対応するプレビューを開きます。`enableShareCard` は既定オンの + 唯一の表示切替です。廃止済みの `showHeatmap` は 1 リリース分の状態互換性と限定的な消去のためだけに残ります。 +- **厳格なローカル成果物/プロバイダー境界** — 表示の選択、プレビュー、ローカル + SVG/Markdown 書き出しは既に集計済みのデータだけを使い、ネットワーク要求、GitHub + サインイン、プロフィール/アバター/名前の取得を行いません。接続できるのは Claude + ヒートマップの独立した **GitHub に公開** 操作だけです。公開リポジトリ限定で、書き込み前に + 正確な対象と作成/上書き操作を確認します。統合活動は請求、成果、生産性、能力、プロバイダー間の等価性を示しません。 + ## v2.3 の新機能 - **v2.3 系列全体の改善** — GPT-6 Astra と Fable 5.1 のモデル情報、任意の diff --git a/README-ko.md b/README-ko.md index d9b5ec2e..dca36d59 100644 --- a/README-ko.md +++ b/README-ko.md @@ -1,14 +1,14 @@ # Claude Code 사용량 모니터 -🌐 **언어**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | **한국어** | [Bahasa Indonesia](README-id.md) +🌐 **언어**: [🏠 Main](README.md) | [English](README-en.md) | [Deutsch](README-de-DE.md) | [繁體中文](README-zh-TW.md) | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | **한국어** | [Português (Brasil)](README-pt-BR.md) | [Bahasa Indonesia](README-id.md) --- -**Claude Code와 Codex의 로컬 사용량을 개선하는 상태 표시줄 코치.** 청구 도구가 아닙니다. Claude 비용 / 쿼터 보기를 유지하면서 Codex Beta는 Codex 고유의 토큰과 동작 의미로 분석합니다. +**Claude Code와 Codex의 로컬 사용량을 개선하는 상태 표시줄 코치.** 청구 도구가 아닙니다. Claude 비용 / 쿼터 보기를 유지하면서 Codex는 Codex 고유의 토큰과 동작 의미로 분석합니다. -> **무엇인가**: 로컬 Claude Code 대화 로그를 읽어 **토큰 기반**의 사용량과 비용 추정치를 보여주는 VS Code 상태 표시줄 모니터. 프롬프트 개선과 낭비 절감을 제안하는 선택적 AI 어드바이저 포함. +> **무엇인가**: 로컬 Claude Code와 Codex 사용 로그를 읽고 각 공급자의 의미에 맞게 토큰, 한도, 추정치를 보여주는 VS Code 상태 표시줄 모니터입니다. 불필요한 사용을 줄이는 선택적 조언도 제공합니다. > -> **무엇이 아닌가**: 청구 도구가 아닙니다. 표시되는 모든 금액은 공개된 100만 토큰당 단가 기준 추정치입니다. 실제 청구는 Anthropic 계정을 확인하세요. +> **무엇이 아닌가**: 청구 도구가 아닙니다. Claude 비용과 Codex의 API 환산 비용은 추정치이며 구독 청구액이 아닙니다. 실제 청구는 해당 공급자 계정에서 확인하세요. Codex 한도는 로컬에서 마지막으로 관측한 값이며 실시간 잔액이 아닙니다. > 스크린샷에는 영어와 중국어 간체 UI가 포함됩니다. 전체 기능 설명은 [메인 README](README.md)를 참고하세요. @@ -48,6 +48,7 @@ ## 기능 - **상태 표시줄** — 오늘 비용, 현재 세션 비용, 그리고 Claude Code 자체 OAuth 세션에서 읽는 실제 5시간 / 주간 쿼터(`5h:N% wk:N%`). 설정 불필요. +- **쿼터 표시 형식** — ⚙ 설정에서 기본(기본값), 5시간만, 주간만 선택할 수 있습니다. 사용자 지정을 선택할 때만 템플릿 입력칸이 표시됩니다. - **대시보드 탭** — 오늘 / 최근 30일 / 전체 기간, 그리고 **Sessions / Projects / Content / Branches**. 모두 정렬 가능. - **누적 비용 구성 차트**(Y축과 기준선 포함) — 각 일 / 월의 비용 중 입력·출력·캐시 쓰기·캐시 읽기가 차지하는 비율을 한눈에. - **Content 탭** — 어떤 콘텐츠가 토큰을 소비하는지 추정(내 프롬프트 vs 도구 결과 vs 어시스턴트 출력 / 사고). @@ -56,6 +57,23 @@ - **멀티 벤더 가격** — Opus 4.x / Sonnet 4.x / Haiku 4.5는 Anthropic 공식 가격으로 검증. OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 참고 가격(패밀리 인식 폴백 포함). `Refresh Token Pricing`으로 LiteLLM 최신 가격 가져오기. - **개인화** — 언어, 시간대, 소수 자릿수, 간략 숫자, 프로젝트 그룹화, 대시보드 자동 새로고침 토글. +## v2.4 새로운 기능 + +- **Codex 상태와 스크롤** — 간결한 기본 지표는 오늘 처리한 토큰이며 주간 한도는 + 남은 비율을 표시합니다. 스크롤 중 패널 갱신은 잠시 미루되 대기 시간에 상한을 둡니다. +- **하나의 미리보기 우선 공유 작업 공간** — 전체 너비 내보내기 미리보기를 중심에 두고 + 컨트롤은 아래에 배치했습니다. 하나의 표시 형식 선택기로 **통합 활동 히트맵**, + **Claude 공유 카드**, **Claude 토큰 히트맵**을 전환합니다. 통합 표시는 두 공급자 모두 실제 데이터가 + 있을 때만 사용할 수 있으며, 기존 두 표시는 계속 Claude 전용입니다. +- **중복 패널 없는 호환 진입점** — `exportShareCard`, `exportHeatmap`, + `publishHeatmapToGitHub`는 유지되며 해당 미리보기를 엽니다. `enableShareCard`는 기본 켜짐인 + 유일한 표시 스위치입니다. 폐기된 `showHeatmap`은 한 릴리스 동안 상태 호환성과 제한된 삭제를 위해서만 유지됩니다. +- **엄격한 로컬 산출물 및 공급자 경계** — 표시 선택, 미리보기, 로컬 SVG/Markdown + 내보내기는 이미 집계된 데이터만 사용하며 네트워크 요청, GitHub 로그인 또는 프로필/아바타/이름 + 조회를 수행하지 않습니다. 연결할 수 있는 것은 Claude 히트맵의 별도 **GitHub에 게시** 작업뿐입니다. + 이 작업은 공개 저장소로 제한되며 쓰기 전에 정확한 대상과 생성/덮어쓰기 작업을 확인합니다. + 통합 활동은 청구, 생산성, 능력 또는 공급자 간 동등성을 뜻하지 않습니다. + ## v2.3 새로운 기능 - **v2.3 계열 전반의 개선** — GPT-6 Astra와 Fable 5.1 모델 메타데이터, diff --git a/README-pt-BR.md b/README-pt-BR.md new file mode 100644 index 00000000..daea74a0 --- /dev/null +++ b/README-pt-BR.md @@ -0,0 +1,58 @@ +# Claude Code Usage + +🌐 **Idioma**: [Página principal](README.md) · [English](README-en.md) · [Deutsch](README-de-DE.md) · [繁體中文](README-zh-TW.md) · [简体中文](README-zh-CN.md) · [日本語](README-ja.md) · [한국어](README-ko.md) · **Português (Brasil)** · [Bahasa Indonesia](README-id.md) + +**Uso local do Claude Code e do Codex na barra de status do VS Code.** A +extensão lê registros locais e mostra cada provedor conforme o significado +dos seus dados. **Não é uma ferramenta de cobrança.** + +## O essencial + +- **Claude Code:** custos estimados a partir de tokens e, quando a conta está + autenticada, limites oficiais de 5 horas e semanais do perfil ativo. +- **Codex:** tokens processados hoje e saldo semanal restante da última + observação local. Esse saldo não é uma consulta em tempo real à conta. +- **Painel:** hoje, últimos 30 dias, histórico, sessões, projetos e sugestões + locais. Os gráficos podem detalhar mês, dia e hora sem reler os registros + a cada clique. +- **Compartilhamento:** uma área única com prévia antes da exportação. SVG e + Markdown locais não enviam dados; publicar no GitHub exige confirmação + separada. A opção vem ativada e pode ser desativada nas configurações. + +![Visão geral do Codex com dados sintéticos](images/v2.3.1/codex-overview-zh-CN-dark.png) + +A imagem usa dados sintéticos, não informações de uma conta real. Veja mais +imagens e detalhes na [página principal completa em inglês](README.md). + +## Como interpretar os números + +No Codex, **processado = entrada + saída** e **uso sem cache = entrada sem +cache + saída**. A entrada em cache já faz parte da entrada; os tokens de +raciocínio já fazem parte da saída. Não some essas parcelas outra vez. O +**custo equivalente de API** é apenas uma estimativa com preços conhecidos, +não uma fatura nem cobrança da assinatura. Custos e limites de Claude e Codex +não são somados entre si. Registros ausentes ou ainda não indexados podem +reduzir os totais mostrados; o limite do Codex é a última observação local. + +## Instalação e privacidade + +Procure `Claude Code Usage` em **Extensões** no VS Code ou execute +`ext install GrowthJack.claude-code-usage`. Abra o painel pela barra de status +ou pelo comando **Show Usage Details**. Idioma, diretório de dados e opções +de exibição ficam nas configurações. Os registros do Claude são lidos +localmente; o índice do Codex guarda agregados pseudônimos, não o conteúdo +das conversas. A orientação por IA é opcional: só envia uma solicitação após +prévia e envio explícito, com sua própria chave de API. Detalhes e formas de +apagar dados derivados: [Dados locais e privacidade](LOCAL-DATA.md). + +Para a referência completa, consulte [inglês](README.md) ou +[chinês simplificado](README-zh-CN.md). Relate problemas e sugestões em +[Issues](https://github.com/ClaudeCodeUsage/ClaudeCodeUsage/issues). + +## Créditos e licença + +Licença MIT. A manutenção usa [Claude Code](https://claude.com/claude-code) +e [OpenAI Codex](https://developers.openai.com/codex/) como ferramentas; +contribuições humanas constam no [changelog](CHANGELOG.md). + +[Licença MIT](LICENSE) diff --git a/README-zh-CN.md b/README-zh-CN.md index 475e520d..9d6cfd42 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -1,14 +1,14 @@ # Claude Code 使用量监控 -🌐 **语言**: [🏠 Main](README.md) | [English](README-en.md) | [繁體中文](README-zh-TW.md) | **简体中文** | [日本語](README-ja.md) | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) +🌐 **语言**: [🏠 Main](README.md) | [English](README-en.md) | [Deutsch](README-de-DE.md) | [繁體中文](README-zh-TW.md) | **简体中文** | [日本語](README-ja.md) | [한국어](README-ko.md) | [Português (Brasil)](README-pt-BR.md) | [Bahasa Indonesia](README-id.md) --- -**状态栏中的 Claude Code 与 Codex 本地用量教练。** 它不是账单工具。Claude 保留成本与配额视图;Codex Beta 按自己的 token 与行为语义提供分析,无需硬套 Claude 的结构。 +**状态栏中的 Claude Code 与 Codex 本地用量教练。** 它不是账单工具。Claude 保留成本与配额视图;Codex 按自己的 token 与行为语义提供分析,无需硬套 Claude 的结构。 -> **它是什么**:一个 VS Code 状态栏小工具,读取本地 Claude Code 对话日志,用 token 数量乘以公开单价来估算使用量和成本;并可提供 AI 建议,帮你优化提示、减少浪费。 +> **它是什么**:一个 VS Code 状态栏小工具,读取本地 Claude Code 与 Codex 用量日志,分别展示各自的 token、额度与估算值;还可提供可选建议,帮助减少不必要的消耗。 > -> **它不是什么**:账单工具。所有金额均为估算值(基于公开的每百万 token 单价),实际费用请以 Anthropic 官方账单为准。 +> **它不是什么**:账单工具。Claude 成本和 Codex API 等效成本都是估算值,不是订阅扣费或发票;实际账单请以对应供应商账号为准。Codex 额度仅是本地最后观测,不是实时余额。 > 截图包含中英文界面;各图标明主题与范围。 @@ -50,6 +50,7 @@ 以上五张 v2.3 截图使用生产仪表板渲染器、合成夹具及 VS Code Light+/Dark+ 主题变量生成,不是个人用量或账单证据;原生 VSIX 安装单独验收。 - Codex 主状态项显示**今日已用 token**,周额度项显示**余量**:已用 36% 时为 `wk 64%`;悬浮卡保留已用进度条、重置时间和分行说明。额度来自本地最后观测,不是实时余额。 +- Claude 配额格式在 ⚙ 设置中用下拉栏选择:默认布局、仅 5 小时、仅每周或自定义;只有选“自定义”才显示模板输入框。 - 周期详情默认折叠;分享设置位于图下方。分享工作台默认开启、可关闭;色阶可选分位数、对数或线性。 - CLI 调用只有留下带 usage 的持久会话日志才能计入。未保存会话的历史调用不能回填;今日和最近 30 天都采用配置时区。 - Projects 为两个供应商新增仅 Token 的 30/90 天“项目 × 日期”热力图与每日堆叠趋势,并保留精确提示、覆盖状态、有界行数和“其他项目”长尾。 @@ -76,6 +77,25 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 --- +## 2.4 新功能 + +- **Codex 状态栏与滚动**:默认简短指标显示今日已处理 Token,独立的每周额度显示 + 剩余比例。滚动期间面板刷新短暂延后,且有最长等待边界。 +- **统一、预览优先的分享工作台**:全宽导出预览位于视觉中心,控制项统一放在下方; + 一个呈现方式选择器即可切换**综合活动热力图**、**Claude 分享卡**与 **Claude token + 热力图**。综合呈现只在两个供应商都有真实数据时出现,后两种旧版呈现仍只使用 Claude 数据。 +- **兼容入口,不再重复面板**:`exportShareCard`、`exportHeatmap` 与 + `publishHeatmapToGitHub` 继续保留,并打开对应预览。`enableShareCard` 仍是唯一可见的 + 分享开关且默认开启;退役的 `showHeatmap` 仅为一个版本的状态兼容与有界清除而保留。 +- **严格的本地产物与供应商边界**:切换、预览和本地 SVG/Markdown 导出只使用已物化聚合, + 不发起网络请求,也不登录 GitHub 或获取 profile/头像/名称。只有 Claude 热力图中独立的 + **发布到 GitHub** 动作可以联网;它仍仅支持公开仓库,并在写入前确认精确目标及创建/覆盖动作。 + 综合活动不代表账单、生产率、能力或跨供应商等价。 + +![v2.4 统一分享工作台,浅色主题](images/v2.4.0/compare-sharing-en-light.png) + +此图来自生产渲染器、合成用量与 VS Code 主题变量;不是已安装 VSIX 或真实账号的截图。 + ## 2.3 新功能 - **2.3 系列持续完善**——新增 GPT-6 Astra、Fable 5.1 模型元数据与可选 @@ -96,7 +116,7 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 ## 2.2 新功能 -- **用量分享卡**(可选,`enableShareCard`):一张可配置的单页 SVG 用量卡——自选时间范围 × 范围(总体 / 工程 / 会话)× 展示哪些指标,以及主题(**Claude 经典橙** / **奶油** / **极光暗色** / **自动**),可选带上 GitHub 头像与名称。自包含、可复现;提示词、路径、ID 一律不出本机。中文语言下使用 万/亿 单位。 +- **用量分享卡**(可选,`enableShareCard`):一张可配置的单页 SVG 用量卡——自选时间范围 × 范围(总体 / 工程 / 会话)× 展示哪些指标,以及主题(**Claude 经典橙** / **奶油** / **极光暗色** / **自动**)。预览和本地导出不会获取 GitHub 身份数据。自包含、可复现;提示词、路径、ID 一律不出本机。中文语言下使用 万/亿 单位。 - **只读会话查看器**(会话标签,**默认开启**):每行的「查看」按钮以只读方式重开某个历史会话的提示词与 Markdown 渲染的回答,帮你回忆内容,而**不必**像「恢复」那样把它重新塞回模型上下文。思考与工具调用收进折叠区,默认展示最近若干轮。 - **Token 热力图**(可选,`showHeatmap`):All 标签顶部的 GitHub 风格年度 token 热力图,并可**导出 / 发布到你的 GitHub 主页**(自包含 SVG,一键复制 Markdown 嵌入代码)。 - **实验性洞察**(可选,`showInsights`,Content 标签):基于本地日志的启发式估算(均标注为估算)——**缓存损耗账单**(模型切换 / 空闲导致重写缓存的花费)、**各模型缓存保温时长**、**大单轮**、**你的活跃时段**、以及**技能 ROI**(每美元产出的输出 token)。 @@ -117,7 +137,7 @@ AI 个性化仍为可选项。自备 `advice.apiKey` 后,默认请求只含聚 - **用量追踪面板**:对标官方 `/usage` 的"用量构成"视图,但支持全部模型 / 供应商和五档范围(日 / 周 / 月 / 会话 / 项目):>150k 上下文占比、8 小时以上会话占比、子代理密集占比、工作流占比,以及 Skills/子代理/插件/模型四类细分。今日标签页有精简卡片。 - **思考占比**:每会话的估算思考 token 占比(Sessions 列 + 今日卡片),过高时提示改用 `/effort`。 - **工作流配额护栏**:当 5 小时窗口剩余不足以完成一次运行时,仪表板显示可关闭的警告横幅(`claudeCodeUsage.workflowQuotaWarnPercent`)。 -- **设置搬进仪表板**:新增 ⚙ 设置标签页,就地管理所有选项;VS Code 原生设置只保留三个适合同步的(`language`、`dataDirectory`、`advice.apiKey`)。右上角按钮精简为 ✨ AI 建议 和 ⚙ 设置(都跳到对应标签);自动刷新开关挪进设置(暂停时右上角才出现手动 ↻)。如果你把成本、配额、上下文三项**全部隐藏**,状态栏会保留一个小图标作为回到仪表板的入口。 +- **设置搬进仪表板**:新增 ⚙ 设置标签页,就地管理大多数选项;VS Code 原生设置保留语言及 Claude/Codex 数据目录。自备 AI 密钥在仪表板填写、保存在 SecretStorage,不随 Settings Sync 同步。右上角按钮精简为 ✨ AI 建议 和 ⚙ 设置(都跳到对应标签);自动刷新开关挪进设置(暂停时右上角才出现手动 ↻)。如果你把成本、配额、上下文三项**全部隐藏**,状态栏会保留一个小图标作为回到仪表板的入口。 - **状态栏指标**(`statusBarMetric`):默认显示今日成本,也可切换为今日**总 token** 消耗(紧凑 k/M)。 - **模型专属每周上限**(`showScopedWeekly`,可选开启):显示 Anthropic 实际返回的受限模型名称,例如 `fable 17%`;由 PR #38 @@ -167,16 +187,18 @@ ext install GrowthJack.claude-code-usage **绝大多数设置现在都在仪表板里。** 打开仪表板(运行 **Show Usage Details**,或点右上角 ⚙),用 **⚙ 设置**标签页 —— 分为「常规 / 状态栏 / 数据与刷新 / AI 建议与优化器」。改动即时生效。 -为了让 VS Code 原生设置 UI 保持清爽,只保留三个设置在那里(以便随 Settings Sync 同步)。打开设置(`Ctrl+,`)搜索 **`Claude Code Usage`**: +为了让 VS Code 原生设置 UI 保持清爽,只保留以下三个普通设置;自备 AI 密钥请在仪表板的 ⚙ 设置中填写,它只存入 SecretStorage,不随 Settings Sync 同步。打开 VS Code 设置(`Ctrl+,`)搜索 **`Claude Code Usage`**: | 设置 | 默认 | 作用 | |---|---|---| | `language` | `"auto"` | 界面语言:`auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`。 | | `dataDirectory` | `""` | 自定义 Claude 数据目录;留空 = 自动检测。 | -| `advice.apiKey` | `""` | AI 建议 + 用量优化器的自备 API key;留空时不能发送请求。 | +| `codex.dataDirectory` | `""` | 自定义 Codex 数据目录;留空 = `CODEX_HOME` 或 `~/.codex`。 | 其余全部 —— 刷新间隔、状态栏各项、数字 / 日期格式、项目分组、内容分析,以及所有 AI 建议 / 优化器选项 —— 都在仪表板的 ⚙ 设置标签页里。升级不丢配置:首次启动会做一次性迁移,把你 `settings.json` 里已有的值搬过来。 +如需自定义 Claude 配额状态栏,在 ⚙ 设置中选择“自定义”。模板支持 `{5h.pct}`、`{wk.pct}`(或 `{7d.pct}`)和 `{model:Fable.pct}`;每个窗口也支持 `.label`、`.reset`,重置样式可用 `:decimal`、`:units`、`:clock`、`:at`,例如 `{5h.pct} | {wk.reset:at}`。未报告的窗口及其分隔符会省略;选“默认”则沿用原有额度选项。 + --- ## 成本是如何计算的 @@ -211,7 +233,7 @@ ext install GrowthJack.claude-code-usage | 建议状态/密钥 | 有界聚合证据;密钥仅在 SecretStorage | 另行点击发送后才传输精确预览过的请求 | - 所有 token / 成本 / session 分析都在**本地**进行,只读取你的 `~/.claude/projects/**/*.jsonl` 文件。 -- 配额指示器用 Claude Code 现有的 OAuth token 调用 **`api.anthropic.com/api/oauth/usage`**,不发送任何额外凭证。 +- 配额指示器用 Claude Code 现有的 OAuth token 调用 **`api.anthropic.com/api/oauth/usage`**。如 token 已过期,插件会将现有 refresh token 发送至 **`console.anthropic.com/v1/oauth/token`**,并把刷新后的凭证写回选中的 Claude 凭证文件或 macOS 钥匙串。详见[本地数据与隐私](LOCAL-DATA.zh-CN.md)。 - **AI 建议**和**用量优化器**是仅有的会调用模型的功能,而且只在你查看完整请求后再次主动点击「发送」时调用。AI 建议默认只发送聚合证据;提示样本与用户上下文需要独立同意,并完整出现在预览中。优化器只包含你粘贴的文字。两者仅使用 `advice.apiUrl` 与自备 `advice.apiKey`;密钥只进入请求头,插件不使用订阅凭据,也没有后台请求。 ### 已知限制 diff --git a/README-zh-TW.md b/README-zh-TW.md index 22d52cad..be3c02a4 100644 --- a/README-zh-TW.md +++ b/README-zh-TW.md @@ -1,14 +1,14 @@ # Claude Code 使用量監控 -🌐 **語言**: [🏠 Main](README.md) | [English](README-en.md) | **繁體中文** | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Bahasa Indonesia](README-id.md) +🌐 **語言**: [🏠 Main](README.md) | [English](README-en.md) | [Deutsch](README-de-DE.md) | **繁體中文** | [简体中文](README-zh-CN.md) | [日本語](README-ja.md) | [한국어](README-ko.md) | [Português (Brasil)](README-pt-BR.md) | [Bahasa Indonesia](README-id.md) --- -**看清 Claude Code 與 Codex 的本地用量,讓 AI 幫你用得更好。** 不是帳單工具。Claude 保留成本與配額檢視;Codex Beta 依自己的 token 與行為語意提供分析。 +**看清 Claude Code 與 Codex 的本地用量,讓 AI 幫你用得更好。** 不是帳單工具。Claude 保留成本與配額檢視;Codex 依自己的 token 與行為語意提供分析。 -> **它是什麼**:一個 VS Code 狀態列小工具,讀取本地 Claude Code 對話日誌,按 token × 公開單價估算用量與成本;並提供可選的 AI 建議功能,幫你優化提示詞、減少不必要的 token 消耗。 +> **它是什麼**:一個 VS Code 狀態列小工具,讀取本地 Claude Code 與 Codex 用量日誌,依各自語意顯示 token、配額與估算值;並提供可選的建議,協助減少不必要的消耗。 > -> **它不是什麼**:帳單工具。顯示金額均為估算值,實際費用請以官方帳單為準。 +> **它不是什麼**:帳單工具。Claude 成本與 Codex API 等效成本均為估算值,不是訂閱扣款或發票;實際帳單請以相應供應商帳戶為準。Codex 配額僅為本地最後觀測,並非即時餘額。 > 截圖包含英文與簡體中文介面。完整功能說明請見[主 README](README.md)。 @@ -48,6 +48,7 @@ ## 功能特色 - **狀態列** — 今日成本、當前 session 成本,以及真實的 5 小時 / 每週配額(`5h:N% wk:N%`),透過 Claude Code 自身的 OAuth 工作階段讀取,無需設定。 +- **配額格式** — ⚙ 設定可選內建(預設)、僅 5 小時或僅每週;選「自訂」時才會顯示範本輸入欄。 - **儀表板分頁** — 今日 / 最近 30 天 / 全部時間,外加 **Sessions / Projects / Content / Branches**,皆可排序。 - **堆疊式成本構成圖**,含 Y 軸與參考線 —— 一眼看出每日 / 每月的成本中,輸入、輸出、快取寫入、快取讀取各佔多少。 - **Content 分頁** — 估算哪些內容消耗你的 token(你的提示 vs 工具結果 vs 助理輸出 / 思考)。 @@ -56,6 +57,21 @@ - **多廠商定價** — Opus 4.x / Sonnet 4.x / Haiku 4.5 對照 Anthropic 官方定價;OpenAI / Gemini / DeepSeek / Kimi / GLM / Qwen 參考價,含家族感知回退。`Refresh Token Pricing` 可拉取 LiteLLM 即時價格。 - **個人化** — 語言、時區、小數位數、精簡數字、專案分組、儀表板自動刷新開關。 +## v2.4 新功能 + +- **Codex 狀態列與捲動**:預設精簡指標顯示今日已處理 Token,獨立的每週額度顯示 + 剩餘比例。捲動期間面板更新會短暫延後,並設有最長等待邊界。 +- **統一、預覽優先的分享工作台**:全寬匯出預覽成為視覺焦點,控制項統一置於下方; + 一個呈現方式選擇器即可切換**綜合活動熱力圖**、**Claude 分享卡**與 **Claude token + 熱力圖**。綜合呈現只在兩個供應商都有真實資料時出現,後兩種舊版呈現仍只使用 Claude 資料。 +- **相容入口,不再重複面板**:`exportShareCard`、`exportHeatmap` 與 + `publishHeatmapToGitHub` 繼續保留並開啟對應預覽。`enableShareCard` 仍是唯一可見的 + 分享開關且預設開啟;退役的 `showHeatmap` 僅為一個版本的狀態相容與有界清除而保留。 +- **嚴格的本機產物與供應商邊界**:切換、預覽和本機 SVG/Markdown 匯出只使用已物化彙總, + 不發出網路請求,也不登入 GitHub 或取得 profile/頭像/名稱。只有 Claude 熱力圖中獨立的 + **發佈到 GitHub** 動作可以連線;它仍僅支援公開儲存庫,並在寫入前確認精確目標及建立/覆寫動作。 + 綜合活動不代表帳單、生產力、能力或跨供應商等價。 + ## v2.3 新功能 - **v2.3 系列持續完善**——新增 GPT-6 Astra、Fable 5.1 模型中繼資料與 diff --git a/README.md b/README.md index faf10925..80edacef 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,13 @@ # Claude Code Usage [![VSCode Marketplace](https://img.shields.io/visual-studio-marketplace/v/growthjack.claude-code-usage?style=flat-square&logo=visual-studio-code&label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=growthjack.claude-code-usage) -[![Open VSX Registry](https://img.shields.io/open-vsx/v/GrowthJack/claude-code-usage?style=flat-square&logo=eclipseide&label=Open%20VSX)](https://marketplace.cursorapi.com/items/?itemName=GrowthJack.claude-code-usage) +[![Open VSX Registry](https://img.shields.io/open-vsx/v/GrowthJack/claude-code-usage?style=flat-square&logo=eclipseide&label=Open%20VSX)](https://open-vsx.org/extension/GrowthJack/claude-code-usage) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT) **The local Claude Code and Codex usage coach in your status bar.** Not a -billing tool. Claude keeps its cost and quota views; the v2.3 Codex Beta adds -provider-specific token and behaviour insights through the same dashboard tabs, -render functions, and visual system without pretending both providers expose -the same data. +billing tool. Claude keeps its cost and quota views; Codex adds provider-specific +token and behaviour insights through the same dashboard tabs, render functions, +and visual system without pretending both providers expose the same data. > **What this is:** A VS Code status-bar monitor that reads your local > Claude Code and Codex logs and shows provider-appropriate usage views — plus @@ -23,7 +22,7 @@ the same data. > **看清 Claude Code 与 Codex 的本地用量,让 AI 帮你用得更好。** > > **简介**:一个 VS Code 状态栏小工具。Claude 保留成本与配额视图; -> v2.3 的 Codex Beta 则按 Codex 自身的数据语义展示 token、effort、任务结构 +> Codex 则按自身的数据语义展示 token、effort、任务结构 > 和本地优化建议,同时复用 Claude 仪表盘的标签页、渲染函数和视觉体系, > 而不是另做一套页面或强行套用 Claude 的统计口径。 > @@ -33,10 +32,12 @@ the same data. 🌐 **Multi-language documentation**: [English](README-en.md) · +[Deutsch](README-de-DE.md) · [繁體中文](README-zh-TW.md) · [简体中文](README-zh-CN.md) · [日本語](README-ja.md) · [한국어](README-ko.md) · +[Português (Brasil)](README-pt-BR.md) · [Bahasa Indonesia](README-id.md) --- @@ -100,6 +101,8 @@ Hover the quota indicator for a breakdown: *Real `/usage` data: utilisation percent, plus time left and the wall-clock reset for every window.* *Every weekly cap your plan meters gets its own row, per-model ones included (Anthropic supplies the name, so the row follows whichever model is capped), plus usage credits when you have them enabled.* +⚙ Settings offers a compact quota-format dropdown: Built-in (default), 5-hour only, Weekly only, or Custom. Only Custom reveals the template field. + ### Dashboard ![Dashboard — summary and charts](images/v2-dashboard-en.png) @@ -160,6 +163,38 @@ same full-request preview and separate explicit Send action as AI advice. --- +## What's new in 2.4 + +- **Codex status and scrolling** — the default compact Token item shows today's + processed amount; the separate weekly indicator shows remaining capacity. + Live panel updates wait briefly for scrolling to pause, with a bounded delay. +- **One preview-first sharing workspace** — a full-width export preview now + leads the surface, with controls below it and one presentation selector for + the **Combined activity heatmap**, **Claude Share Card**, and **Claude token + heatmap**. The combined presentation appears only with real data from both + providers; both legacy presentations remain Claude-only. +- **Compatibility without duplicate panels** — `exportShareCard`, + `exportHeatmap`, and `publishHeatmapToGitHub` remain available and open the + matching presentation instead of bypassing preview. `enableShareCard` remains + the single visible sharing switch and still defaults to on; the retired + `showHeatmap` value is retained only for one-release state compatibility and + bounded clearing. +- **Strict local-artifact boundary and provider truth** — switching, previewing, + and local SVG/Markdown export use materialized aggregates only and perform no + network request, GitHub sign-in, or profile/avatar/name lookup. Only the + separate **Publish to GitHub** action for the Claude heatmap can connect; it + remains public-repository-only and confirms the exact target and create/ + overwrite action before writing. Combined activity is not billing, + productivity, capability, or cross-provider equivalence. + +![v2.4 combined sharing workspace, light theme](images/v2.4.0/compare-sharing-en-light.png) + +This is a production-renderer capture with synthetic usage and VS Code theme +variables, not evidence from an installed VSIX or a real account. The +[Claude Share Card](images/v2.4.0/claude-sharing-zh-CN-dark.png) and +[360 px dark-theme view](images/v2.4.0/narrow-sharing-de-DE-dark.png) use the same +fixture boundary. + ## What's new in 2.3 - **Refined throughout the v2.3 line** — GPT-6 Astra and Fable 5.1 model @@ -279,7 +314,8 @@ same full-request preview and separate explicit Send action as AI advice. - **Usage share card** (opt-in, `enableShareCard`) — a themed, configurable one-page SVG of your usage: pick a range × scope (overall / project / session) × which metrics to show, and a theme (**Claude Classic** / **Cream** / - **Aurora Dark** / **Auto**), with an optional GitHub avatar + name. + **Aurora Dark** / **Auto**). Preview and local export never fetch GitHub + identity data. Self-contained and deterministic; no prompts, paths or ids ever leave your machine. Chinese locales use 万/亿 units. - **Read-only conversation viewer** (Sessions tab, **on by default**) — a "view" @@ -341,9 +377,10 @@ same full-request preview and separate explicit Send action as AI advice. - **Workflow quota guard** — a dismissible banner before you start a run the remaining 5-hour window can't finish (`claudeCodeUsage.workflowQuotaWarnPercent`). -- **Settings in the dashboard** — a new ⚙ Settings tab manages every option - in place; VS Code's own Settings keeps only the three that benefit from - syncing (`language`, `dataDirectory`, `advice.apiKey`). Header buttons +- **Settings in the dashboard** — a new ⚙ Settings tab manages most options + in place; VS Code's own Settings keeps the language and provider data + directories. The advice API key is entered in the dashboard and stored in + SecretStorage, not Settings Sync. Header buttons trimmed to ✨ AI advice and ⚙ Settings (both jump to their tab); the auto-refresh toggle moved into Settings (a manual ↻ appears when paused). If you hide the cost, quota *and* context items, the status bar keeps a small @@ -417,7 +454,7 @@ ext install GrowthJack.claude-code-usage ### Cursor / Windsurf / Antigravity (Open VSX) Same extension is published at the Open VSX Registry: -[GrowthJack.claude-code-usage](https://marketplace.cursorapi.com/items/?itemName=GrowthJack.claude-code-usage). +[GrowthJack.claude-code-usage](https://open-vsx.org/extension/GrowthJack/claude-code-usage). ### From a `.vsix` file @@ -433,8 +470,10 @@ downloaded `.vsix`. tab — grouped into General, Status bar, Data & refresh, and AI advice & Optimizer. Changes apply immediately. -To keep VS Code's own Settings UI uncluttered, only four settings stay there -(so they still travel with Settings Sync). Open Settings (`Ctrl+,`) and search +To keep VS Code's own Settings UI uncluttered, only three ordinary settings +stay there. The BYOK advice key is entered in the dashboard's ⚙ Settings tab +and stored in SecretStorage; it does not travel with Settings Sync. Open VS Code +Settings (`Ctrl+,`) and search for **`Claude Code Usage`**: | Setting | Default | What it does | @@ -442,13 +481,19 @@ for **`Claude Code Usage`**: | `language` | `"auto"` | UI language: `auto` / `en` / `de-DE` / `zh-TW` / `zh-CN` / `ja` / `ko` / `pt-BR` / `id`. | | `dataDirectory` | `""` | Custom Claude data dir; empty = auto-detect. | | `codex.dataDirectory` | `""` | Custom Codex home; empty = `CODEX_HOME` or `~/.codex`. | -| `advice.apiKey` | `""` | Bring-your-own key for AI advice + the Usage Optimizer; empty means no request can be sent. | Everything else — refresh interval, status-bar items, number/date formatting, project grouping, content analysis, and all the AI advice / Optimizer options — is in the dashboard's ⚙ Settings tab. Upgrading keeps your existing values: a one-time migration copies them out of `settings.json` on first launch. +For a custom quota status-bar layout, choose **Custom** in ⚙ Settings. The +template accepts `{5h.pct}`, `{wk.pct}` (or `{7d.pct}`), and +`{model:Fable.pct}`; each window also supports `.label` and `.reset`. +Reset styles include `:decimal`, `:units`, `:clock`, and `:at`, for example +`{5h.pct} | {wk.reset:at}`. Missing windows and their separators are omitted. +The built-in choice leaves the existing quota options unchanged. + --- ## How costs are calculated @@ -514,7 +559,10 @@ remote boundaries are in [Local data and privacy](LOCAL-DATA.md) ([简体中文] effort, role, time, and quality metadata. It never stores raw IDs, full paths or repository URLs, thread titles, or conversation bodies. - The quota indicator calls **`api.anthropic.com/api/oauth/usage`** using - Claude Code's existing OAuth token. No additional credentials are sent. + Claude Code's existing OAuth token. If that token has expired, the extension + sends the existing refresh token to **`console.anthropic.com/v1/oauth/token`** + and writes the refreshed credentials back to the selected Claude credential + file or macOS Keychain item. See [Local data and privacy](LOCAL-DATA.md). - **AI advice** and the **Usage Optimizer** are the only features that call a model — and only after *you* preview and explicitly send a prepared request. Advice defaults to allowlisted aggregates; prompt samples and optional user diff --git a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md index 684f0e98..c74b295a 100644 --- a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md +++ b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.md @@ -27,12 +27,12 @@ This document is the source of truth for data read, retained, derived, displayed | P3 | Latest Claude quota response **migration input** | `ccu.usageLimits.` in globalState | Legacy response plus timestamp | v2.3.1 reads once, normalizes into P2, then removes it. No raw response is a long-term source of truth | No additional request | | P4 | Legacy Claude weekly history **migration input** | `ccu.weeklyQuotaHistory.v1.` in globalState | Legacy normalized observations | v2.3.1 imports, deduplicates into P2, then removes it | None | | P5 | Legacy Codex quota history **migration input** | `quotaHistory` inside P1 | provider, observed/reset time, used percent | v2.3.1 normalizes into schema 2 without inventing an account. Rebuilding P1 must not erase already migrated P2 history | None | -| P6 | Extension preferences and migration flags | Exact catalogued `ccu.setting.` names, `ccu.lastSeenVersion`, `ccu.codex.backgroundWork.v1`, migration flags, and `ccu.localData.pendingClientReset.v1` in globalState; language and data directories in VS Code configuration | Typed preferences, bounded worker status, schema/migration flags, or one value-free pending client action enum | Preferences remain until reset/clear/uninstall; the pending action remains only until a verified Webview acknowledgement or Clear All | Settings Sync may sync VS Code configuration according to the user's VS Code account; extension globalState is not intentionally uploaded by this extension | +| P6 | Extension preferences and migration flags | Exact catalogued `ccu.setting.` names, `ccu.lastSeenVersion`, `ccu.codex.backgroundWork.v1`, migration flags, and `ccu.localData.pendingClientReset.v1` in globalState; language and data directories in VS Code configuration | Typed preferences, bounded worker status, schema/migration flags, or one value-free pending client action with a monotonic revision | Preferences remain until reset/clear/uninstall; the pending action remains only until its exact revision receives a verified Webview acknowledgement or Clear All | Settings Sync may sync VS Code configuration according to the user's VS Code account; extension globalState is not intentionally uploaded by this extension | | P7 | Machine-local pseudonymization material | `ccu.codex.machineSalt` plus versioned local fingerprint salt | Random secret salt and algorithm version only | Until all derived data is cleared. Clearing it invalidates old fingerprints rather than relinking them | Never | | P8 | BYOK advice credential | `claudeCodeUsage.secret.advice.apiKey` in VS Code SecretStorage | Secret value only | Until the user clears the key or extension data. VS Code documents SecretStorage as encrypted and not synchronized across machines; the concrete protection mechanism remains host-dependent | Sent only as an authorization credential to the user-configured endpoint after a separate Send action; never included in request body or preview | | P9 | Advice-effectiveness ledger | `ccu.adviceEffectiveness.localState`, schema 3; legacy feedback key may exist | Consent mode, feedback, suppression records, comparable-pair metrics and results; no prompt body/path/account | Bounded by implementation (feedback/suppression 500, pairs/results 200). Clear Advice Data removes current and known legacy keys | Advice payload is sent only after preview and explicit Send | | P10 | Webview preferences | Webview state and localStorage keys `ccu.activeTab`, `ccu.sessionFilter`, `ccu.sessionRange`, `ccu.sessionModel` | Tab, sort/filter/range, expanded state, scroll and chart selection | Until Reset UI State, Webview storage clear, or uninstall | Never | -| P11 | Heatmap/share preferences | `showHeatmap`, share-card settings, Webview localStorage `ccu.combinedHeatmap.title`, `ccu.combinedHeatmap.range`, `ccu.combinedHeatmap.intensityMode`, `ccu.combinedHeatmap.privacyPreview`, `ccu.combinedHeatmap.palette`, `ccu.combinedHeatmap.customAccent`, plus globalState `ccu.heatmapRepo` and `ccu.heatmapPath` | Bounded title (80 characters), range/intensity/palette enums, privacy-preview boolean, validated six-digit hex accent, and optional GitHub `owner/repo/path` destination strings | Until Reset Sharing Preferences, Webview storage clear, or all derived data clear | Local SVG/Markdown export has none. Direct publication is public-repository-only in v2.3.1, requires an exact destination confirmation every time, and never caches GitHub credentials | +| P11 | Sharing-workspace preferences | Active `enableShareCard`, retired compatibility `showHeatmap`, Webview localStorage `ccu.sharing.template`, `ccu.combinedHeatmap.title`, `ccu.combinedHeatmap.range`, `ccu.combinedHeatmap.intensityMode`, `ccu.combinedHeatmap.privacyPreview`, `ccu.combinedHeatmap.palette`, `ccu.combinedHeatmap.customAccent`, plus one versioned globalState object `ccu.heatmapDestination.v1`; legacy `ccu.heatmapRepo` and `ccu.heatmapPath` remain read/clear-only migration inputs | Presentation/range/intensity/palette enums, bounded title (80 characters), privacy-preview boolean, validated six-digit hex accent, and one optional GitHub `owner/repo/path` destination object. Explicit command revisions are provider-lifetime runtime coordination, not browser data | Preferences remain until Reset Sharing Preferences, Webview storage clear, or all derived data clear | Local preview and SVG/Markdown export have none and do not request GitHub identity. Direct publication is public-repository-only, requires an exact destination/action confirmation every time, and never caches GitHub credentials. A successful PUT is reported even if preference persistence fails | | M1 | Runtime-only Claude/Codex views | Extension-host memory | Records, prepared aggregates, charts, current session mappings, bounded content sample | Released on reload/exit | None unless included in a separately previewed request/export | | M2 | Runtime credentials and network responses | Extension-host memory or provider-owned credential store | OAuth access/refresh token, transient headers, response/error object | Never retained by this extension. Provider refresh may update the provider-owned credential store | Anthropic OAuth/quota endpoint or user-configured advice endpoint only | @@ -128,7 +128,7 @@ Provider daily inputs are already deduplicated aggregates. If an identical provi - Deterministic local SVG and Markdown snippets may contain only selected date range, title, normalized daily aggregates, provider labels, totals, and explicit caveats. - Export/share payloads must pass privacy-canary tests proving absence of account IDs, fingerprints, project/thread names, repository names, local paths, prompt/log text, and credentials. -- Optional GitHub owner/repo/path preferences are destinations, not credentials. GitHub authentication remains transient in the VS Code authentication provider. v2.3.1 requests only `public_repo`, verifies public visibility and the default branch, previews the exact owner/repo/branch/path and create-or-overwrite action, and persists destination strings only after a successful confirmed write. Private repositories use local SVG + Markdown instead of silently escalating to broad `repo` access. +- Optional GitHub owner/repo/path preferences are destinations, not credentials. Local sharing preview/export performs no authentication or identity/profile lookup. Only the separate publish action uses transient GitHub authentication from the VS Code authentication provider, requests `public_repo`, verifies public visibility and the default branch, previews the exact owner/repo/branch/path and create-or-overwrite action, and persists destination strings only after a successful confirmed write. Private repositories use local SVG + Markdown instead of silently escalating to broad `repo` access. - v2.3.1 release validation uses mocked GitHub publication only. No Goal step may write to a real repository. - AI advice requires exact request preview and a separate Send action. Quota tracking sends no local logs. Pricing refresh sends no user usage. diff --git a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md index 88330826..f46a81b5 100644 --- a/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md +++ b/docs/superpowers/specs/2026-09-02-v2.3.1-local-data-contract.zh-CN.md @@ -27,12 +27,12 @@ | P3 | 最新 Claude 额度 response **迁移输入** | globalState `ccu.usageLimits.` | 旧版 response 与时间戳 | v2.3.1 读取一次并规范化到 P2,成功后删除;完整 response 不再作为长期事实 | 不新增请求 | | P4 | Claude 周额度历史 **迁移输入** | globalState `ccu.weeklyQuotaHistory.v1.` | 旧版规范化观测 | 导入 P2、去重后删除 | 无 | | P5 | Codex 旧额度历史 **迁移输入** | P1 内 `quotaHistory` | provider、观测/重置时间、已用百分比 | 迁移为 schema 2,不虚构账号;重建 P1 不得抹掉已迁移 P2 历史 | 无 | -| P6 | 插件设置与迁移标记 | globalState 中精确列入清单的 `ccu.setting.`、`ccu.lastSeenVersion`、`ccu.codex.backgroundWork.v1`、迁移 flag 与 `ccu.localData.pendingClientReset.v1`;语言和数据目录在 VS Code configuration | 类型化偏好、有界后台任务状态、schema/迁移标记,或一个不含数据值的待执行客户端动作枚举 | 偏好保留到重置/清除/卸载;待执行动作只保留到 Webview 验证确认或清除全部 | VS Code configuration 可能按用户的 VS Code 账号设置参与 Settings Sync;插件自身不主动上传 globalState | +| P6 | 插件设置与迁移标记 | globalState 中精确列入清单的 `ccu.setting.`、`ccu.lastSeenVersion`、`ccu.codex.backgroundWork.v1`、迁移 flag 与 `ccu.localData.pendingClientReset.v1`;语言和数据目录在 VS Code configuration | 类型化偏好、有界后台任务状态、schema/迁移标记,或一个带单调 revision 且不含数据值的待执行客户端动作 | 偏好保留到重置/清除/卸载;待执行动作只保留到其精确 revision 获得 Webview 验证确认或清除全部 | VS Code configuration 可能按用户的 VS Code 账号设置参与 Settings Sync;插件自身不主动上传 globalState | | P7 | 本机匿名化材料 | `ccu.codex.machineSalt` 与带版本的 fingerprint salt | 随机盐和算法版本 | 保留到清除全部派生数据;清除后旧 fingerprint 失效,不跨盐重连 | 永不外发 | | P8 | AI 建议 BYOK 凭证 | VS Code SecretStorage 的 `claudeCodeUsage.secret.advice.apiKey` | secret 值本身 | 用户清除 key 或清除插件数据前保留;VS Code 官方说明 SecretStorage 会加密且不跨设备同步,具体保护机制仍取决于宿主实现 | 只有用户预览后再次点击 Send,才作为授权凭证发送给用户配置的 endpoint;不进入请求正文或预览 | | P9 | 建议效果账本 | `ccu.adviceEffectiveness.localState` schema 3;可能仍有旧 feedback key | consent 模式、反馈、抑制记录、可比较任务指标和结果;无 prompt/path/account | 实现上限:反馈/抑制约 500,对与结果约 200;“清除建议数据”同时删除当前键和已知旧键 | 请求只有在预览后显式 Send 才外发 | | P10 | Webview 偏好 | Webview state 与 localStorage:`ccu.activeTab`、`ccu.sessionFilter`、`ccu.sessionRange`、`ccu.sessionModel` | tab、排序/筛选/范围、展开状态、滚动和图表选择 | 直到重置 UI、清除 Webview 存储或卸载 | 无 | -| P11 | 热力图/分享偏好 | `showHeatmap`、share-card 设置、Webview localStorage `ccu.combinedHeatmap.title`、`ccu.combinedHeatmap.range`、`ccu.combinedHeatmap.intensityMode`、`ccu.combinedHeatmap.privacyPreview`、`ccu.combinedHeatmap.palette`、`ccu.combinedHeatmap.customAccent`,以及 globalState `ccu.heatmapRepo`、`ccu.heatmapPath` | 有界标题(80 字符)、范围/色阶/配色枚举、隐私预览布尔值、经校验的六位十六进制主色,以及可选 GitHub `owner/repo/path` 目的地字符串 | 直到重置分享偏好、清除 Webview 存储或清除全部派生数据 | 本地 SVG/Markdown 导出不联网。v2.3.1 直发只支持公开仓库,每次均须精确确认目标,且绝不缓存 GitHub 凭证 | +| P11 | 分享工作台偏好 | 当前 `enableShareCard`、退役兼容 `showHeatmap`、Webview localStorage `ccu.sharing.template`、`ccu.combinedHeatmap.title`、`ccu.combinedHeatmap.range`、`ccu.combinedHeatmap.intensityMode`、`ccu.combinedHeatmap.privacyPreview`、`ccu.combinedHeatmap.palette`、`ccu.combinedHeatmap.customAccent`,以及单个版本化 globalState 对象 `ccu.heatmapDestination.v1`;旧 `ccu.heatmapRepo`、`ccu.heatmapPath` 只作为只读/可清除迁移输入保留 | 呈现方式/范围/色阶/配色枚举、有界标题(80 字符)、隐私预览布尔值、经校验的六位十六进制主色,以及单个可选 GitHub `owner/repo/path` 目的地对象。显式命令 revision 只属于 provider 生命周期内的运行时协调,不是浏览器数据 | 偏好保留到重置分享偏好、清除 Webview 存储或清除全部派生数据 | 本地预览与 SVG/Markdown 导出不联网,也不请求 GitHub 身份。直发只支持公开仓库,每次均须精确确认目标与动作,且绝不缓存 GitHub 凭证。即使偏好持久化失败,成功的 PUT 仍会如实报告 | | M1 | Claude/Codex 运行期视图 | Extension Host 内存 | records、prepared aggregate、图表、当前会话映射和有界内容样本 | reload/退出即释放 | 除非进入单独预览并确认的请求/导出,否则无 | | M2 | 运行期凭证和网络 response | Extension Host 内存或 provider 自有凭证存储 | OAuth access/refresh token、临时 header、response/error object | 插件绝不长期保留;provider 刷新流程可能更新其自有凭证存储 | 仅 Anthropic OAuth/额度端点或用户配置的建议端点 | @@ -124,7 +124,7 @@ Provider 日输入本身已经去重。如果分享边界仍收到同一 provide - 确定性本地 SVG 与 Markdown 片段只能包含所选日期范围、标题、规范化日聚合、provider 标签、总计和明确限制说明。 - 导出/分享必须通过隐私 canary 测试,确保不存在账号 ID/fingerprint、项目/线程名、repo 名、本地路径、prompt/log 文本或凭证。 -- GitHub owner/repo/path 是目的地偏好,不是凭证;GitHub 登录只临时使用 VS Code authentication provider。v2.3.1 只请求 `public_repo`,验证仓库为公开、核实默认分支,写入前预览精确 owner/repo/branch/path 以及创建或覆盖动作,并仅在确认写入成功后保存目的地字符串。私有仓库使用本地 SVG + Markdown,不静默升级到宽泛 `repo` 权限。 +- GitHub owner/repo/path 是目的地偏好,不是凭证;本地分享预览/导出不执行 authentication 或身份/profile 查询。只有独立的发布动作会临时使用 VS Code authentication provider 中的 GitHub 登录,请求 `public_repo`,验证仓库为公开、核实默认分支,写入前预览精确 owner/repo/branch/path 以及创建或覆盖动作,并仅在确认写入成功后保存目的地字符串。私有仓库使用本地 SVG + Markdown,不静默升级到宽泛 `repo` 权限。 - v2.3.1 的发布验证只允许 mock GitHub publish;Goal 全程不得写入真实仓库。 - AI 建议必须先给出精确请求预览,再单独点击 Send;额度跟踪不发送本地日志;价格刷新不发送用户用量。 diff --git a/images/v2.4.0/claude-sharing-zh-CN-dark.png b/images/v2.4.0/claude-sharing-zh-CN-dark.png new file mode 100644 index 00000000..4d16facd Binary files /dev/null and b/images/v2.4.0/claude-sharing-zh-CN-dark.png differ diff --git a/images/v2.4.0/codex-month-zh-CN-dark.png b/images/v2.4.0/codex-month-zh-CN-dark.png new file mode 100644 index 00000000..62db2dcb Binary files /dev/null and b/images/v2.4.0/codex-month-zh-CN-dark.png differ diff --git a/images/v2.4.0/compare-sharing-en-light.png b/images/v2.4.0/compare-sharing-en-light.png new file mode 100644 index 00000000..6a40e0d3 Binary files /dev/null and b/images/v2.4.0/compare-sharing-en-light.png differ diff --git a/images/v2.4.0/narrow-sharing-de-DE-dark.png b/images/v2.4.0/narrow-sharing-de-DE-dark.png new file mode 100644 index 00000000..78338db4 Binary files /dev/null and b/images/v2.4.0/narrow-sharing-de-DE-dark.png differ diff --git a/package.json b/package.json index 64cbcca6..20caedea 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "claude-code-usage", "displayName": "Claude Code Usage", - "description": "Local Claude Code and Codex usage insights in the VS Code status bar", + "description": "Local Claude Code and Codex token usage and quota insights in the VS Code status bar", "version": "2.1.1", "publisher": "GrowthJack", "author": "GrowthJack", @@ -26,7 +26,10 @@ "ai", "anthropic", "token", - "status-bar" + "status-bar", + "codex-usage", + "token-usage", + "quota" ], "engines": { "vscode": "^1.74.0" diff --git a/src/claudeIncrementalIndex.ts b/src/claudeIncrementalIndex.ts index e4917755..86d8d62c 100644 --- a/src/claudeIncrementalIndex.ts +++ b/src/claudeIncrementalIndex.ts @@ -13,6 +13,7 @@ import { AnalysisStructuralEvent, analyzeLine, ClaudeDataLoader, + compactUsageRecord, finalizeAnalysis, mergeAnalysisAcc, newAnalysisAcc, @@ -26,7 +27,7 @@ import { rollingDayKeys, } from './dateKeys'; import { I18n } from './i18n'; -import { isRetryDuplicatePrompt } from './promptDedup'; +import { detachedPromptPrefix, isRetryDuplicatePrompt } from './promptDedup'; import { buildProjectUsageMatrixSnapshot, ProjectUsageMatrixSnapshot, @@ -272,6 +273,9 @@ export interface ClaudeUsageAggregateSnapshot { allTime: UsageData; dailyForLast30Days: { date: string; data: UsageData }[]; dailyForMonth: { date: string; data: UsageData }[]; + /** All indexed local-calendar days. Kept host-side so month drill-downs can + * filter materialized aggregates instead of rescanning raw usage records. */ + dailyForAllTime: { date: string; data: UsageData }[]; monthlyForAllTime: { date: string; data: UsageData }[]; hourlyForToday: { hour: string; data: UsageData }[]; /** Sparse, already-materialized hours for active days in the rolling @@ -302,7 +306,7 @@ export interface ClaudeUsageDashboardSnapshot extends ClaudeUsageAggregateSnapsh interface FilePlan { kind: 'append' | 'rebuild'; - analysisReason?: 'source' | 'cutoff' | 'ownership' | 'full'; + analysisReason?: 'source' | 'cutoff' | 'window' | 'ownership' | 'full'; entry: UsageFileFingerprint; fileId: string; orderTimestampMs?: number; @@ -1400,7 +1404,7 @@ async function parsePlan( const role = message?.role ?? parsed.type; if (agentInfo && base.agentTask === undefined && role === 'user') { const task = textFromUserContent(message?.content, ' ').replace(/\s+/g, ' ').trim(); - if (task) base.agentTask = task.slice(0, 200); + if (task) base.agentTask = detachedPromptPrefix(task, 200); } if (!isSubagentFile && role === 'user' && !parsed.isMeta && !parsed.isSidechain && @@ -1412,7 +1416,7 @@ async function parsePlan( timestamp: parsed.timestamp, message: { usage: { input_tokens: 0, output_tokens: 0 } }, _isUserPrompt: true, - _promptText: text.trim().slice(0, 4000), + _promptText: detachedPromptPrefix(text.trim(), 4000), _sessionId: sessionInfo.sessionId, _projectDirEncoded: sessionInfo.projectPath, }; @@ -1440,7 +1444,7 @@ async function parsePlan( } if (!validateUsageRecord(parsed)) return; - const record = parsed as unknown as ClaudeUsageRecord; + const record = compactUsageRecord(parsed as unknown as ClaudeUsageRecord); record._sessionId = sessionInfo.sessionId; record._projectDirEncoded = sessionInfo.projectPath; const cwd = parsed.cwd; @@ -2276,6 +2280,9 @@ export function claudeUsageAggregateSnapshot( .filter(([day]) => day.startsWith(configuredMonth)) .map(([date, data]) => ({ date, data: cloneUsageData(data) })) .sort((left, right) => right.date.localeCompare(left.date)); + const dailyForAllTime = [...index.aggregates.byLocalDay.entries()] + .map(([date, data]) => ({ date, data: cloneUsageData(data) })) + .sort((left, right) => right.date.localeCompare(left.date)); const monthlyForAllTime = [...index.aggregates.byMonth.entries()] .map(([month, data]) => ({ date: `${month}-01`, data: cloneUsageData(data) })) .sort((left, right) => right.date.localeCompare(left.date)); @@ -2288,6 +2295,7 @@ export function claudeUsageAggregateSnapshot( allTime: cloneUsageData(index.aggregates.allTime), dailyForLast30Days, dailyForMonth, + dailyForAllTime, monthlyForAllTime, hourlyForToday, hourlyForLast30DaysByDay, @@ -2495,12 +2503,23 @@ export async function updateClaudeUsageIndex( continue; } if (needsAnalysisBody) { + // A file that only grew still has to be re-read in full when the window + // moved past one of its events: the stored aggregate counts that event, + // and a tail read cannot subtract it. The body is still an append, so + // the file keeps the UUIDs it owned and gains only new ones — the same + // shape as a plain append, and 'window' records that. Calling it + // 'source', as a mid-file edit, is what made this refresh re-read the + // whole corpus on ordinary window drift. + const grewOnly = !bodyUnchanged && + entry.size > sameIdentity.fingerprint.size && + await appendPrefixStillMatches(sameIdentity, entry) && + await appendBoundaryStillMatches(sameIdentity, entry); analysisRebases.delete(fileId); analysisPayloadRebases.delete(fileId); plans.push({ kind: 'rebuild', - analysisReason: bodyUnchanged && !timeZoneChanged && Boolean(sameIdentity.analysis) - ? 'cutoff' + analysisReason: !timeZoneChanged && Boolean(sameIdentity.analysis) + ? (bodyUnchanged ? 'cutoff' : (grewOnly ? 'window' : 'source')) : 'source', entry, fileId, @@ -2518,9 +2537,14 @@ export async function updateClaudeUsageIndex( const plannedKind: FilePlan['kind'] = append && !rebaseChangedPayload ? 'append' : 'rebuild'; + // An append whose file also lost an event to the window cannot be served + // from the stored aggregate — that aggregate still counts the expired + // event — so it has to be re-read in full. The body still only grew: + // 'window' records that, keeping it apart from a genuine mid-file edit. + const windowForcedRebuild = append && rebaseChangedPayload; plans.push({ kind: plannedKind, - analysisReason: 'source', + analysisReason: windowForcedRebuild ? 'window' : 'source', entry, fileId, prior: append && rebasedAnalysis && !rebaseChangedPayload ? rebasedAnalysis : sameIdentity, @@ -2588,14 +2612,43 @@ export async function updateClaudeUsageIndex( let forcedFullAnalysisRebuild = false; if (analyzeContent) { const sourcePlans = plans.filter((plan) => plan.analysisReason === 'source'); - const soleSourcePlan = sourcePlans.length === 1 && plans.length === 1 - ? sourcePlans[0] - : undefined; + // Several sessions appending between two refreshes is the ordinary case on a + // machine running more than one agent, not an edge case: with the fast path + // limited to a single changed file, such a machine never took it and every + // refresh re-read the whole history. Any number of pure tail appends is + // safe here; the per-file UUID ownership check below still guards order. + // A file that simply appeared is as safe as a tail append: its own body is + // read in full, and the established contributions are untouched. Every new + // session starts a new transcript, so treating this as an unsafe mutation + // cost a full rebuild many times a day. + const isTailAppend = (plan: FilePlan): boolean => + plan.kind === 'append' && Boolean(plan.prior) && (plan.prior?.firstTimestampMs ?? 0) > 0; + const isNewFile = (plan: FilePlan): boolean => + plan.kind === 'rebuild' && !plan.prior && !plan.replacedFileId; + // The window keeps drifting, so on a long history almost every refresh also + // carries a file whose oldest event has just fallen out of it. Such a file + // is re-read only to recompute its aggregate under the new cutoff — its + // body on disk is unchanged ('cutoff' is assigned only when bodyUnchanged), + // so it owns exactly the UUIDs it owned before and cannot preempt anyone. + // A verified append can cross that cutoff in the same file; it and other + // appends remain bounded to their changed bodies. UUID ownership preemption + // is checked after parsing, while dropped UUIDs are handed to the ownership + // restoration pass below. + // + // Requiring "appends and nothing else" therefore rejected the fast path on + // ordinary drift: measured on a 650-file history, 136 of 140 refreshes + // re-read every body — 1.4 GB, ~60 s — to serve one appended file. + // 'cutoff' — тело не менялось; 'window' — файл дописан и потерял событие + // за окном. Второй случай может нести новые UUID, поэтому его пропускает + // проверка владения после разбора, ниже. + const isWindowRebuild = (plan: FilePlan): boolean => + plan.analysisReason === 'cutoff' || plan.analysisReason === 'window'; + const appendOnlyPlans = plans.length > 0 && + plans.every((plan) => isTailAppend(plan) || isNewFile(plan) || isWindowRebuild(plan)); const safeTailAppend = Boolean( - soleSourcePlan?.kind === 'append' && soleSourcePlan.prior && + appendOnlyPlans && previousAnalysisRuntime && !timeZoneChanged && - previous.windowDays === windowDays && analysisPayloadRebases.size === 0 && - soleSourcePlan.prior.firstTimestampMs > 0, + previous.windowDays === windowDays, ); const priorAnalysisCutoffMs = previousAnalysisRuntime?.cutoffMs ?? (previous.analyzeContent ? previous.analysisCutoffMs : undefined); @@ -2660,7 +2713,9 @@ export async function updateClaudeUsageIndex( analyzeContent && previousAnalysisRuntime && previous.contentAnalysis && !forcedFullAnalysisRebuild && calibrationCutoffIsStable && analysisPayloadRebases.size === 0 && deletions.length === 0 && moves.length === 0 && - plans.every((plan) => plan.kind === 'append' && Boolean(plan.prior)), + plans.every((plan) => + (plan.kind === 'append' && Boolean(plan.prior)) || + (plan.kind === 'rebuild' && !plan.prior && !plan.replacedFileId)), ); await options.beforeBodyReads?.(); @@ -2705,11 +2760,12 @@ export async function updateClaudeUsageIndex( for (const uuid of file.analysis?.seenUuids ?? []) analysisSeenUuids.add(uuid); } } - const rebuildPlans = plans.filter((plan) => plan.kind === 'rebuild'); - if (rebuildPlans.length > 0) { + // The first file to carry a UUID owns it, so parsing has to follow the + // same order the full loader uses. With one plan the order is moot, but + // several changed files must be parsed in full-scan order — otherwise + // an expiring-window rebuild can claim a UUID before an earlier append. + if (plans.length > 1) { plans.sort((left, right) => { - if (left.kind !== right.kind) return left.kind === 'rebuild' ? -1 : 1; - if (left.kind === 'append') return 0; return (left.orderTimestampMs ?? 0) - (right.orderTimestampMs ?? 0) || left.entry.discoveryIndex - right.entry.discoveryIndex; }); @@ -2738,21 +2794,47 @@ export async function updateClaudeUsageIndex( // collision changes the full loader's first owner, so retry the analysis as // one globally ordered rebuild. The first tail read remains bounded and is // the evidence used to choose the safe path. - if (fastAppendAnalysis && previousAnalysisRuntime && parsedPlans.length === 1 && - parsedPlans[0].kind === 'append') { - const appended = parsedPlans[0]; - const filePosition = previousAnalysisRuntime.filePositionById.get(appended.fileId) ?? -1; - let laterOwnerCollision = filePosition < 0; - for (const uuid of appended.analysisTouchedUuids) { + // + // A 'window' plan is an append too — the file only grew, it is re-read in + // full solely because its stored aggregate still counts an event that has + // since left the window. Its appended lines can carry UUIDs owned by a + // later file exactly like a plain append, so it is probed here as well; + // that probe is what allows it to skip the full rebuild in the first place. + const windowRebasedAppends = parsedPlans.some( + (plan) => plan.analysisReason === 'window', + ); + if ((fastAppendAnalysis || windowRebasedAppends) && + previousAnalysisRuntime && parsedPlans.length > 0) { + let laterOwnerCollision = false; + for (const appended of parsedPlans) { if (laterOwnerCollision) break; - const ownerFileId = previousAnalysisRuntime.firstUuidFileByUuid.get(uuid); - if (!ownerFileId || ownerFileId === appended.fileId) continue; - const ownerPosition = previousAnalysisRuntime.filePositionById.get(ownerFileId); - if (ownerPosition === undefined) { + if (appended.kind === 'rebuild' && !appended.prior) { + // A brand-new file owns only UUIDs nobody else has: anything else can + // move ownership, which the full loader resolves by scan order. + for (const uuid of appended.analysisTouchedUuids) { + if (previousAnalysisRuntime.firstUuidFileByUuid.has(uuid)) { + laterOwnerCollision = true; + break; + } + } + continue; + } + const filePosition = previousAnalysisRuntime.filePositionById.get(appended.fileId) ?? -1; + if (filePosition < 0) { laterOwnerCollision = true; break; } - laterOwnerCollision = ownerPosition > filePosition; + for (const uuid of appended.analysisTouchedUuids) { + if (laterOwnerCollision) break; + const ownerFileId = previousAnalysisRuntime.firstUuidFileByUuid.get(uuid); + if (!ownerFileId || ownerFileId === appended.fileId) continue; + const ownerPosition = previousAnalysisRuntime.filePositionById.get(ownerFileId); + if (ownerPosition === undefined) { + laterOwnerCollision = true; + break; + } + laterOwnerCollision = ownerPosition > filePosition; + } } if (laterOwnerCollision) { forcedFullAnalysisRebuild = true; @@ -2890,18 +2972,43 @@ export async function updateClaudeUsageIndex( // calibration identities, and the already-bounded prompt/skill samples. const merged = cloneAnalysisAcc(previousAnalysisRuntime.merged); const appended = new Map(); + const addedFileIds: string[] = []; for (const plan of parsedPlans) { - if (!plan.prior?.analysis || !plan.contribution.analysis) continue; - addAppendAnalysisDelta(merged, plan.prior.analysis, plan.contribution.analysis); + if (!plan.contribution.analysis) continue; + if (plan.prior?.analysis) { + addAppendAnalysisDelta(merged, plan.prior.analysis, plan.contribution.analysis); + } else { + // A file that just appeared has no prior contribution: its whole + // analysis is the delta. + addAppendAnalysisDelta( + merged, + newAnalysisAcc(analysisCutoffMs), + plan.contribution.analysis, + ); + addedFileIds.push(plan.fileId); + } appended.set(plan.fileId, plan.contribution.analysis); } - const promptTail = refreshedPromptTail(previousAnalysisRuntime, appended); + // Ordering state is reused as is for pure appends — recomputing it would + // touch every historical file. New files have to take their place in the + // order, and that is metadata-only work: no body is re-read. + const orderedState: AnalysisRuntimeState = addedFileIds.length === 0 + ? previousAnalysisRuntime + : (() => { + const orderedFileIds = analysisFilesInOrder(next).map((file) => file.fileId); + return { + ...previousAnalysisRuntime, + orderedFileIds, + filePositionById: new Map(orderedFileIds.map((fileId, at) => [fileId, at])), + }; + })(); + const promptTail = refreshedPromptTail(orderedState, appended); const touchedToolIds = new Set(); for (const plan of parsedPlans) { for (const toolId of plan.analysisTouchedToolIds) touchedToolIds.add(toolId); } const structural = refreshedStructuralRuntime( - previousAnalysisRuntime, + orderedState, merged, appended, touchedToolIds, @@ -2919,7 +3026,22 @@ export async function updateClaudeUsageIndex( ? new Map(previousAnalysisRuntime.firstUuidFileByUuid) : previousAnalysisRuntime.firstUuidFileByUuid; if (firstUuidFileByUuid instanceof Map) { - for (const uuid of analysisUuidAdditions) { + // With more than one appended file, ownership belongs to the file that + // comes first in the established order — not to whichever plan happened + // to be parsed first. + const appendedByOrder = [...parsedPlans].sort((left, right) => + (orderedState.filePositionById.get(left.fileId) ?? 0) - + (orderedState.filePositionById.get(right.fileId) ?? 0)); + const unassigned = new Set(analysisUuidAdditions); + for (const plan of appendedByOrder) { + if (unassigned.size === 0) break; + for (const uuid of plan.analysisTouchedUuids) { + if (!unassigned.delete(uuid)) continue; + firstUuidFileByUuid.set(uuid, plan.fileId); + } + } + // A UUID no appended file claims still needs an owner, as before. + for (const uuid of unassigned) { firstUuidFileByUuid.set(uuid, parsedPlans[0]?.fileId ?? ''); } } @@ -2935,8 +3057,8 @@ export async function updateClaudeUsageIndex( const runtime: AnalysisRuntimeState = { asOfDay: analysisAsOfDay, cutoffMs: analysisCutoffMs, - orderedFileIds: previousAnalysisRuntime.orderedFileIds, - filePositionById: previousAnalysisRuntime.filePositionById, + orderedFileIds: orderedState.orderedFileIds, + filePositionById: orderedState.filePositionById, merged, promptTail, skillHead, diff --git a/src/codexViewComponents.ts b/src/codexViewComponents.ts index d1d56238..39b0f8d5 100644 --- a/src/codexViewComponents.ts +++ b/src/codexViewComponents.ts @@ -31,7 +31,6 @@ export type CodexQualityFlag = (typeof CODEX_QUALITY_FLAGS)[number]; export interface CodexViewCopy { title: string; - beta: string; refresh: string; overview: string; explore: string; @@ -160,7 +159,6 @@ export interface CodexViewCopy { export const CODEX_COPY_EN: CodexViewCopy = { title: 'Codex usage', - beta: 'Beta', refresh: 'Refresh', overview: 'Overview', explore: 'Explore', diff --git a/src/combinedHeatmapSvg.ts b/src/combinedHeatmapSvg.ts index 6ccaf4f2..913ce9ea 100644 --- a/src/combinedHeatmapSvg.ts +++ b/src/combinedHeatmapSvg.ts @@ -5,6 +5,7 @@ import { selectCombinedHeatmapWindow, } from './combinedHeatmap'; import { renderHeatmapSvg } from './heatmapSvg'; +import { HEATMAP_ARTIFACT_TRANSLATIONS, artifactLocale } from './i18n'; export type CombinedHeatmapPalette = | 'academicViolet' @@ -112,6 +113,7 @@ export interface CombinedHeatmapSvgOptions { palette?: CombinedHeatmapPalette | string; customAccent?: string; intensityMode?: CombinedHeatmapIntensityMode | string; + locale?: string; labels?: { combined: string; processedTokens: string; @@ -119,7 +121,13 @@ export interface CombinedHeatmapSvgOptions { }; } -function compactNumber(value: number): string { +function compactNumber(value: number, locale = 'en'): string { + if (locale !== 'en') { + return new Intl.NumberFormat(locale, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value); + } const absolute = Math.abs(value); const format = (scaled: number): string => scaled.toFixed(1).replace(/\.0$/, ''); if (absolute >= 1_000_000_000) return `${format(value / 1_000_000_000)}B`; @@ -137,21 +145,23 @@ export function renderCombinedHeatmapSvg( daily: Readonly>, options: CombinedHeatmapSvgOptions, ): string { + const locale = artifactLocale(options.locale); + const artifactCopy = HEATMAP_ARTIFACT_TRANSLATIONS[locale]; const window = selectCombinedHeatmapWindow( daily, options.range ?? 'year', options.endDateISO, ); - const title = options.title ?? 'Claude + Codex local activity'; + const title = options.title ?? artifactCopy.combinedTitle; const labels = options.labels ?? { - combined: 'Combined', - processedTokens: 'processed tokens', - footerNote: 'Local activity volume · not productivity, billing, or provider equivalence', + combined: artifactCopy.combined, + processedTokens: artifactCopy.processedTokens, + footerNote: artifactCopy.combinedFooter, }; const subtitle = [ - `Claude ${compactNumber(window.totals.claudeProcessed)}`, - `Codex ${compactNumber(window.totals.codexProcessed)}`, - `${labels.combined} ${compactNumber(window.totals.combinedProcessed)} ${labels.processedTokens}`, + `Claude ${compactNumber(window.totals.claudeProcessed, locale)}`, + `Codex ${compactNumber(window.totals.codexProcessed, locale)}`, + `${labels.combined} ${compactNumber(window.totals.combinedProcessed, locale)} ${labels.processedTokens}`, ].join(' · '); const palette = normalizeCombinedHeatmapPalette(options.palette); const scale = palette === 'custom' @@ -164,7 +174,8 @@ export function renderCombinedHeatmapSvg( title, subtitle, footerNote: labels.footerNote, - watermark: options.watermark ?? 'Made with Claude Code Usage', + watermark: options.watermark ?? artifactCopy.madeWith, + locale, scale, intensityMode: normalizeCombinedHeatmapIntensityMode(options.intensityMode), background: '#fcfaff', @@ -180,9 +191,9 @@ export function renderCombinedHeatmapSvg( codexProcessed: 0, combinedProcessed: 0, }; - return `${dateISO} · Claude: ${compactNumber(usage.claudeProcessed)} · ` + - `Codex: ${compactNumber(usage.codexProcessed)} · ` + - `${labels.combined}: ${compactNumber(usage.combinedProcessed)} ${labels.processedTokens}`; + return `${dateISO} · Claude: ${compactNumber(usage.claudeProcessed, locale)} · ` + + `Codex: ${compactNumber(usage.codexProcessed, locale)} · ` + + `${labels.combined}: ${compactNumber(usage.combinedProcessed, locale)} ${labels.processedTokens}`; }, }); } diff --git a/src/dashboardRefreshClient.ts b/src/dashboardRefreshClient.ts index 76f8118e..8574081e 100644 --- a/src/dashboardRefreshClient.ts +++ b/src/dashboardRefreshClient.ts @@ -506,5 +506,41 @@ function ccuApplyDashboardDataPatch(message) { return false; } } + +// A full provider-panel swap during wheel/trackpad motion forces layout on the +// scrolling frame. Keep the newest patch in memory until the burst quiets; +// the cap ensures a long continuous gesture cannot starve live data forever. +var __ccuLastScrollEventAt = -Infinity; +var __ccuScrollPatchPending = null; +var __ccuScrollPatchQuietTimer = 0; +var __ccuScrollPatchMaxTimer = 0; +function ccuFlushScrollPatch() { + if (__ccuScrollPatchQuietTimer) { clearTimeout(__ccuScrollPatchQuietTimer); } + if (__ccuScrollPatchMaxTimer) { clearTimeout(__ccuScrollPatchMaxTimer); } + __ccuScrollPatchQuietTimer = 0; + __ccuScrollPatchMaxTimer = 0; + var latest = __ccuScrollPatchPending; + __ccuScrollPatchPending = null; + if (latest) { ccuApplyDashboardDataPatch(latest); } +} +function ccuScheduleScrollPatchQuiet() { + if (__ccuScrollPatchQuietTimer) { clearTimeout(__ccuScrollPatchQuietTimer); } + __ccuScrollPatchQuietTimer = setTimeout(ccuFlushScrollPatch, 120); +} +window.addEventListener('scroll', function() { + __ccuLastScrollEventAt = performance.now(); + if (__ccuScrollPatchPending) { ccuScheduleScrollPatchQuiet(); } +}, { passive: true }); +function ccuQueueDashboardDataPatch(message) { + if (!__ccuScrollPatchPending && performance.now() - __ccuLastScrollEventAt >= 120) { + ccuApplyDashboardDataPatch(message); + return; + } + __ccuScrollPatchPending = message; + ccuScheduleScrollPatchQuiet(); + if (!__ccuScrollPatchMaxTimer) { + __ccuScrollPatchMaxTimer = setTimeout(ccuFlushScrollPatch, 500); + } +} `; } diff --git a/src/dataLoader.ts b/src/dataLoader.ts index 04ae4c20..0a675d03 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -5,7 +5,7 @@ import * as path from 'path'; // Removed tinyglobby dependency - using native fs instead // Removed zod dependency - using native validation instead import { calculateCostBreakdown, getModelRatesPerMillion } from './pricing'; -import { isRetryDuplicatePrompt } from './promptDedup'; +import { detachedPromptPrefix, isRetryDuplicatePrompt } from './promptDedup'; import { dayKeyInZone, formatHourLabel, @@ -82,6 +82,41 @@ export function validateUsageRecord(data: any): data is ClaudeUsageRecord { return true; } +/** Keep only usage evidence after content analysis has inspected the raw line. + * A usage-bearing assistant row may contain a very large response/tool body; + * retaining the parsed row would pin that body in every dashboard snapshot. */ +export function compactUsageRecord(data: ClaudeUsageRecord): ClaudeUsageRecord { + const sourceUsage = data.message.usage; + const usage: ClaudeUsageRecord['message']['usage'] = { + input_tokens: sourceUsage.input_tokens, + output_tokens: sourceUsage.output_tokens, + ...(sourceUsage.cache_creation_input_tokens !== undefined + ? { cache_creation_input_tokens: sourceUsage.cache_creation_input_tokens } : {}), + ...(sourceUsage.cache_read_input_tokens !== undefined + ? { cache_read_input_tokens: sourceUsage.cache_read_input_tokens } : {}), + ...(sourceUsage.cache_creation !== undefined ? { + cache_creation: { + ...(sourceUsage.cache_creation?.ephemeral_1h_input_tokens !== undefined + ? { ephemeral_1h_input_tokens: sourceUsage.cache_creation.ephemeral_1h_input_tokens } : {}), + ...(sourceUsage.cache_creation?.ephemeral_5m_input_tokens !== undefined + ? { ephemeral_5m_input_tokens: sourceUsage.cache_creation.ephemeral_5m_input_tokens } : {}), + }, + } : {}), + }; + return { + timestamp: data.timestamp, + ...(data.version !== undefined ? { version: data.version } : {}), + message: { + usage, + ...(data.message.model !== undefined ? { model: data.message.model } : {}), + ...(data.message.id !== undefined ? { id: data.message.id } : {}), + }, + ...(data.costUSD !== undefined ? { costUSD: data.costUSD } : {}), + ...(data.requestId !== undefined ? { requestId: data.requestId } : {}), + ...(data.isApiErrorMessage !== undefined ? { isApiErrorMessage: data.isApiErrorMessage } : {}), + }; +} + function validationDropReason(data: any): string { if (!data || typeof data !== 'object') return 'not-an-object'; if (typeof data.timestamp !== 'string') return 'timestamp-missing-or-non-string'; @@ -216,7 +251,7 @@ function collectPrompt( // Web Component examples and must remain eligible for explicit opt-in. acc.prompts.push({ cwd, - text: trimmed.slice(0, 2500), + text: detachedPromptPrefix(trimmed, 2500), observedAtEpochMs: Number.isFinite(observedAtEpochMs) ? observedAtEpochMs : 0, }); if (acc.prompts.length > 600) { @@ -234,7 +269,7 @@ function collectCommandUse(acc: AnalysisAcc, text: string, sessionId: string, ti if (!m) { return; } - const name = m[1].trim(); + const name = detachedPromptPrefix(m[1].trim(), 80); if (TRIVIAL_COMMANDS.has(name.toLowerCase())) { return; } @@ -842,7 +877,7 @@ export class ClaudeDataLoader { } } - // Per-session (per-file) map of prompt text → last-counted epoch ms, + // Per-session (per-file) map of prompt digest → 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(); @@ -891,7 +926,7 @@ export class ClaudeDataLoader { : ''; const trimmed = text.replace(/\s+/g, ' ').trim(); if (trimmed.length > 0) { - agentInfo.task = trimmed.slice(0, 200); + agentInfo.task = detachedPromptPrefix(trimmed, 200); } } } @@ -930,7 +965,7 @@ export class ClaudeDataLoader { // 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), + _promptText: detachedPromptPrefix(text.trim(), 4000), _sessionId: sessionInfo.sessionId, _projectDirEncoded: sessionInfo.projectPath, }; @@ -961,7 +996,7 @@ export class ClaudeDataLoader { // Tag the record with the session/project it came from. // Prefer the real working directory (`cwd`) recorded in the log line // over the lossy, dash-encoded folder name when it is available. - const record = data as ClaudeUsageRecord; + const record = compactUsageRecord(data as ClaudeUsageRecord); record._sessionId = sessionInfo.sessionId; record._projectDirEncoded = sessionInfo.projectPath; const cwd = (parsed as { cwd?: unknown }).cwd; diff --git a/src/dateKeys.ts b/src/dateKeys.ts index f4d01a0f..6eae1669 100644 --- a/src/dateKeys.ts +++ b/src/dateKeys.ts @@ -7,21 +7,64 @@ // inside the new month's view. Deriving both the day and the month key from the // same zone here removes that split. +// Constructing an Intl.DateTimeFormat is expensive, and these helpers run once +// per ingested record: a single index pass over a large local history calls them +// millions of times. Both the zone resolution and the formatters are therefore +// memoised per zone. Every distinct key is a configured zone, so the maps stay +// at a handful of entries for the life of the process. +const resolvedZones = new Map(); +const dayFormatters = new Map(); +const hourFormatters = new Map(); + /** Returns a usable IANA zone, falling back to the system zone when necessary. */ export function resolveTimeZone(timeZone: string): string { + const cached = resolvedZones.get(timeZone); + if (cached !== undefined) { + return cached; + } + let resolved: string; try { - return new Intl.DateTimeFormat('en-CA', timeZone ? { timeZone } : undefined) + resolved = new Intl.DateTimeFormat('en-CA', timeZone ? { timeZone } : undefined) .resolvedOptions() .timeZone || 'UTC'; } catch { - return new Intl.DateTimeFormat('en-CA').resolvedOptions().timeZone || 'UTC'; + resolved = new Intl.DateTimeFormat('en-CA').resolvedOptions().timeZone || 'UTC'; } + resolvedZones.set(timeZone, resolved); + return resolved; +} + +function dayFormatter(zone: string): Intl.DateTimeFormat { + const cached = dayFormatters.get(zone); + if (cached) { + return cached; + } + const formatter = new Intl.DateTimeFormat('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + timeZone: zone, + }); + dayFormatters.set(zone, formatter); + return formatter; +} + +function hourFormatter(zone: string): Intl.DateTimeFormat { + const cached = hourFormatters.get(zone); + if (cached) { + return cached; + } + const formatter = new Intl.DateTimeFormat('en-GB', { + hour: '2-digit', + hourCycle: 'h23', + timeZone: zone, + }); + hourFormatters.set(zone, formatter); + return formatter; } function partsInZone(date: Date, timeZone: string): { y: string; m: string; d: string } { - const base: Intl.DateTimeFormatOptions = { year: 'numeric', month: '2-digit', day: '2-digit' }; - const fmt = new Intl.DateTimeFormat('en-CA', { ...base, timeZone: resolveTimeZone(timeZone) }); - const parts = fmt.formatToParts(date); + const parts = dayFormatter(resolveTimeZone(timeZone)).formatToParts(date); const get = (t: string): string => parts.find((p) => p.type === t)?.value ?? ''; return { y: get('year'), m: get('month'), d: get('day') }; } @@ -46,13 +89,8 @@ export function hourKeyInZone(date: Date, timeZone: string): string { if (isNaN(date.getTime())) { return ''; } - const formatter = new Intl.DateTimeFormat('en-GB', { - hour: '2-digit', - hourCycle: 'h23', - timeZone: resolveTimeZone(timeZone), - }); - return formatter.formatToParts(date).find((part) => part.type === 'hour') - ?.value ?? ''; + return hourFormatter(resolveTimeZone(timeZone)).formatToParts(date) + .find((part) => part.type === 'hour')?.value ?? ''; } /** Provider-neutral display label for a validated "00" through "23" hour key. */ diff --git a/src/extension.ts b/src/extension.ts index 0d526949..fffa33a2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,8 +4,6 @@ import * as path from 'path'; import * as os from 'os'; import { createHash, randomBytes } from 'crypto'; 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 { @@ -19,9 +17,12 @@ import { UsageWebviewProvider } from './webview'; import { I18n } from './i18n'; import { dayKeyInZone, resolveTimeZone } from './dateKeys'; import { + GITHUB_HEATMAP_DESTINATION_KEY, GITHUB_PUBLIC_REPO_SCOPE, + completeSuccessfulGitHubPublish, createGitHubPublishPlan, githubPublishConfirmationDetail, + parseGitHubHeatmapDestination, probePublicGitHubPublishTarget, publishPublicGitHubFile, } from './githubHeatmapPublish'; @@ -144,6 +145,7 @@ import { LocalDataAction, LocalDataActionResult, LocalDataClientAction, + LocalDataClientResetTombstone, LocalDataClientSummary, LocalDataInventory, LocalDataInventoryRow, @@ -516,6 +518,7 @@ export class ClaudeCodeUsageExtension { private initializationWriteFailure: unknown = null; private localDataActionWrite: Promise = Promise.resolve(); private pendingClientResetReplay: Promise = Promise.resolve(); + private pendingClientResetRevision = 0; private clearingAllLocalData = false; private localDataClearedRequiresReload = false; private readonly localDataQuotaScopes = new Map< @@ -694,8 +697,8 @@ export class ClaudeCodeUsageExtension { this.buildLocalDataInventory(client); this.webviewProvider.onRunLocalDataAction = (action, quotaScopeToken) => this.runLocalDataActionInteractive(action, quotaScopeToken); - this.webviewProvider.onResetSharingPreferences = () => - this.resetSharingPreferencesHost(); + this.webviewProvider.onExportClaudeHeatmap = () => this.exportHeatmap(); + this.webviewProvider.onPublishClaudeHeatmap = () => this.publishHeatmapToGitHub(); this.webviewProvider.onLocalDataClientReady = () => { void this.replayPendingClientReset(); }; @@ -820,13 +823,13 @@ export class ClaudeCodeUsageExtension { this.outputChannel.show(); }), vscode.commands.registerCommand('claudeCodeUsage.exportHeatmap', () => { - this.exportHeatmap(); + this.webviewProvider.showSharingWorkspace('claudeHeatmap'); }), vscode.commands.registerCommand('claudeCodeUsage.publishHeatmapToGitHub', () => { - this.publishHeatmapToGitHub(); + this.webviewProvider.showSharingWorkspace('claudeHeatmap'); }), vscode.commands.registerCommand('claudeCodeUsage.exportShareCard', () => { - this.exportShareCard(); + this.webviewProvider.showSharingWorkspace('claudeShareCard'); }), vscode.commands.registerCommand('claudeCodeUsage.previewWhatsNew', () => { this.previewWhatsNew(); @@ -1088,6 +1091,7 @@ export class ClaudeCodeUsageExtension { key === QUOTA_FINGERPRINT_SALT_KEY || key === 'ccu.codex.machineSalt', ); const sharingKeys = stateKeys.filter((key) => + key === GITHUB_HEATMAP_DESTINATION_KEY || key === 'ccu.heatmapRepo' || key === 'ccu.heatmapPath' || sharingSettingKeys.has(key), @@ -1326,35 +1330,77 @@ export class ClaudeCodeUsageExtension { private async resetSharingPreferencesHost(): Promise { await this.settings.resetSharingOwnedData(); + await this.context.globalState.update(GITHUB_HEATMAP_DESTINATION_KEY, undefined); await this.context.globalState.update('ccu.heatmapRepo', undefined); await this.context.globalState.update('ccu.heatmapPath', undefined); this.webviewProvider.clearSharingRuntimeState(); } - private pendingClientReset(): LocalDataClientAction | undefined { + private pendingClientReset(): LocalDataClientResetTombstone | undefined { const value = this.context.globalState.get( LOCAL_DATA_PENDING_CLIENT_RESET_KEY, ); - return value === 'reset-ui-state' || + if (value === 'reset-ui-state' || value === 'reset-sharing-preferences' || - value === 'clear-all-client-state' - ? value - : undefined; + value === 'clear-all-client-state') { + return { schemaVersion: 1, revision: 0, action: value }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const candidate = value as Partial; + if ( + candidate.schemaVersion !== 1 || + !Number.isSafeInteger(candidate.revision) || + Number(candidate.revision) < 1 || + (candidate.action !== 'reset-ui-state' && + candidate.action !== 'reset-sharing-preferences' && + candidate.action !== 'clear-all-client-state') + ) { + return undefined; + } + this.pendingClientResetRevision = Math.max( + this.pendingClientResetRevision ?? 0, + Number(candidate.revision), + ); + return candidate as LocalDataClientResetTombstone; + } + + private nextPendingClientReset(action: LocalDataClientAction): LocalDataClientResetTombstone { + const current = this.pendingClientReset(); + const revision = Math.max( + this.pendingClientResetRevision ?? 0, + current?.revision ?? 0, + ) + 1; + this.pendingClientResetRevision = revision; + return { schemaVersion: 1, revision, action }; + } + + private async clearPendingClientReset( + acknowledged: LocalDataClientResetTombstone, + ): Promise { + const current = this.pendingClientReset(); + if ( + !current || + current.revision !== acknowledged.revision || + current.action !== acknowledged.action + ) { + return; + } + await this.context.globalState.update( + LOCAL_DATA_PENDING_CLIENT_RESET_KEY, + undefined, + ); } private replayPendingClientReset(): Promise { const run = this.pendingClientResetReplay .catch(() => undefined) - .then(async () => { + .then(() => this.serializeLocalDataAction(async () => { const pending = this.pendingClientReset(); if (!pending) return; - if (await this.webviewProvider.requestClientLocalDataAction(pending)) { - await this.context.globalState.update( - LOCAL_DATA_PENDING_CLIENT_RESET_KEY, - undefined, - ); + if (await this.webviewProvider.requestClientLocalDataAction(pending.action)) { + await this.clearPendingClientReset(pending); } - }); + })); this.pendingClientResetReplay = run; return run; } @@ -1489,11 +1535,12 @@ export class ClaudeCodeUsageExtension { } } if (result.clientAction) { + const tombstone = this.nextPendingClientReset(result.clientAction); let tombstoneStored = false; try { await this.context.globalState.update( LOCAL_DATA_PENDING_CLIENT_RESET_KEY, - result.clientAction, + tombstone, ); tombstoneStored = true; } catch { @@ -1505,10 +1552,7 @@ export class ClaudeCodeUsageExtension { ); if (clientResetOk && tombstoneStored) { try { - await this.context.globalState.update( - LOCAL_DATA_PENDING_CLIENT_RESET_KEY, - undefined, - ); + await this.clearPendingClientReset(tombstone); } catch { // A stale tombstone only replays the same exact idempotent reset. } @@ -1719,6 +1763,7 @@ export class ClaudeCodeUsageExtension { try { const result = await fetchLatestPricing(); this.invalidateClaudeUsagePricingCache(); + this.webviewProvider.invalidateShareCardPreview(); vscode.window.showInformationMessage(`${I18n.t.popup.pricingUpdated} (${result.updated})`); // Force a full recompute so the new prices take effect. void this.refreshData(true, 'pricing'); @@ -1741,6 +1786,7 @@ export class ClaudeCodeUsageExtension { const daily = ClaudeDataLoader.getDailyUsageMap(records, timeZone); const svg = renderHeatmapSvg(daily, { endDateISO: dayKeyInZone(new Date(), timeZone), + locale: I18n.getLocale(), }); const uri = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(path.join(os.homedir(), 'claude-code-heatmap.svg')), @@ -1845,10 +1891,15 @@ export class ClaudeCodeUsageExtension { } const token = session.accessToken; const login = session.account.label.split(/\s/)[0]; + const storedDestination = parseGitHubHeatmapDestination( + this.context.globalState.get(GITHUB_HEATMAP_DESTINATION_KEY), + ); const repo = await vscode.window.showInputBox({ prompt: 'Public target repository (owner/name). Private repositories use local SVG export.', - value: this.context.globalState.get('ccu.heatmapRepo') || `${login}/${login}`, + value: storedDestination?.repository || + this.context.globalState.get('ccu.heatmapRepo') || + `${login}/${login}`, validateInput: (value) => { try { createGitHubPublishPlan(value, 'heatmap.svg'); @@ -1864,7 +1915,9 @@ export class ClaudeCodeUsageExtension { const filePath = (await vscode.window.showInputBox({ prompt: 'File path in the repo', - value: this.context.globalState.get('ccu.heatmapPath') || 'claude-code-heatmap.svg', + value: storedDestination?.filePath || + this.context.globalState.get('ccu.heatmapPath') || + 'claude-code-heatmap.svg', })) || ''; if (!filePath) { return; @@ -1879,7 +1932,7 @@ export class ClaudeCodeUsageExtension { const timeZone = I18n.getTimezone(); const svg = renderHeatmapSvg( ClaudeDataLoader.getDailyUsageMap(records, timeZone), - { endDateISO: dayKeyInZone(new Date(), timeZone) }, + { endDateISO: dayKeyInZone(new Date(), timeZone), locale: I18n.getLocale() }, ); const contentB64 = Buffer.from(svg, 'utf8').toString('base64'); const request = ( @@ -1921,70 +1974,26 @@ export class ClaudeCodeUsageExtension { return; } - // Destination strings are convenience preferences, not credentials. Save - // them only after the exact, confirmed write succeeds. - await this.context.globalState.update('ccu.heatmapRepo', `${preview.owner}/${preview.repository}`); - await this.context.globalState.update('ccu.heatmapPath', preview.filePath); - const view = 'View on GitHub'; - const pick = await vscode.window.showInformationMessage( - `Heatmap published to ${preview.owner}/${preview.repository}.`, - view - ); - if (pick === view) { - void vscode.env.openExternal(vscode.Uri.parse(preview.browserUrl)); - } - } - - /** 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(), - formatCurrency: (amountUsd) => I18n.formatCurrency(amountUsd), - }); - const defaultName = shareCardFilename(picked.range).replace(/\.png$/, '.svg'); - const uri = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(path.join(os.homedir(), defaultName)), - filters: { 'SVG image': ['svg'] }, - saveLabel: 'Export share card', + const completion = await completeSuccessfulGitHubPublish(preview, { + persistDestination: async (key, destination) => { + await this.context.globalState.update(key, destination); + }, + showSuccess: async () => { + const pick = await vscode.window.showInformationMessage( + `Heatmap published to ${preview.owner}/${preview.repository} on ${preview.branch} at ${preview.filePath}.`, + view, + ); + return pick === view; + }, + showPersistenceWarning: async () => { + await vscode.window.showWarningMessage( + I18n.sharingWorkspace.publishPreferenceSaveWarning, + ); + }, }); - 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); + if (completion.openBrowser) { + void vscode.env.openExternal(vscode.Uri.parse(preview.browserUrl)); } } @@ -2092,7 +2101,7 @@ export class ClaudeCodeUsageExtension { this.activePricingBackend = config.pricingBackend; setPricingBackend(config.pricingBackend); this.applyFormattingConfiguration(config); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat); // Listen for configuration changes vscode.workspace.onDidChangeConfiguration(e => { @@ -2115,7 +2124,7 @@ export class ClaudeCodeUsageExtension { } private applyFormattingConfiguration(config: ExtensionConfig): void { - I18n.setLanguage(config.language as any); + I18n.setLanguage(config.language as any, vscode.env.language); I18n.setDecimalPlaces(config.decimalPlaces); I18n.setCurrencyDisplay(config.displayCurrency); I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); @@ -2156,6 +2165,7 @@ export class ClaudeCodeUsageExtension { showResetInStatusBar: s.get('showResetInStatusBar'), quotaFiveHourOnly: s.get('quotaFiveHourOnly'), resetCountdownFormat: s.get<'decimal' | 'units' | 'clock'>('resetCountdownFormat'), + statusBarQuotaFormat: s.get('statusBarQuotaFormat'), usageLimitTracking: s.get('usageLimitTracking'), adviceApiKey: s.get('advice.apiKey'), adviceApiUrl: s.get('advice.apiUrl'), @@ -3178,7 +3188,7 @@ export class ClaudeCodeUsageExtension { // next tick. 'showCost', 'showContext', 'statusBarMetric', 'showScopedWeekly', 'quotaFiveHourOnly', 'showResetInStatusBar', 'resetCountdownFormat', - 'statusBarProvider', 'codex.statusMetric', + 'statusBarQuotaFormat', 'statusBarProvider', 'codex.statusMetric', ]); // Presentation-only dashboard toggles must not restart watchers, recreate @@ -3210,7 +3220,7 @@ export class ClaudeCodeUsageExtension { } if (key && ClaudeCodeUsageExtension.STATUS_BAR_ONLY_SETTINGS.has(key)) { const config = this.getConfiguration(); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat); this.statusBar.updateQuota(this.cache.usageLimits ?? null); this.syncProviderUi(); return; @@ -3276,9 +3286,10 @@ export class ClaudeCodeUsageExtension { setPricingBackend(config.pricingBackend); if (pricingBackendChanged) { this.invalidateClaudeUsagePricingCache(); + this.webviewProvider.invalidateShareCardPreview(); } this.applyFormattingConfiguration(config); - this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat); + this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showScopedWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar, config.resetCountdownFormat, config.statusBarQuotaFormat); // Restart auto-refresh with new interval this.startAutoRefresh(); @@ -4421,6 +4432,7 @@ export class ClaudeCodeUsageExtension { materialized.costliestMessages, materialized.hourlyForLast30DaysByDay, materialized.projectUsageMatrix, + materialized.dailyForAllTime, ); } this.cache.lastUpdate = new Date(snapshotNow.getTime()); @@ -4532,7 +4544,7 @@ export class ClaudeCodeUsageExtension { this.statusBar.updateUsageData(todayData, workspaceTodayData, undefined, undefined, calendarMonthData); this.statusBar.updateContext(materialized.context); if (updateWebview) { - this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay, materialized.projectUsageMatrix); + this.webviewProvider.updateData(sessionData, todayData, rolling30Data, allTimeData, dailyDataForRolling30, dailyDataForAllTime, hourlyDataForToday, undefined, dataDirectory, records, sessionBreakdown, projectBreakdown, contentAnalysis, branchBreakdown, workflowBreakdown, costliestMessages, hourlyDataForRolling30DaysByDay, materialized.projectUsageMatrix, materialized.dailyForAllTime); } } @@ -4659,7 +4671,7 @@ export async function activate(context: vscode.ExtensionContext): Promise console.log('Claude Code Usage extension is now active'); const settings = new SettingsStore(context); - I18n.setLanguage(settings.get('language') as any); + I18n.setLanguage(settings.get('language') as any, vscode.env.language); const secretMigrationFailure = await settings.initializeSecretsForActivation(); if (secretMigrationFailure) { const needsManualWorkspaceMigration = diff --git a/src/githubHeatmapPublish.ts b/src/githubHeatmapPublish.ts index 5de322fb..7a2aec9a 100644 --- a/src/githubHeatmapPublish.ts +++ b/src/githubHeatmapPublish.ts @@ -1,4 +1,11 @@ export const GITHUB_PUBLIC_REPO_SCOPE = 'public_repo'; +export const GITHUB_HEATMAP_DESTINATION_KEY = 'ccu.heatmapDestination.v1'; + +export interface GitHubHeatmapDestination { + schemaVersion: 1; + repository: string; + filePath: string; +} export interface GitHubApiResponse { status: number; @@ -37,6 +44,60 @@ export class GitHubPublishHttpError extends Error { } } +export function parseGitHubHeatmapDestination( + value: unknown, +): GitHubHeatmapDestination | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const candidate = value as Record; + if ( + Object.keys(candidate).sort().join(',') !== 'filePath,repository,schemaVersion' || + candidate.schemaVersion !== 1 || + typeof candidate.repository !== 'string' || + typeof candidate.filePath !== 'string' + ) { + return undefined; + } + try { + const plan = createGitHubPublishPlan(candidate.repository, candidate.filePath); + return { + schemaVersion: 1, + repository: `${plan.owner}/${plan.repository}`, + filePath: plan.filePath, + }; + } catch { + return undefined; + } +} + +export async function completeSuccessfulGitHubPublish( + preview: GitHubPublishPreview, + callbacks: { + persistDestination: ( + key: typeof GITHUB_HEATMAP_DESTINATION_KEY, + destination: GitHubHeatmapDestination, + ) => Promise; + showSuccess: () => Promise; + showPersistenceWarning: () => Promise; + }, +): Promise<{ openBrowser: boolean; preferenceSaved: boolean }> { + const destination: GitHubHeatmapDestination = { + schemaVersion: 1, + repository: `${preview.owner}/${preview.repository}`, + filePath: preview.filePath, + }; + let preferenceSaved = true; + try { + await callbacks.persistDestination(GITHUB_HEATMAP_DESTINATION_KEY, destination); + } catch { + preferenceSaved = false; + } + const openBrowser = await callbacks.showSuccess(); + if (!preferenceSaved) { + await callbacks.showPersistenceWarning(); + } + return { openBrowser, preferenceSaved }; +} + function encodedSegments(value: string): string { return value.split('/').map(encodeURIComponent).join('/'); } diff --git a/src/heatmapSvg.ts b/src/heatmapSvg.ts index 6f9d1ed7..c05d2579 100644 --- a/src/heatmapSvg.ts +++ b/src/heatmapSvg.ts @@ -8,6 +8,14 @@ // README renders on a white/light canvas. import { DayUsage, HeatMetric, intensityBucket } from './heatmap'; +import { + approximateSvgTextWidth, + escapeSvgAttribute, + escapeSvgText, + fitSvgText, +} from './svgEscape'; +import { HEATMAP_ARTIFACT_TRANSLATIONS, artifactLocale } from './i18n'; +import { SupportedLanguage } from './types'; /** Claude-orange 5-step intensity ramp (bucket 0..4). 0 = empty cell. */ export const CLAUDE_ORANGE_SCALE = ['#ebedf0', '#fadcc9', '#f0aa82', '#e07d4f', '#c85a2b']; @@ -30,6 +38,7 @@ export interface HeatmapSvgOptions { accentColor?: string; minWidth?: number; // optional card width floor for short date ranges ariaLabel?: string; + locale?: string; tooltip?: (dateISO: string, usage: DayUsage, value: number, metric: HeatMetric) => string; } @@ -114,7 +123,13 @@ function daysBetween(aISO: string, bISO: string): number { } /** Compact number: 5.3B / 1.2M / 345K / 42. */ -function compactNum(n: number): string { +function compactNum(n: number, locale: SupportedLanguage = 'en'): string { + if (locale !== 'en') { + return new Intl.NumberFormat(locale, { + notation: 'compact', + maximumFractionDigits: 1, + }).format(n); + } const abs = Math.abs(n); const trim = (x: number): string => x.toFixed(1).replace(/\.0$/, ''); if (abs >= 1e9) return trim(n / 1e9) + 'B'; @@ -130,19 +145,25 @@ function ordinal(d: number): string { 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 { +/** Locale-aware visible date for a YYYY-MM-DD key. */ +function longDate(dateISO: string, locale: SupportedLanguage): string { + if (locale !== 'en') { + return new Intl.DateTimeFormat(locale, { + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }).format(new Date(`${dateISO}T00:00:00.000Z`)); + } 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, '>'); +const esc = escapeSvgText; export interface HeatGridCell { dateISO: string; @@ -198,6 +219,8 @@ export function buildContributionGrid( /** Render the trailing-year token heatmap as a self-contained SVG string. */ export function renderHeatmapSvg(daily: Record, opts: HeatmapSvgOptions = {}): string { + const locale = artifactLocale(opts.locale); + const copy = HEATMAP_ARTIFACT_TRANSLATIONS[locale]; 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); @@ -208,7 +231,7 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS color, CLAUDE_ORANGE_SCALE[Math.min(index, CLAUDE_ORANGE_SCALE.length - 1)], )); - const watermark = opts.watermark ?? 'Made with Claude Code Usage'; + const watermark = opts.watermark ?? copy.madeWith; // Trailing window: full weeks ending on the Saturday of today's week, cells // only up to today (no future) — GitHub's default contribution view. @@ -227,11 +250,11 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS const summary = opts.title ?? (metric === 'cost' - ? `$${compactNum(grid.total)} in Claude Code · ${year}` + ? `$${compactNum(grid.total, locale)} ${copy.inClaudeCode} · ${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'; + ? `${compactNum(grid.total, locale)} ${copy.sessions} ${copy.inClaudeCode} · ${year}` + : `${compactNum(grid.total, locale)} ${copy.tokens} ${copy.inClaudeCode} · ${year}`); + const noun = metric === 'sessions' ? copy.sessions : metric === 'cost' ? '' : copy.tokens; const cell = 12; const gap = 3; @@ -245,6 +268,7 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS const footerH = opts.footerNote ? 48 : 30; const width = Math.max(padL + gridW + 10, Math.max(0, opts.minWidth ?? 0)); const height = padT + gridH + footerH; + const renderedSummary = fitSvgText(summary, width - padL - 10, 15); const parts: string[] = []; const background = safeSvgColor(opts.background, '#ffffff'); @@ -255,13 +279,13 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS ? safeSvgColor(opts.accentColor, '') : undefined; parts.push( - `` + `` ); parts.push(``); if (accentColor) { parts.push(``); } - parts.push(`${esc(summary)}`); + parts.push(`${esc(renderedSummary)}`); if (opts.subtitle) { parts.push(`${esc(opts.subtitle)}`); } @@ -270,15 +294,15 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS for (const c of grid.cells) { const x = padL + c.col * step; const y = padT + c.row * step; - const when = longDate(c.dateISO); + const when = longDate(c.dateISO, locale); const usage = daily[c.dateISO] ?? { tokens: 0, cost: 0, sessions: 0 }; const tip = opts.tooltip ? opts.tooltip(c.dateISO, usage, c.value, metric) : c.value <= 0 - ? `No ${noun || 'usage'} on ${when}` + ? `${copy.no} ${noun || copy.usage} ${copy.on} ${when}` : metric === 'cost' - ? `$${compactNum(c.value)} on ${when}` - : `${compactNum(c.value)} ${noun} on ${when}`; + ? `$${compactNum(c.value, locale)} ${copy.on} ${when}` + : `${compactNum(c.value, locale)} ${noun} ${copy.on} ${when}`; parts.push( `${esc(tip)}` ); @@ -294,12 +318,20 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS const m = Number(first.dateISO.slice(5, 7)) - 1; if (m !== lastMonth) { lastMonth = m; - parts.push(`${MONTHS[m]}`); + const monthLabel = new Intl.DateTimeFormat(locale, { + month: 'short', + timeZone: 'UTC', + }).format(new Date(Date.UTC(2026, m, 1))); + parts.push(`${esc(monthLabel)}`); } } // Weekday labels (Mon / Wed / Fri). - for (const [row, label] of [[1, 'Mon'], [3, 'Wed'], [5, 'Fri']] as [number, string][]) { + for (const [row, weekday] of [[1, 1], [3, 3], [5, 5]] as [number, number][]) { + const label = new Intl.DateTimeFormat(locale, { + weekday: 'short', + timeZone: 'UTC', + }).format(new Date(Date.UTC(2026, 0, 4 + weekday))); parts.push(`${label}`); } @@ -318,13 +350,16 @@ export function renderHeatmapSvg(daily: Record, opts: HeatmapS // Anchor to the rendered card rather than the raw grid. Short 30/90-day // ranges use minWidth, so a grid-relative position can fall outside the // viewBox as the legend gains additional bands. - let lx = Math.max(padL, width - (scale.length * step + 66)); - parts.push(`Less`); - lx += 26; + const lessWidth = approximateSvgTextWidth(copy.less, 11); + const moreWidth = approximateSvgTextWidth(copy.more, 11); + const legendWidth = lessWidth + 8 + scale.length * step + 4 + moreWidth; + let lx = Math.max(padL, width - legendWidth - 10); + parts.push(`${esc(copy.less)}`); + lx += lessWidth + 8; for (let b = 0; b < scale.length; b++) { parts.push(``); } - parts.push(`More`); + parts.push(`${esc(copy.more)}`); parts.push(''); return parts.join('\n'); diff --git a/src/i18n.ts b/src/i18n.ts index 31b7f968..54383a8d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -13,6 +13,60 @@ export interface ProviderTranslations { codex: CodexViewCopy; } +/** Complete copy contract for the preview-first sharing workspace. Keeping the + * contract here makes every presentation follow the same eight-locale guard. */ +export interface SharingWorkspaceTranslations { + eyebrow: string; + panelTitle: string; + description: string; + privacyBadge: string; + presentationLabel: string; + combinedPresentation: string; + combinedPresentationDescription: string; + claudeCardPresentation: string; + claudeCardPresentationDescription: string; + claudeHeatmapPresentation: string; + claudeHeatmapPresentationDescription: string; + combinedUnavailable: string; + rangeRollingGroup: string; + rangeSpecificMonthGroup: string; + last7: string; + thisMonth: string; + last12Months: string; + todayHourly: string; + lastMonthSuffix: string; + scopeLabel: string; + scopeAllGroup: string; + overall: string; + byProject: string; + bySession: string; + themeLabel: string; + claudeClassic: string; + claudeCream: string; + auroraDark: string; + autoTheme: string; + cardContentsLabel: string; + totalTokens: string; + estimatedCost: string; + cacheHitRate: string; + topModel: string; + sessionCount: string; + tokenMix: string; + dailyPulse: string; + badge: string; + messageCount: string; + projectName: string; + fullNumbers: string; + fullNumbersHelp: string; + previewPending: string; + generating: string; + cardBuildFailed: string; + publishGitHub: string; + publishPreferenceSaveWarning: string; + claudeHeatmapDisclaimer: string; + noClaudeData: string; +} + export interface WeeklyValueCopy { title: string; description: string; @@ -138,6 +192,11 @@ export interface Translations { settingsGroupProviders: string; settingsGroupFeatures: string; settingsGroupStatusBar: string; + quotaFormatBuiltIn: string; + quotaFormatFiveHour: string; + quotaFormatWeekly: string; + quotaFormatCustom: string; + quotaFormatShortHelp: string; settingsGroupData: string; settingsGroupAdvice: string; adviceEffectiveness: AdviceEffectivenessTranslations; @@ -936,16 +995,16 @@ function providerTranslations( const PROVIDERS: Record = { en: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: 'Compare' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Compare' }, {}, ), 'de-DE': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: 'Vergleichen' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Vergleichen' }, { ...TASK8_CODEX_COPY['de-DE'], accountSnapshotLastObserved: 'Nutzung fasst Anmeldungen in diesem Codex home zusammen · Limits werden zuletzt beobachtet, nicht kombiniert', - title: 'Codex-Nutzung', beta: 'Beta', overview: 'Übersicht', usageTrend: 'Nutzungstrend', daily: 'Täglich', date: 'Datum', role: 'Rolle', scope: 'Bereich', threadLabel: 'Thread', rootRole: 'Hauptaufgabe', childRole: 'Subagent', approvalReviewerRole: 'Freigabe-Prüfer', unknownRole: 'Unbekannt', noDailyData: 'Noch keine tägliche Codex-Nutzung indexiert.', noThreadData: 'Noch keine Codex-Threads indexiert.', lastTask: 'Letzte Aufgabe', last7Days: 'Letzte 7 Tage', last30Days: 'Letzte 30 Tage', projects: 'Projekte', projectLabel: 'Projekt', + title: 'Codex-Nutzung', overview: 'Übersicht', usageTrend: 'Nutzungstrend', daily: 'Täglich', date: 'Datum', role: 'Rolle', scope: 'Bereich', threadLabel: 'Thread', rootRole: 'Hauptaufgabe', childRole: 'Subagent', approvalReviewerRole: 'Freigabe-Prüfer', unknownRole: 'Unbekannt', noDailyData: 'Noch keine tägliche Codex-Nutzung indexiert.', noThreadData: 'Noch keine Codex-Threads indexiert.', lastTask: 'Letzte Aufgabe', last7Days: 'Letzte 7 Tage', last30Days: 'Letzte 30 Tage', projects: 'Projekte', projectLabel: 'Projekt', unnamedSession: 'Unbenannte Sitzung', unidentifiedProject: 'Nicht identifiziertes Projekt', parentThread: 'Übergeordnet', parentTask: 'Übergeordnete Aufgabe', searchThreads: 'Sitzungen suchen', sessions: 'Sitzungen', modelsEffort: 'Modelle & Aufwand', clearFilters: 'Filter löschen', activeFilters: 'Aktive Filter', all: 'Alle', localDirectory: 'Lokaler Ordner', lastActive: 'Zuletzt aktiv', expand: 'Erweitern', viewAllSessions: 'Alle Sitzungen anzeigen', sortBy: 'Sortieren nach', usageLimits: 'Nutzungslimits', resets: 'Zurücksetzung', credits: 'Guthaben', unlimited: 'Unbegrenzt', allTime: 'Gesamter Zeitraum', behavior: 'Verhalten', settings: 'Einstellungen', monthly: 'Monatlich', tokenComposition: 'Token-Zusammensetzung', freshInput: 'Eingabe ohne Cache', reasoningSubset: 'In Ausgabe enthalten', threadRoleComposition: 'Thread-Rollenverteilung', childThreadsPerRootTask: 'Unter-Threads / Hauptaufgabe', childFreshShare: 'Anteil der Nutzung ohne Cache durch Unter-Threads', approvalFreshShare: 'Anteil der Nutzung ohne Cache durch Freigabeprüfung', highEffortFreshShare: 'Anteil der Nutzung ohne Cache bei hohem Aufwand', processedToFreshRatio: 'Verarbeitet / Nutzung ohne Cache', reasoningOutputShare: 'Reasoning-Anteil der Ausgabe', postPatchToolCallsPerPatchCall: 'Tool-Call-Proxy nach Patch / Patch-Aufruf', patchCalls: 'Patch-Aufrufe', compactions: 'Kontextkomprimierungen', processed: 'Verarbeitet', apiEquivalentCost: 'API-äquivalente Kosten', apiEquivalentCostHelp: 'Aus derzeit indexierten Token mit aktuellen offiziellen API-Preisen geschätzt; keine Rechnung oder Abonnementbelastung. Preisabdeckung: {coverage}.', fresh: 'Nutzung ohne Cache', input: 'Eingabe', cachedInput: 'Gecachte Eingabe', output: 'Ausgabe', reasoning: 'Reasoning', @@ -956,12 +1015,12 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['de-DE'], ), 'zh-TW': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: '比較' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '比較' }, { ...TASK8_CODEX_COPY['zh-TW'], accountSnapshotLastObserved: '用量會合併此 Codex home 中的多個登入 · 額度只顯示最後觀測,不合併', - title: 'Codex 用量', beta: 'Beta', overview: '總覽', usageTrend: '用量趨勢', daily: '按日', date: '日期', role: '角色', scope: '範圍', threadLabel: '執行緒', rootRole: '根任務', childRole: 'Subagent', approvalReviewerRole: '權限審批', unknownRole: '未知', noDailyData: '尚未索引到 Codex 每日用量。', noThreadData: '尚未索引到 Codex 執行緒。', lastTask: '最近任務', last7Days: '最近 7 天', last30Days: '最近 30 天', projects: '專案', projectLabel: '專案', + title: 'Codex 用量', overview: '總覽', usageTrend: '用量趨勢', daily: '按日', date: '日期', role: '角色', scope: '範圍', threadLabel: '執行緒', rootRole: '根任務', childRole: 'Subagent', approvalReviewerRole: '權限審批', unknownRole: '未知', noDailyData: '尚未索引到 Codex 每日用量。', noThreadData: '尚未索引到 Codex 執行緒。', lastTask: '最近任務', last7Days: '最近 7 天', last30Days: '最近 30 天', projects: '專案', projectLabel: '專案', unnamedSession: '未命名工作階段', unidentifiedProject: '未識別專案', parentThread: '父執行緒', parentTask: '父任務', searchThreads: '搜尋工作階段', sessions: '工作階段', modelsEffort: '模型與推理強度', clearFilters: '清除篩選條件', activeFilters: '作用中的篩選條件', all: '全部', localDirectory: '本機資料夾', lastActive: '最後活動', expand: '展開', viewAllSessions: '檢視所有工作階段', sortBy: '排序依據', usageLimits: '用量限制', resets: '重設時間', credits: '點數', unlimited: '無上限', allTime: '全部時間', behavior: '行為', settings: '設定', monthly: '按月', tokenComposition: 'Token 構成', freshInput: '未快取輸入', reasoningSubset: '已包含在輸出中', threadRoleComposition: '執行緒角色構成', childThreadsPerRootTask: '每個根任務的子執行緒數', childFreshShare: '子執行緒未快取用量占比', approvalFreshShare: '審批未快取用量占比', highEffortFreshShare: '高推理強度未快取用量占比', processedToFreshRatio: '已處理 / 未快取用量', reasoningOutputShare: '推理占輸出比例', postPatchToolCallsPerPatchCall: '每次修補呼叫的修補後工具呼叫代理量', patchCalls: '修補呼叫次數', compactions: '上下文壓縮次數', processed: '已處理', apiEquivalentCost: 'API 等效成本', apiEquivalentCostHelp: '依目前已建立索引的 Token 與現行官方 API 單價估算;不是帳單或訂閱扣款。已定價模型涵蓋率:{coverage}。', fresh: '未快取用量', input: '輸入', cachedInput: '快取輸入', output: '輸出', reasoning: '推理', @@ -972,12 +1031,12 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['zh-TW'], ), 'zh-CN': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: '对比' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '对比' }, { ...TASK8_CODEX_COPY['zh-CN'], accountSnapshotLastObserved: '用量会合并此 Codex home 中的多个登录 · 额度只显示最后观测,不合并', - title: 'Codex 用量', beta: 'Beta', overview: '概览', usageTrend: '用量趋势', daily: '按日', date: '日期', role: '角色', scope: '范围', threadLabel: '线程', rootRole: '根任务', childRole: 'Subagent', approvalReviewerRole: '权限审批', unknownRole: '未知', noDailyData: '尚未索引到 Codex 每日用量。', noThreadData: '尚未索引到 Codex 线程。', lastTask: '最近任务', last7Days: '最近 7 天', last30Days: '最近 30 天', projects: '项目', projectLabel: '项目', + title: 'Codex 用量', overview: '概览', usageTrend: '用量趋势', daily: '按日', date: '日期', role: '角色', scope: '范围', threadLabel: '线程', rootRole: '根任务', childRole: 'Subagent', approvalReviewerRole: '权限审批', unknownRole: '未知', noDailyData: '尚未索引到 Codex 每日用量。', noThreadData: '尚未索引到 Codex 线程。', lastTask: '最近任务', last7Days: '最近 7 天', last30Days: '最近 30 天', projects: '项目', projectLabel: '项目', unnamedSession: '未命名会话', unidentifiedProject: '未识别项目', parentThread: '父线程', parentTask: '父任务', searchThreads: '搜索会话', sessions: '会话', modelsEffort: '模型与推理强度', clearFilters: '清除筛选条件', activeFilters: '生效的筛选条件', all: '全部', localDirectory: '本地文件夹', lastActive: '最后活动', expand: '展开', viewAllSessions: '查看所有会话', sortBy: '排序依据', usageLimits: '用量限制', resets: '重置时间', credits: '点数', unlimited: '无限制', allTime: '全部时间', behavior: '行为', settings: '设置', monthly: '按月', tokenComposition: 'Token 构成', freshInput: '未缓存输入', reasoningSubset: '已包含在输出中', threadRoleComposition: '线程角色构成', childThreadsPerRootTask: '每个根任务的子线程数', childFreshShare: '子线程未缓存用量占比', approvalFreshShare: '审批未缓存用量占比', highEffortFreshShare: '高推理强度未缓存用量占比', processedToFreshRatio: '已处理 / 未缓存用量', reasoningOutputShare: '推理占输出比例', postPatchToolCallsPerPatchCall: '每次补丁调用的补丁后工具调用代理量', patchCalls: '补丁调用次数', compactions: '上下文压缩次数', processed: '已处理', apiEquivalentCost: 'API 等效成本', apiEquivalentCostHelp: '按当前已索引 Token 和现行官方 API 单价估算;不是账单或订阅扣费。已定价模型覆盖率:{coverage}。', fresh: '未缓存用量', input: '输入', cachedInput: '缓存输入', output: '输出', reasoning: '推理', @@ -988,12 +1047,12 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['zh-CN'], ), ja: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: '比較' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '比較' }, { ...TASK8_CODEX_COPY.ja, accountSnapshotLastObserved: 'この Codex home の複数ログインの使用量を合算 · 上限は最終観測のみで、合算しません', - title: 'Codex 使用量', beta: 'ベータ', overview: '概要', usageTrend: '使用量の推移', daily: '日別', date: '日付', role: '役割', scope: '範囲', threadLabel: 'スレッド', rootRole: 'ルート', childRole: 'サブエージェント', approvalReviewerRole: '承認レビュアー', unknownRole: '不明', noDailyData: '日別の Codex 使用量はまだ索引化されていません。', noThreadData: 'Codex スレッドはまだ索引化されていません。', lastTask: '最近のタスク', last7Days: '過去 7 日', last30Days: '過去 30 日', projects: 'プロジェクト', projectLabel: 'プロジェクト', + title: 'Codex 使用量', overview: '概要', usageTrend: '使用量の推移', daily: '日別', date: '日付', role: '役割', scope: '範囲', threadLabel: 'スレッド', rootRole: 'ルート', childRole: 'サブエージェント', approvalReviewerRole: '承認レビュアー', unknownRole: '不明', noDailyData: '日別の Codex 使用量はまだ索引化されていません。', noThreadData: 'Codex スレッドはまだ索引化されていません。', lastTask: '最近のタスク', last7Days: '過去 7 日', last30Days: '過去 30 日', projects: 'プロジェクト', projectLabel: 'プロジェクト', unnamedSession: '名前のないセッション', unidentifiedProject: '未識別のプロジェクト', parentThread: '親スレッド', parentTask: '親タスク', searchThreads: 'セッションを検索', sessions: 'セッション', modelsEffort: 'モデルと推論強度', clearFilters: 'フィルターをクリア', activeFilters: '適用中のフィルター', all: 'すべて', localDirectory: 'ローカルフォルダー', lastActive: '最終アクティブ', expand: '展開', viewAllSessions: 'すべてのセッションを表示', sortBy: '並べ替え', usageLimits: '使用量上限', resets: 'リセット', credits: 'クレジット', unlimited: '無制限', allTime: '全期間', behavior: '行動', settings: '設定', monthly: '月別', tokenComposition: 'トークン構成', freshInput: '非キャッシュ入力', reasoningSubset: '出力に含まれます', threadRoleComposition: 'スレッド役割構成', childThreadsPerRootTask: 'ルートタスクあたりの子スレッド', childFreshShare: '子スレッドの非キャッシュ使用量比率', approvalFreshShare: '承認の非キャッシュ使用量比率', highEffortFreshShare: '高推論強度の非キャッシュ使用量比率', processedToFreshRatio: '処理済み / 非キャッシュ使用量', reasoningOutputShare: '出力に占める推論', postPatchToolCallsPerPatchCall: 'パッチ呼び出しあたりのパッチ後ツール呼び出しプロキシ', patchCalls: 'パッチ呼び出し', compactions: 'コンテキスト圧縮', processed: '処理済み', apiEquivalentCost: 'API 等価コスト', apiEquivalentCostHelp: '現在索引済みの Token を現行の公式 API 単価で見積もった値です。請求額やサブスクリプション料金ではありません。価格適用率: {coverage}。', fresh: '非キャッシュ使用量', input: '入力', cachedInput: 'キャッシュ入力', output: '出力', reasoning: '推論', @@ -1004,12 +1063,12 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY.ja, ), ko: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: '비교' }, + { claude: 'Claude', codexBeta: 'Codex', compare: '비교' }, { ...TASK8_CODEX_COPY.ko, accountSnapshotLastObserved: '이 Codex home의 여러 로그인 사용량을 합산 · 한도는 마지막 관측만 표시하며 합산하지 않음', - title: 'Codex 사용량', beta: '베타', overview: '개요', usageTrend: '사용량 추이', daily: '일별', date: '날짜', role: '역할', scope: '범위', threadLabel: '스레드', rootRole: '루트', childRole: '하위 에이전트', approvalReviewerRole: '승인 검토자', unknownRole: '알 수 없음', noDailyData: '아직 일별 Codex 사용량이 인덱싱되지 않았습니다.', noThreadData: '아직 Codex 스레드가 인덱싱되지 않았습니다.', lastTask: '최근 작업', last7Days: '최근 7일', last30Days: '최근 30일', projects: '프로젝트', projectLabel: '프로젝트', + title: 'Codex 사용량', overview: '개요', usageTrend: '사용량 추이', daily: '일별', date: '날짜', role: '역할', scope: '범위', threadLabel: '스레드', rootRole: '루트', childRole: '하위 에이전트', approvalReviewerRole: '승인 검토자', unknownRole: '알 수 없음', noDailyData: '아직 일별 Codex 사용량이 인덱싱되지 않았습니다.', noThreadData: '아직 Codex 스레드가 인덱싱되지 않았습니다.', lastTask: '최근 작업', last7Days: '최근 7일', last30Days: '최근 30일', projects: '프로젝트', projectLabel: '프로젝트', unnamedSession: '이름 없는 세션', unidentifiedProject: '식별되지 않은 프로젝트', parentThread: '상위 스레드', parentTask: '상위 작업', searchThreads: '세션 검색', sessions: '세션', modelsEffort: '모델 및 추론 강도', clearFilters: '필터 지우기', activeFilters: '활성 필터', all: '전체', localDirectory: '로컬 폴더', lastActive: '마지막 활동', expand: '펼치기', viewAllSessions: '모든 세션 보기', sortBy: '정렬 기준', usageLimits: '사용량 한도', resets: '재설정', credits: '크레딧', unlimited: '무제한', allTime: '전체 기간', behavior: '행동', settings: '설정', monthly: '월별', tokenComposition: '토큰 구성', freshInput: '캐시되지 않은 입력', reasoningSubset: '출력에 포함됨', threadRoleComposition: '스레드 역할 구성', childThreadsPerRootTask: '루트 작업당 하위 스레드', childFreshShare: '하위 스레드 캐시되지 않은 사용량 비율', approvalFreshShare: '승인 캐시되지 않은 사용량 비율', highEffortFreshShare: '고강도 캐시되지 않은 사용량 비율', processedToFreshRatio: '처리됨 / 캐시되지 않은 사용량', reasoningOutputShare: '출력 중 추론 비율', postPatchToolCallsPerPatchCall: '패치 호출당 패치 후 도구 호출 프록시', patchCalls: '패치 호출', compactions: '컨텍스트 압축', processed: '처리됨', apiEquivalentCost: 'API 등가 비용', apiEquivalentCostHelp: '현재 인덱싱된 Token에 현행 공식 API 단가를 적용한 추정치입니다. 청구액이나 구독 결제액이 아닙니다. 가격 적용률: {coverage}.', fresh: '캐시되지 않은 사용량', input: '입력', cachedInput: '캐시 입력', output: '출력', reasoning: '추론', @@ -1020,12 +1079,12 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY.ko, ), 'pt-BR': providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: 'Comparar' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Comparar' }, { ...TASK8_CODEX_COPY['pt-BR'], accountSnapshotLastObserved: 'O uso combina logins neste Codex home · limites são a última observação, não somados', - title: 'Uso do Codex', beta: 'Beta', overview: 'Visão geral', usageTrend: 'Tendência de uso', daily: 'Diário', date: 'Data', role: 'Função', scope: 'Escopo', threadLabel: 'Thread', rootRole: 'Raiz', childRole: 'Subagente', approvalReviewerRole: 'Revisor de aprovação', unknownRole: 'Desconhecido', noDailyData: 'Nenhum uso diário do Codex foi indexado.', noThreadData: 'Nenhuma thread do Codex foi indexada.', lastTask: 'Tarefa recente', last7Days: 'Últimos 7 dias', last30Days: 'Últimos 30 dias', projects: 'Projetos', projectLabel: 'Projeto', + title: 'Uso do Codex', overview: 'Visão geral', usageTrend: 'Tendência de uso', daily: 'Diário', date: 'Data', role: 'Função', scope: 'Escopo', threadLabel: 'Thread', rootRole: 'Raiz', childRole: 'Subagente', approvalReviewerRole: 'Revisor de aprovação', unknownRole: 'Desconhecido', noDailyData: 'Nenhum uso diário do Codex foi indexado.', noThreadData: 'Nenhuma thread do Codex foi indexada.', lastTask: 'Tarefa recente', last7Days: 'Últimos 7 dias', last30Days: 'Últimos 30 dias', projects: 'Projetos', projectLabel: 'Projeto', unnamedSession: 'Sessão sem nome', unidentifiedProject: 'Projeto não identificado', parentThread: 'Thread pai', parentTask: 'Tarefa pai', searchThreads: 'Pesquisar sessões', sessions: 'Sessões', modelsEffort: 'Modelos e esforço', clearFilters: 'Limpar filtros', activeFilters: 'Filtros ativos', all: 'Tudo', localDirectory: 'Pasta local', lastActive: 'Última atividade', expand: 'Expandir', viewAllSessions: 'Ver todas as sessões', sortBy: 'Ordenar por', usageLimits: 'Limites de uso', resets: 'Redefinição', credits: 'Créditos', unlimited: 'Ilimitado', allTime: 'Todo o período', behavior: 'Comportamento', settings: 'Configurações', monthly: 'Mensal', tokenComposition: 'Composição de tokens', freshInput: 'Entrada sem cache', reasoningSubset: 'Incluído na saída', threadRoleComposition: 'Composição por função da thread', childThreadsPerRootTask: 'Threads filhas / tarefa raiz', childFreshShare: 'Participação do uso sem cache das threads filhas', approvalFreshShare: 'Participação do uso sem cache de aprovação', highEffortFreshShare: 'Participação do uso sem cache de alto esforço', processedToFreshRatio: 'Processado / uso sem cache', reasoningOutputShare: 'Participação do raciocínio na saída', postPatchToolCallsPerPatchCall: 'Proxy de chamadas de ferramenta pós-patch / chamada de patch', patchCalls: 'Chamadas de patch', compactions: 'Compactações', processed: 'Processado', apiEquivalentCost: 'Custo equivalente de API', apiEquivalentCostHelp: 'Estimado a partir dos Tokens indexados no momento com os preços oficiais atuais da API; não é uma fatura nem uma cobrança de assinatura. Cobertura de preços: {coverage}.', fresh: 'Uso sem cache', input: 'Entrada', cachedInput: 'Entrada em cache', output: 'Saída', reasoning: 'Raciocínio', @@ -1036,12 +1095,12 @@ const PROVIDERS: Record = { TASK5_CODEX_COPY['pt-BR'], ), id: providerTranslations( - { claude: 'Claude', codexBeta: 'Codex Beta', compare: 'Bandingkan' }, + { claude: 'Claude', codexBeta: 'Codex', compare: 'Bandingkan' }, { ...TASK8_CODEX_COPY.id, accountSnapshotLastObserved: 'Penggunaan menggabungkan login di Codex home ini · batas adalah pengamatan terakhir, tidak dijumlahkan', - title: 'Penggunaan Codex', beta: 'Beta', overview: 'Ringkasan', usageTrend: 'Tren penggunaan', daily: 'Harian', date: 'Tanggal', role: 'Peran', scope: 'Cakupan', threadLabel: 'Thread', rootRole: 'Utama', childRole: 'Subagen', approvalReviewerRole: 'Peninjau persetujuan', unknownRole: 'Tidak diketahui', noDailyData: 'Belum ada penggunaan harian Codex yang diindeks.', noThreadData: 'Belum ada thread Codex yang diindeks.', lastTask: 'Tugas terbaru', last7Days: '7 hari terakhir', last30Days: '30 hari terakhir', projects: 'Proyek', projectLabel: 'Proyek', + title: 'Penggunaan Codex', overview: 'Ringkasan', usageTrend: 'Tren penggunaan', daily: 'Harian', date: 'Tanggal', role: 'Peran', scope: 'Cakupan', threadLabel: 'Thread', rootRole: 'Utama', childRole: 'Subagen', approvalReviewerRole: 'Peninjau persetujuan', unknownRole: 'Tidak diketahui', noDailyData: 'Belum ada penggunaan harian Codex yang diindeks.', noThreadData: 'Belum ada thread Codex yang diindeks.', lastTask: 'Tugas terbaru', last7Days: '7 hari terakhir', last30Days: '30 hari terakhir', projects: 'Proyek', projectLabel: 'Proyek', unnamedSession: 'Sesi tanpa nama', unidentifiedProject: 'Proyek tidak teridentifikasi', parentThread: 'Thread induk', parentTask: 'Tugas induk', searchThreads: 'Cari sesi', sessions: 'Sesi', modelsEffort: 'Model & upaya', clearFilters: 'Hapus filter', activeFilters: 'Filter aktif', all: 'Semua', localDirectory: 'Folder lokal', lastActive: 'Terakhir aktif', expand: 'Perluas', viewAllSessions: 'Lihat semua sesi', sortBy: 'Urutkan berdasarkan', usageLimits: 'Batas penggunaan', resets: 'Reset', credits: 'Kredit', unlimited: 'Tanpa batas', allTime: 'Sepanjang waktu', behavior: 'Perilaku', settings: 'Pengaturan', monthly: 'Bulanan', tokenComposition: 'Komposisi token', freshInput: 'Input tanpa cache', reasoningSubset: 'Termasuk dalam output', threadRoleComposition: 'Komposisi peran thread', childThreadsPerRootTask: 'Thread anak / tugas utama', childFreshShare: 'Porsi penggunaan tanpa cache thread anak', approvalFreshShare: 'Porsi penggunaan tanpa cache persetujuan', highEffortFreshShare: 'Porsi penggunaan tanpa cache effort tinggi', processedToFreshRatio: 'Diproses / penggunaan tanpa cache', reasoningOutputShare: 'Porsi penalaran dalam output', postPatchToolCallsPerPatchCall: 'Proksi panggilan alat pasca-patch / panggilan patch', patchCalls: 'Panggilan patch', compactions: 'Pemadatan konteks', processed: 'Diproses', apiEquivalentCost: 'Biaya ekuivalen API', apiEquivalentCostHelp: 'Perkiraan dari Token yang saat ini terindeks dengan harga API resmi terkini; bukan tagihan atau biaya langganan. Cakupan harga: {coverage}.', fresh: 'Penggunaan tanpa cache', input: 'Input', cachedInput: 'Input cache', output: 'Output', reasoning: 'Penalaran', @@ -1215,6 +1274,11 @@ const translations: Record = { settingsGroupProviders: 'Providers', settingsGroupFeatures: 'Optional features', settingsGroupStatusBar: 'Status bar', + quotaFormatBuiltIn: 'Built-in', + quotaFormatFiveHour: '5-hour only', + quotaFormatWeekly: 'Weekly only', + quotaFormatCustom: 'Custom…', + quotaFormatShortHelp: 'Choose a compact layout. Built-in keeps the quota options above.', settingsGroupData: 'Data & refresh', settingsGroupAdvice: 'AI advice & Optimizer', adviceEffectiveness: { @@ -1516,6 +1580,11 @@ const translations: Record = { settingsGroupProviders: "Anbieter", settingsGroupFeatures: "Optionale Funktionen", settingsGroupStatusBar: "Statusleiste", + quotaFormatBuiltIn: 'Standard', + quotaFormatFiveHour: 'Nur 5 Stunden', + quotaFormatWeekly: 'Nur Woche', + quotaFormatCustom: 'Benutzerdefiniert…', + quotaFormatShortHelp: 'Kompaktes Layout wählen. „Standard“ übernimmt die obigen Kontingentoptionen.', settingsGroupData: "Daten & Aktualisierung", settingsGroupAdvice: "KI-Beratung & Optimizer", adviceEffectiveness: { @@ -1820,6 +1889,11 @@ const translations: Record = { settingsGroupProviders: '供應商', settingsGroupFeatures: '選用功能', settingsGroupStatusBar: '狀態列', + quotaFormatBuiltIn: '內建', + quotaFormatFiveHour: '僅 5 小時', + quotaFormatWeekly: '僅每週', + quotaFormatCustom: '自訂…', + quotaFormatShortHelp: '選擇精簡版面;「內建」沿用上方配額選項。', settingsGroupData: '資料與重新整理', settingsGroupAdvice: 'AI 建議與最佳化工具', adviceEffectiveness: { @@ -2117,6 +2191,11 @@ const translations: Record = { settingsGroupProviders: '供应商', settingsGroupFeatures: '可选功能', settingsGroupStatusBar: '状态栏', + quotaFormatBuiltIn: '默认', + quotaFormatFiveHour: '仅 5 小时', + quotaFormatWeekly: '仅每周', + quotaFormatCustom: '自定义…', + quotaFormatShortHelp: '选择简洁布局;“默认”沿用上方额度选项。', settingsGroupData: '数据与刷新', settingsGroupAdvice: 'AI 建议与优化器', adviceEffectiveness: { @@ -2414,6 +2493,11 @@ const translations: Record = { settingsGroupProviders: 'プロバイダー', settingsGroupFeatures: 'オプション機能', settingsGroupStatusBar: 'ステータスバー', + quotaFormatBuiltIn: '標準', + quotaFormatFiveHour: '5時間のみ', + quotaFormatWeekly: '週のみ', + quotaFormatCustom: 'カスタム…', + quotaFormatShortHelp: '簡潔な表示を選択します。「標準」は上の利用枠設定を使います。', settingsGroupData: 'データと更新', settingsGroupAdvice: 'AI アドバイス & オプティマイザー', adviceEffectiveness: { @@ -2716,6 +2800,11 @@ const translations: Record = { settingsGroupProviders: '공급자', settingsGroupFeatures: '선택 기능', settingsGroupStatusBar: '상태 표시줄', + quotaFormatBuiltIn: '기본', + quotaFormatFiveHour: '5시간만', + quotaFormatWeekly: '주간만', + quotaFormatCustom: '사용자 지정…', + quotaFormatShortHelp: '간결한 표시를 선택하세요. 기본은 위의 한도 옵션을 따릅니다.', settingsGroupData: '데이터 및 새로고침', settingsGroupAdvice: 'AI 조언 & 옵티마이저', adviceEffectiveness: { @@ -3018,6 +3107,11 @@ const translations: Record = { settingsGroupProviders: 'Provedores', settingsGroupFeatures: 'Recursos opcionais', settingsGroupStatusBar: 'Barra de status', + quotaFormatBuiltIn: 'Padrão', + quotaFormatFiveHour: 'Só 5 horas', + quotaFormatWeekly: 'Só semanal', + quotaFormatCustom: 'Personalizado…', + quotaFormatShortHelp: 'Escolha um layout compacto. Padrão mantém as opções de limite acima.', settingsGroupData: 'Dados e atualização', settingsGroupAdvice: 'Conselho de IA e Optimizer', adviceEffectiveness: { @@ -3319,6 +3413,11 @@ const translations: Record = { settingsGroupProviders: 'Penyedia', settingsGroupFeatures: 'Fitur opsional', settingsGroupStatusBar: 'Status bar', + quotaFormatBuiltIn: 'Bawaan', + quotaFormatFiveHour: 'Hanya 5 jam', + quotaFormatWeekly: 'Hanya mingguan', + quotaFormatCustom: 'Kustom…', + quotaFormatShortHelp: 'Pilih tampilan ringkas. Bawaan mengikuti opsi kuota di atas.', settingsGroupData: 'Data & penyegaran', settingsGroupAdvice: 'Saran AI & Optimizer', adviceEffectiveness: { @@ -3590,6 +3689,545 @@ const translations: Record = { // non-English UIs so the descriptions follow the plugin language too. Code / // command tokens (token, /v1/messages, opus:NN%, k/M, git/folder/flat …) are kept // verbatim. Generated with DeepSeek V4 Pro and validated for completeness. +type SharingWorkspaceDerivedField = + | 'thisMonth' + | 'totalTokens' + | 'cacheHitRate' + | 'topModel' + | 'sessionCount' + | 'tokenMix' + | 'messageCount' + | 'projectName'; + +function sharingWorkspaceCopy( + locale: SupportedLanguage, + copy: Omit, +): SharingWorkspaceTranslations { + const popup = translations[locale].popup; + return { + ...copy, + thisMonth: popup.thisMonth, + totalTokens: popup.totalTokens, + cacheHitRate: popup.cacheHitRate, + topModel: popup.model, + sessionCount: popup.sessions, + tokenMix: popup.tokenComposition, + messageCount: popup.messages, + projectName: popup.project, + }; +} + +/** Locale-complete copy for the unified sharing workspace. Presentation names + * remain provider-qualified so the selector never implies metric equivalence. */ +export const SHARING_WORKSPACE_TRANSLATIONS: Record = { + en: sharingWorkspaceCopy('en', { + eyebrow: 'Share', + panelTitle: 'Sharing workspace', + description: 'Choose a presentation, review the full export preview, then use the controls below it.', + privacyBadge: 'Local preview · explicit export', + presentationLabel: 'Presentation', + combinedPresentation: 'Combined activity heatmap', + combinedPresentationDescription: 'Claude and Codex processed-token activity by local calendar day.', + claudeCardPresentation: 'Claude Share Card', + claudeCardPresentationDescription: 'Claude-only usage totals, estimates, and optional aggregate scope details.', + claudeHeatmapPresentation: 'Claude token heatmap', + claudeHeatmapPresentationDescription: 'The legacy Claude-only GitHub-style yearly token heatmap.', + combinedUnavailable: 'Available when both Claude and Codex have indexed daily activity.', + rangeRollingGroup: 'Rolling ranges', + rangeSpecificMonthGroup: 'Specific month', + last7: 'Last 7 days', + last12Months: 'Last 12 months', + todayHourly: 'Today (hourly)', + lastMonthSuffix: 'last month', + scopeLabel: 'Scope', + scopeAllGroup: 'All Claude usage', + overall: 'Overall', + byProject: 'By project', + bySession: 'By session', + themeLabel: 'Theme', + claudeClassic: 'Claude Classic', + claudeCream: 'Claude Cream', + auroraDark: 'Aurora Dark', + autoTheme: 'Auto (follow VS Code)', + cardContentsLabel: 'Card contents', + estimatedCost: 'Estimated cost', + dailyPulse: 'Daily pulse', + badge: 'Badge', + fullNumbers: 'Full numbers', + fullNumbersHelp: 'Show exact token counts instead of compact values such as 1.2M.', + previewPending: 'The preview will appear here.', + generating: 'Generating preview…', + cardBuildFailed: 'Could not build the card:', + publishGitHub: 'Publish to GitHub…', + publishPreferenceSaveWarning: 'The file was published, but this destination could not be saved for next time.', + claudeHeatmapDisclaimer: 'Claude-only token activity. Publishing still requires the existing public-repository checks and exact confirmation.', + noClaudeData: 'No Claude usage is available for sharing yet.', + }), + 'de-DE': sharingWorkspaceCopy('de-DE', { + eyebrow: 'Freigeben', + panelTitle: 'Freigabe-Arbeitsbereich', + description: 'Wähle eine Darstellung, prüfe die vollständige Exportvorschau und nutze danach die darunterliegenden Steuerelemente.', + privacyBadge: 'Lokale Vorschau · ausdrücklicher Export', + presentationLabel: 'Darstellung', + combinedPresentation: 'Kombinierte Aktivitäts-Heatmap', + combinedPresentationDescription: 'Verarbeitete Token-Aktivität von Claude und Codex nach lokalem Kalendertag.', + claudeCardPresentation: 'Claude-Freigabekarte', + claudeCardPresentationDescription: 'Nur Claude: Nutzungssummen, Schätzungen und optionale aggregierte Bereichsdetails.', + claudeHeatmapPresentation: 'Claude-Token-Heatmap', + claudeHeatmapPresentationDescription: 'Die bisherige GitHub-artige Jahres-Heatmap nur für Claude.', + combinedUnavailable: 'Verfügbar, sobald Claude und Codex beide indexierte Tagesaktivität haben.', + rangeRollingGroup: 'Gleitende Zeiträume', + rangeSpecificMonthGroup: 'Bestimmter Monat', + last7: 'Letzte 7 Tage', + last12Months: 'Letzte 12 Monate', + todayHourly: 'Heute (stündlich)', + lastMonthSuffix: 'letzter Monat', + scopeLabel: 'Bereich', + scopeAllGroup: 'Gesamte Claude-Nutzung', + overall: 'Gesamt', + byProject: 'Nach Projekt', + bySession: 'Nach Sitzung', + themeLabel: 'Design', + claudeClassic: 'Claude Classic', + claudeCream: 'Claude Cream', + auroraDark: 'Aurora Dark', + autoTheme: 'Automatisch (VS Code folgen)', + cardContentsLabel: 'Karteninhalt', + estimatedCost: 'Geschätzte Kosten', + dailyPulse: 'Tagesrhythmus', + badge: 'Abzeichen', + fullNumbers: 'Vollständige Zahlen', + fullNumbersHelp: 'Zeigt exakte Tokenzahlen statt kompakter Werte wie 1,2 Mio.', + previewPending: 'Die Vorschau erscheint hier.', + generating: 'Vorschau wird erstellt…', + cardBuildFailed: 'Die Karte konnte nicht erstellt werden:', + publishGitHub: 'Auf GitHub veröffentlichen…', + publishPreferenceSaveWarning: 'Die Datei wurde veröffentlicht, aber dieses Ziel konnte nicht für das nächste Mal gespeichert werden.', + claudeHeatmapDisclaimer: 'Nur Claude-Tokenaktivität. Für die Veröffentlichung gelten weiterhin die Prüfungen für öffentliche Repositories und die exakte Bestätigung.', + noClaudeData: 'Noch keine Claude-Nutzung zum Teilen verfügbar.', + }), + 'zh-TW': sharingWorkspaceCopy('zh-TW', { + eyebrow: '分享', + panelTitle: '分享工作區', + description: '選擇呈現方式,先檢查完整匯出預覽,再使用下方控制項。', + privacyBadge: '本機預覽 · 明確匯出', + presentationLabel: '呈現方式', + combinedPresentation: '綜合活動熱力圖', + combinedPresentationDescription: '依本機自然日呈現 Claude 與 Codex 的已處理 Token 活動量。', + claudeCardPresentation: 'Claude 分享卡', + claudeCardPresentationDescription: '僅含 Claude 用量總計、估算與可選的彙總範圍資訊。', + claudeHeatmapPresentation: 'Claude Token 熱力圖', + claudeHeatmapPresentationDescription: '舊版僅 Claude 的 GitHub 風格年度 Token 熱力圖。', + combinedUnavailable: 'Claude 與 Codex 都有已索引的每日活動後即可使用。', + rangeRollingGroup: '滾動範圍', + rangeSpecificMonthGroup: '指定月份', + last7: '最近 7 天', + last12Months: '最近 12 個月', + todayHourly: '今天(每小時)', + lastMonthSuffix: '上個月', + scopeLabel: '範圍', + scopeAllGroup: '全部 Claude 用量', + overall: '整體', + byProject: '依專案', + bySession: '依工作階段', + themeLabel: '主題', + claudeClassic: 'Claude 經典', + claudeCream: 'Claude 奶油', + auroraDark: '極光深色', + autoTheme: '自動(跟隨 VS Code)', + cardContentsLabel: '卡片內容', + estimatedCost: '預估成本', + dailyPulse: '每日節奏', + badge: '徽章', + fullNumbers: '完整數字', + fullNumbersHelp: '顯示精確 Token 數,而不是 1.2M 等精簡值。', + previewPending: '預覽會顯示在這裡。', + generating: '正在產生預覽…', + cardBuildFailed: '無法建立卡片:', + publishGitHub: '發佈到 GitHub…', + publishPreferenceSaveWarning: '檔案已發佈,但無法儲存此目的地供下次使用。', + claudeHeatmapDisclaimer: '僅顯示 Claude Token 活動。發佈仍須通過既有的公開儲存庫檢查與精確確認。', + noClaudeData: '尚無可分享的 Claude 用量。', + }), + 'zh-CN': sharingWorkspaceCopy('zh-CN', { + eyebrow: '分享', + panelTitle: '分享工作台', + description: '选择呈现方式,先检查完整导出预览,再使用下方控件。', + privacyBadge: '本地预览 · 明确导出', + presentationLabel: '呈现方式', + combinedPresentation: '综合活动热力图', + combinedPresentationDescription: '按本地自然日呈现 Claude 与 Codex 的已处理 Token 活动量。', + claudeCardPresentation: 'Claude 分享卡', + claudeCardPresentationDescription: '仅含 Claude 用量总计、估算与可选的汇总范围信息。', + claudeHeatmapPresentation: 'Claude Token 热力图', + claudeHeatmapPresentationDescription: '旧版仅 Claude 的 GitHub 风格年度 Token 热力图。', + combinedUnavailable: 'Claude 与 Codex 都有已索引的每日活动后即可使用。', + rangeRollingGroup: '滚动范围', + rangeSpecificMonthGroup: '指定月份', + last7: '最近 7 天', + last12Months: '最近 12 个月', + todayHourly: '今天(每小时)', + lastMonthSuffix: '上个月', + scopeLabel: '范围', + scopeAllGroup: '全部 Claude 用量', + overall: '总体', + byProject: '按项目', + bySession: '按会话', + themeLabel: '主题', + claudeClassic: 'Claude 经典', + claudeCream: 'Claude 奶油', + auroraDark: '极光深色', + autoTheme: '自动(跟随 VS Code)', + cardContentsLabel: '卡片内容', + estimatedCost: '预估成本', + dailyPulse: '每日节奏', + badge: '徽章', + fullNumbers: '完整数字', + fullNumbersHelp: '显示精确 Token 数,而不是 1.2M 等紧凑值。', + previewPending: '预览会显示在这里。', + generating: '正在生成预览…', + cardBuildFailed: '无法生成卡片:', + publishGitHub: '发布到 GitHub…', + publishPreferenceSaveWarning: '文件已发布,但无法保存此目标供下次使用。', + claudeHeatmapDisclaimer: '仅显示 Claude Token 活动。发布仍须通过现有的公共仓库检查与精确确认。', + noClaudeData: '尚无可分享的 Claude 用量。', + }), + ja: sharingWorkspaceCopy('ja', { + eyebrow: '共有', + panelTitle: '共有ワークスペース', + description: '表示形式を選び、完全な書き出しプレビューを確認してから、下のコントロールを使用します。', + privacyBadge: 'ローカルプレビュー · 明示的な書き出し', + presentationLabel: '表示形式', + combinedPresentation: '統合アクティビティヒートマップ', + combinedPresentationDescription: 'ローカル暦日ごとの Claude と Codex の処理済みトークン活動。', + claudeCardPresentation: 'Claude 共有カード', + claudeCardPresentationDescription: 'Claude のみの使用量合計、推定値、任意の集計範囲情報。', + claudeHeatmapPresentation: 'Claude トークンヒートマップ', + claudeHeatmapPresentationDescription: '従来の Claude 専用 GitHub 風年間トークンヒートマップ。', + combinedUnavailable: 'Claude と Codex の両方にインデックス済みの日別活動があると利用できます。', + rangeRollingGroup: 'ローリング期間', + rangeSpecificMonthGroup: '指定月', + last7: '過去 7 日間', + last12Months: '過去 12 か月', + todayHourly: '今日(1時間ごと)', + lastMonthSuffix: '先月', + scopeLabel: '範囲', + scopeAllGroup: 'Claude の全使用量', + overall: '全体', + byProject: 'プロジェクト別', + bySession: 'セッション別', + themeLabel: 'テーマ', + claudeClassic: 'Claude クラシック', + claudeCream: 'Claude クリーム', + auroraDark: 'オーロラダーク', + autoTheme: '自動(VS Code に合わせる)', + cardContentsLabel: 'カード内容', + estimatedCost: '推定コスト', + dailyPulse: '日別パルス', + badge: 'バッジ', + fullNumbers: '完全な数値', + fullNumbersHelp: '1.2M のような省略値ではなく正確なトークン数を表示します。', + previewPending: 'プレビューはここに表示されます。', + generating: 'プレビューを生成中…', + cardBuildFailed: 'カードを作成できませんでした:', + publishGitHub: 'GitHub に公開…', + publishPreferenceSaveWarning: 'ファイルは公開されましたが、次回用にこの公開先を保存できませんでした。', + claudeHeatmapDisclaimer: 'Claude のトークン活動のみです。公開には従来どおり公開リポジトリの検査と正確な確認が必要です。', + noClaudeData: '共有できる Claude 使用量はまだありません。', + }), + ko: sharingWorkspaceCopy('ko', { + eyebrow: '공유', + panelTitle: '공유 작업 공간', + description: '표시 형식을 선택하고 전체 내보내기 미리보기를 확인한 뒤 아래 컨트롤을 사용하세요.', + privacyBadge: '로컬 미리보기 · 명시적 내보내기', + presentationLabel: '표시 형식', + combinedPresentation: '통합 활동 히트맵', + combinedPresentationDescription: '로컬 달력 날짜별 Claude와 Codex의 처리된 토큰 활동입니다.', + claudeCardPresentation: 'Claude 공유 카드', + claudeCardPresentationDescription: 'Claude 전용 사용량 합계, 추정치 및 선택적 집계 범위 정보입니다.', + claudeHeatmapPresentation: 'Claude 토큰 히트맵', + claudeHeatmapPresentationDescription: '기존 Claude 전용 GitHub 스타일 연간 토큰 히트맵입니다.', + combinedUnavailable: 'Claude와 Codex 모두 인덱싱된 일별 활동이 있을 때 사용할 수 있습니다.', + rangeRollingGroup: '이동 범위', + rangeSpecificMonthGroup: '특정 월', + last7: '최근 7일', + last12Months: '최근 12개월', + todayHourly: '오늘(시간별)', + lastMonthSuffix: '지난달', + scopeLabel: '범위', + scopeAllGroup: '전체 Claude 사용량', + overall: '전체', + byProject: '프로젝트별', + bySession: '세션별', + themeLabel: '테마', + claudeClassic: 'Claude 클래식', + claudeCream: 'Claude 크림', + auroraDark: '오로라 다크', + autoTheme: '자동(VS Code 따름)', + cardContentsLabel: '카드 내용', + estimatedCost: '예상 비용', + dailyPulse: '일별 흐름', + badge: '배지', + fullNumbers: '전체 숫자', + fullNumbersHelp: '1.2M 같은 축약값 대신 정확한 토큰 수를 표시합니다.', + previewPending: '미리보기가 여기에 표시됩니다.', + generating: '미리보기 생성 중…', + cardBuildFailed: '카드를 만들 수 없습니다:', + publishGitHub: 'GitHub에 게시…', + publishPreferenceSaveWarning: '파일은 게시되었지만 다음 사용을 위해 이 대상을 저장하지 못했습니다.', + claudeHeatmapDisclaimer: 'Claude 토큰 활동만 표시합니다. 게시하려면 기존 공개 저장소 검사와 정확한 확인이 계속 필요합니다.', + noClaudeData: '아직 공유할 Claude 사용량이 없습니다.', + }), + 'pt-BR': sharingWorkspaceCopy('pt-BR', { + eyebrow: 'Compartilhar', + panelTitle: 'Espaço de compartilhamento', + description: 'Escolha uma apresentação, revise a prévia completa da exportação e então use os controles abaixo dela.', + privacyBadge: 'Prévia local · exportação explícita', + presentationLabel: 'Apresentação', + combinedPresentation: 'Mapa de calor de atividade combinado', + combinedPresentationDescription: 'Atividade de tokens processados do Claude e do Codex por dia do calendário local.', + claudeCardPresentation: 'Cartão de compartilhamento do Claude', + claudeCardPresentationDescription: 'Totais, estimativas e detalhes opcionais de escopo agregado somente do Claude.', + claudeHeatmapPresentation: 'Mapa de calor de tokens do Claude', + claudeHeatmapPresentationDescription: 'O mapa de calor anual legado, estilo GitHub, somente do Claude.', + combinedUnavailable: 'Disponível quando Claude e Codex têm atividade diária indexada.', + rangeRollingGroup: 'Intervalos móveis', + rangeSpecificMonthGroup: 'Mês específico', + last7: 'Últimos 7 dias', + last12Months: 'Últimos 12 meses', + todayHourly: 'Hoje (por hora)', + lastMonthSuffix: 'mês passado', + scopeLabel: 'Escopo', + scopeAllGroup: 'Todo o uso do Claude', + overall: 'Geral', + byProject: 'Por projeto', + bySession: 'Por sessão', + themeLabel: 'Tema', + claudeClassic: 'Claude Clássico', + claudeCream: 'Claude Creme', + auroraDark: 'Aurora Escuro', + autoTheme: 'Automático (seguir o VS Code)', + cardContentsLabel: 'Conteúdo do cartão', + estimatedCost: 'Custo estimado', + dailyPulse: 'Pulso diário', + badge: 'Selo', + fullNumbers: 'Números completos', + fullNumbersHelp: 'Mostra contagens exatas de tokens em vez de valores compactos como 1,2 mi.', + previewPending: 'A prévia aparecerá aqui.', + generating: 'Gerando prévia…', + cardBuildFailed: 'Não foi possível criar o cartão:', + publishGitHub: 'Publicar no GitHub…', + publishPreferenceSaveWarning: 'O arquivo foi publicado, mas este destino não pôde ser salvo para a próxima vez.', + claudeHeatmapDisclaimer: 'Somente atividade de tokens do Claude. A publicação ainda exige as verificações de repositório público e a confirmação exata existentes.', + noClaudeData: 'Ainda não há uso do Claude disponível para compartilhar.', + }), + id: sharingWorkspaceCopy('id', { + eyebrow: 'Bagikan', + panelTitle: 'Ruang kerja berbagi', + description: 'Pilih presentasi, tinjau pratinjau ekspor lengkap, lalu gunakan kontrol di bawahnya.', + privacyBadge: 'Pratinjau lokal · ekspor eksplisit', + presentationLabel: 'Presentasi', + combinedPresentation: 'Heatmap aktivitas gabungan', + combinedPresentationDescription: 'Aktivitas token yang diproses Claude dan Codex per hari kalender lokal.', + claudeCardPresentation: 'Kartu Berbagi Claude', + claudeCardPresentationDescription: 'Total penggunaan, estimasi, dan detail cakupan agregat opsional khusus Claude.', + claudeHeatmapPresentation: 'Heatmap token Claude', + claudeHeatmapPresentationDescription: 'Heatmap token tahunan gaya GitHub lama yang hanya mencakup Claude.', + combinedUnavailable: 'Tersedia saat Claude dan Codex memiliki aktivitas harian terindeks.', + rangeRollingGroup: 'Rentang bergulir', + rangeSpecificMonthGroup: 'Bulan tertentu', + last7: '7 hari terakhir', + last12Months: '12 bulan terakhir', + todayHourly: 'Hari ini (per jam)', + lastMonthSuffix: 'bulan lalu', + scopeLabel: 'Cakupan', + scopeAllGroup: 'Semua penggunaan Claude', + overall: 'Keseluruhan', + byProject: 'Menurut proyek', + bySession: 'Menurut sesi', + themeLabel: 'Tema', + claudeClassic: 'Claude Klasik', + claudeCream: 'Claude Krem', + auroraDark: 'Aurora Gelap', + autoTheme: 'Otomatis (ikuti VS Code)', + cardContentsLabel: 'Isi kartu', + estimatedCost: 'Perkiraan biaya', + dailyPulse: 'Denyut harian', + badge: 'Lencana', + fullNumbers: 'Angka lengkap', + fullNumbersHelp: 'Tampilkan jumlah token persis, bukan nilai ringkas seperti 1,2 jt.', + previewPending: 'Pratinjau akan muncul di sini.', + generating: 'Membuat pratinjau…', + cardBuildFailed: 'Kartu tidak dapat dibuat:', + publishGitHub: 'Publikasikan ke GitHub…', + publishPreferenceSaveWarning: 'File telah dipublikasikan, tetapi tujuan ini tidak dapat disimpan untuk penggunaan berikutnya.', + claudeHeatmapDisclaimer: 'Hanya aktivitas token Claude. Publikasi tetap memerlukan pemeriksaan repositori publik dan konfirmasi target yang persis.', + noClaudeData: 'Belum ada penggunaan Claude yang dapat dibagikan.', + }), +}; + +export interface ShareCardArtifactTranslations { + 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; + today: string; + last7: string; + last30: string; + thisMonth: string; + last12Months: string; +} + +export interface HeatmapArtifactTranslations { + tokens: string; + sessions: string; + usage: string; + inClaudeCode: string; + no: string; + on: string; + less: string; + more: string; + madeWith: string; + combinedTitle: string; + combined: string; + processedTokens: string; + combinedFooter: string; +} + +export const SHARE_CARD_ARTIFACT_TRANSLATIONS: 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', today: 'today', last7: 'the last 7 days', last30: 'the last 30 days', + thisMonth: 'this month', last12Months: 'the last 12 months', + }, + 'de-DE': { + subtitle: 'Momentaufnahme der KI-Programmier-Nutzung', totalTokens: 'Gesamttoken', spent: 'geschätzte Ausgaben', sessionsUnit: 'Sitzungen', + estCost: 'geschätzte Kosten', cacheHit: 'Cache-Treffer', topModel: 'Topmodell', sessions: 'Sitzungen', messages: 'Nachrichten', + workflows: 'Workflows', peakCtx: 'max. Kontext', tokenMix: 'Token-Mix', input: 'Eingabe', output: 'Ausgabe', + cacheWrite: 'Cache-Schreiben', cacheRead: 'Cache-Lesen', daily: 'Tagesverlauf', hourly: 'Stundenverlauf', peak: 'Spitze', + madeWith: 'Erstellt mit Claude Code Usage', today: 'heute', last7: 'die letzten 7 Tage', last30: 'die letzten 30 Tage', + thisMonth: 'dieser Monat', last12Months: 'die letzten 12 Monate', + }, + '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 製作', today: '今天', last7: '最近 7 天', last30: '最近 30 天', + thisMonth: '本月', last12Months: '最近 12 個月', + }, + '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 制作', today: '今天', last7: '最近 7 天', last30: '最近 30 天', + thisMonth: '本月', last12Months: '最近 12 个月', + }, + ja: { + subtitle: 'AI コーディング使用量のスナップショット', totalTokens: '総トークン', spent: '推定使用額', sessionsUnit: 'セッション', + estCost: '推定コスト', cacheHit: 'キャッシュヒット', topModel: '最多モデル', sessions: 'セッション', messages: 'メッセージ', + workflows: 'ワークフロー', peakCtx: '最大コンテキスト', tokenMix: 'トークン構成', input: '入力', output: '出力', + cacheWrite: 'キャッシュ書込', cacheRead: 'キャッシュ読込', daily: '日別推移', hourly: '時間別推移', peak: 'ピーク', + madeWith: 'Claude Code Usage で作成', today: '今日', last7: '過去 7 日間', last30: '過去 30 日間', + thisMonth: '今月', last12Months: '過去 12 か月', + }, + ko: { + subtitle: 'AI 코딩 사용량 스냅샷', totalTokens: '총 토큰', spent: '예상 지출', sessionsUnit: '세션', + estCost: '예상 비용', cacheHit: '캐시 적중', topModel: '최다 모델', sessions: '세션', messages: '메시지', + workflows: '워크플로', peakCtx: '최대 컨텍스트', tokenMix: '토큰 구성', input: '입력', output: '출력', + cacheWrite: '캐시 쓰기', cacheRead: '캐시 읽기', daily: '일별 흐름', hourly: '시간별 흐름', peak: '최고', + madeWith: 'Claude Code Usage로 제작', today: '오늘', last7: '최근 7일', last30: '최근 30일', + thisMonth: '이번 달', last12Months: '최근 12개월', + }, + 'pt-BR': { + subtitle: 'Resumo do uso de programação com IA', totalTokens: 'total de tokens', spent: 'gasto estimado', sessionsUnit: 'sessões', + estCost: 'custo estimado', cacheHit: 'acerto de cache', topModel: 'modelo principal', sessions: 'sessões', messages: 'mensagens', + workflows: 'fluxos', peakCtx: 'pico de contexto', tokenMix: 'Composição de tokens', input: 'Entrada', output: 'Saída', + cacheWrite: 'Gravação em cache', cacheRead: 'Leitura do cache', daily: 'Pulso diário', hourly: 'Pulso por hora', peak: 'pico', + madeWith: 'Criado com Claude Code Usage', today: 'hoje', last7: 'os últimos 7 dias', last30: 'os últimos 30 dias', + thisMonth: 'este mês', last12Months: 'os últimos 12 meses', + }, + id: { + subtitle: 'Ringkasan penggunaan coding AI', totalTokens: 'total token', spent: 'perkiraan pengeluaran', sessionsUnit: 'sesi', + estCost: 'perkiraan biaya', cacheHit: 'cache hit', topModel: 'model teratas', sessions: 'sesi', messages: 'pesan', + workflows: 'alur kerja', peakCtx: 'konteks puncak', tokenMix: 'Komposisi token', input: 'Input', output: 'Output', + cacheWrite: 'Tulis cache', cacheRead: 'Baca cache', daily: 'Denyut harian', hourly: 'Denyut per jam', peak: 'puncak', + madeWith: 'Dibuat dengan Claude Code Usage', today: 'hari ini', last7: '7 hari terakhir', last30: '30 hari terakhir', + thisMonth: 'bulan ini', last12Months: '12 bulan terakhir', + }, +}; + +export const HEATMAP_ARTIFACT_TRANSLATIONS: Record = { + en: { tokens: 'tokens', sessions: 'sessions', usage: 'usage', inClaudeCode: 'in Claude Code', no: 'No', on: 'on', less: 'Less', more: 'More', madeWith: 'Made with Claude Code Usage', combinedTitle: 'Claude + Codex local activity', combined: 'Combined', processedTokens: 'processed tokens', combinedFooter: 'Local activity volume · not productivity, billing, or provider equivalence' }, + 'de-DE': { tokens: 'Token', sessions: 'Sitzungen', usage: 'Nutzung', inClaudeCode: 'in Claude Code', no: 'Keine', on: 'am', less: 'Weniger', more: 'Mehr', madeWith: 'Erstellt mit Claude Code Usage', combinedTitle: 'Lokale Aktivität von Claude + Codex', combined: 'Kombiniert', processedTokens: 'verarbeitete Token', combinedFooter: 'Lokales Aktivitätsvolumen · keine Produktivität, Abrechnung oder Anbieter-Gleichwertigkeit' }, + 'zh-TW': { tokens: 'Token', sessions: '會話', usage: '用量', inClaudeCode: '於 Claude Code', no: '無', on: '日期', less: '較少', more: '較多', madeWith: '由 Claude Code Usage 製作', combinedTitle: 'Claude + Codex 本機活動', combined: '綜合', processedTokens: '已處理 Token', combinedFooter: '本機活動量 · 不代表生產力、帳單或供應商等價性' }, + 'zh-CN': { tokens: 'Token', sessions: '会话', usage: '用量', inClaudeCode: '于 Claude Code', no: '无', on: '日期', less: '较少', more: '较多', madeWith: '由 Claude Code Usage 制作', combinedTitle: 'Claude + Codex 本地活动', combined: '综合', processedTokens: '已处理 Token', combinedFooter: '本地活动量 · 不代表生产力、账单或供应商等价性' }, + ja: { tokens: 'トークン', sessions: 'セッション', usage: '使用量', inClaudeCode: '(Claude Code)', no: 'なし:', on: '日付', less: '少ない', more: '多い', madeWith: 'Claude Code Usage で作成', combinedTitle: 'Claude + Codex のローカルアクティビティ', combined: '合計', processedTokens: '処理済みトークン', combinedFooter: 'ローカル活動量 · 生産性、請求、プロバイダー同等性を示すものではありません' }, + ko: { tokens: '토큰', sessions: '세션', usage: '사용량', inClaudeCode: 'Claude Code에서', no: '없음:', on: '날짜', less: '적게', more: '많이', madeWith: 'Claude Code Usage로 제작', combinedTitle: 'Claude + Codex 로컬 활동', combined: '합계', processedTokens: '처리된 토큰', combinedFooter: '로컬 활동량 · 생산성, 청구 또는 제공자 동등성을 의미하지 않음' }, + 'pt-BR': { tokens: 'tokens', sessions: 'sessões', usage: 'uso', inClaudeCode: 'no Claude Code', no: 'Sem', on: 'em', less: 'Menos', more: 'Mais', madeWith: 'Criado com Claude Code Usage', combinedTitle: 'Atividade local do Claude + Codex', combined: 'Combinado', processedTokens: 'tokens processados', combinedFooter: 'Volume de atividade local · não representa produtividade, cobrança ou equivalência entre provedores' }, + id: { tokens: 'token', sessions: 'sesi', usage: 'penggunaan', inClaudeCode: 'di Claude Code', no: 'Tidak ada', on: 'pada', less: 'Lebih sedikit', more: 'Lebih banyak', madeWith: 'Dibuat dengan Claude Code Usage', combinedTitle: 'Aktivitas lokal Claude + Codex', combined: 'Gabungan', processedTokens: 'token diproses', combinedFooter: 'Volume aktivitas lokal · bukan produktivitas, tagihan, atau kesetaraan penyedia' }, +}; + +export const SHARE_BADGE_TRANSLATIONS: Record> = { + 'context-marathoner': { + en: { title: 'Context Marathon', line: "Half-marathon of context — the model's still catching its breath." }, + 'de-DE': { title: 'Kontextmarathon', line: 'Ein Halbmarathon an Kontext — das Modell holt noch Luft.' }, + 'zh-TW': { title: 'Context 馬拉松', line: '上下文跑了個半馬,模型還在喘。' }, 'zh-CN': { title: 'Context 马拉松', line: '上下文跑了个半马,模型还在喘。' }, + ja: { title: 'コンテキストマラソン', line: 'コンテキストはハーフマラソン級。モデルも息切れ中。' }, ko: { title: '컨텍스트 마라톤', line: '컨텍스트가 하프 마라톤급이라 모델도 숨을 고르는 중.' }, + 'pt-BR': { title: 'Maratona de contexto', line: 'Uma meia maratona de contexto — o modelo ainda recupera o fôlego.' }, id: { title: 'Maraton konteks', line: 'Konteks setara setengah maraton — model masih mengatur napas.' }, + }, + 'cache-saver': { + en: { title: 'Cache Alchemist', line: 'High cache hits — barely a token wasted.' }, + 'de-DE': { title: 'Cache-Alchemist', line: 'Hohe Cache-Treffer — kaum ein Token verschwendet.' }, + 'zh-TW': { title: '快取日子人', line: '快取命中高,token 沒白燒。' }, 'zh-CN': { title: '缓存日子人', line: '缓存命中高,token 没白烧。' }, + ja: { title: 'キャッシュ錬金術師', line: '高いキャッシュヒット率。無駄なトークンはほぼなし。' }, ko: { title: '캐시 연금술사', line: '높은 캐시 적중률로 낭비되는 토큰이 거의 없습니다.' }, + 'pt-BR': { title: 'Alquimista do cache', line: 'Muitos acertos de cache — quase nenhum token desperdiçado.' }, id: { title: 'Alkemis cache', line: 'Cache hit tinggi — nyaris tak ada token terbuang.' }, + }, + 'token-sprinter': { + en: { title: 'Token Sprinter', line: 'Full throttle. 🔥' }, 'de-DE': { title: 'Token-Sprinter', line: 'Vollgas. 🔥' }, + 'zh-TW': { title: '無限火力', line: '開炮!!!' }, 'zh-CN': { title: '无限火力', line: '开炮!!!' }, + ja: { title: 'トークンスプリンター', line: '全開です。🔥' }, ko: { title: '토큰 스프린터', line: '전속력. 🔥' }, + 'pt-BR': { title: 'Velocista de tokens', line: 'A todo vapor. 🔥' }, id: { title: 'Sprinter token', line: 'Kecepatan penuh. 🔥' }, + }, + 'workflow-pilot': { + en: { title: 'Workflow Pilot', line: "You're not coding — you're running a crew." }, 'de-DE': { title: 'Workflow-Pilot', line: 'Du programmierst nicht nur — du leitest ein Team.' }, + 'zh-TW': { title: 'Agent 包工頭', line: '你不是在寫程式,你是在使喚一支小隊。' }, 'zh-CN': { title: 'Agent 包工头', line: '你不是在写代码,你是在使唤一支小队。' }, + ja: { title: 'ワークフローパイロット', line: 'コーディングではなく、チームを動かしています。' }, ko: { title: '워크플로 파일럿', line: '코딩을 넘어 하나의 팀을 지휘하고 있습니다.' }, + 'pt-BR': { title: 'Piloto de fluxos', line: 'Você não está só programando — está liderando uma equipe.' }, id: { title: 'Pilot alur kerja', line: 'Anda bukan sekadar coding — Anda memimpin tim.' }, + }, + 'steady-builder': { + en: { title: 'Steady Builder', line: 'No rush, no burnout — steady progress.' }, 'de-DE': { title: 'Stetiger Builder', line: 'Keine Hektik, kein Burnout — stetiger Fortschritt.' }, + 'zh-TW': { title: '節奏大師', line: '不捲不燥,程式碼穩步推進。' }, 'zh-CN': { title: '节奏大师', line: '不卷不燥,代码稳步推进。' }, + ja: { title: '着実なビルダー', line: '焦らず燃え尽きず、着実に前進。' }, ko: { title: '꾸준한 빌더', line: '서두르지 않고 번아웃 없이 꾸준히 전진.' }, + 'pt-BR': { title: 'Construtor constante', line: 'Sem pressa nem esgotamento — progresso constante.' }, id: { title: 'Pembangun konsisten', line: 'Tanpa terburu-buru atau burnout — terus maju.' }, + }, +}; + +export function artifactLocale(value: string | undefined): SupportedLanguage { + return value === 'de-DE' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja' || + value === 'ko' || value === 'pt-BR' || value === 'id' ? value : 'en'; +} + const SETTINGS_I18N: Partial>> = { 'de-DE': { 'language': { label: 'Anzeigesprache', help: 'UI-Sprache. "auto" folgt VS Code.' }, @@ -3599,19 +4237,19 @@ const SETTINGS_I18N: Partial= 0 && tsMs - prev <= windowMs; } diff --git a/src/quotaFormat.ts b/src/quotaFormat.ts index 938e9803..b8784f1c 100644 --- a/src/quotaFormat.ts +++ b/src/quotaFormat.ts @@ -17,6 +17,7 @@ export interface QuotaStatusOptions { fiveHourOnly: boolean; // quotaFiveHourOnly (default false) showScopedWeekly: boolean; // opt-in model-scoped weekly caps (default false) resetFormat?: ResetCountdownFormat; // resetCountdownFormat (default 'decimal') + template?: string; // statusBarQuotaFormat (default ''); grammar below now?: number; // for the countdown; defaults to Date.now() } @@ -209,12 +210,225 @@ function shownWindows(windows: QuotaWindow[] | null, opts: QuotaStatusOptions): }); } +// --- Status-bar format template (statusBarQuotaFormat) --------------------- +// +// The escape hatch for a different set of windows, order or separators. Empty +// by default, so the clean built-in layout above is still what ships. +// +// Grammar: +// {5h.FIELD} the 5-hour session window +// {wk.FIELD} {7d.FIELD} the all-models week (aliases) +// {model:NAME.FIELD} a per-model weekly cap, matched on the API's own +// scope label, case-insensitively +// FIELD is pct | reset | label +// reset follows resetCountdownFormat unless it names a style of its own: +// {5h.reset:decimal} "4.8h", :units "4h 48m", :clock "18:20" / "2026-07-22", +// :at "16:59" / "Thu 16:59" (the tooltip's wall clock) +// {{ and }} are literal braces +// +// Separators (| · / , -) cut the template into segments. A segment whose +// tokens all name windows the account does not report is dropped together with +// its separator, literal text included, so "{5h.pct} · fable {model:Fable.pct}" +// keeps working on a plan with no per-model cap. An unrecognised token is left +// verbatim rather than silently dropped, so a typo shows up in the bar instead +// of quietly producing a shorter line. + +const TEMPLATE_TOKEN = /\{\{|\}\}|\{([^{}]+)\}/g; + +// Short choices for the dashboard control. The free-form grammar remains +// available only when someone explicitly selects Custom. +export const FIVE_HOUR_QUOTA_STATUS_TEMPLATE = '{5h.label} {5h.pct}'; +export const WEEKLY_QUOTA_STATUS_TEMPLATE = '{wk.label} {wk.pct}'; +export const CUSTOM_QUOTA_STATUS_TEMPLATE = + `${FIVE_HOUR_QUOTA_STATUS_TEMPLATE} · ${WEEKLY_QUOTA_STATUS_TEMPLATE}`; + +/** A separator run, captured with its own spacing so the bar shows it exactly + * as it was typed. */ +const SEPARATOR = /(\s*[|·/,-]+\s*)/; + +type TemplateField = 'pct' | 'reset' | 'label'; + +/** 'at' is the wall clock; the rest are the resetCountdownFormat values. */ +type ResetStyle = ResetCountdownFormat | 'at'; + +interface TemplateToken { + name: string; + field: TemplateField; + style?: ResetStyle; // reset only; absent means "follow the global format" +} + +type TemplatePart = + | { kind: 'text'; text: string } + | { kind: 'unknown'; text: string } + | ({ kind: 'token' } & TemplateToken); + +/** Stands in for a token whose window the account does not report, so the + * spacing that surrounded it can close up without a trace. */ +const GAP = '\u0000'; +const GAP_RUN = /(\s*)\u0000(\s*)/g; + +/** Splits "model:Fable 5.1.pct" into its window name and field. Uses the LAST + * dot, because a model name can contain one. Returns null when the token is + * not a window.field pair this formatter knows. */ +function parseToken(body: string): TemplateToken | null { + const dot = body.lastIndexOf('.'); + if (dot <= 0) { + return null; + } + const name = body.slice(0, dot).trim().toLowerCase(); + const [field, style, ...rest] = body + .slice(dot + 1) + .split(':') + .map((p) => p.trim().toLowerCase()); + const known = name === '5h' || name === 'wk' || name === '7d' || /^model:./.test(name); + if (!known || rest.length > 0 || (field !== 'pct' && field !== 'reset' && field !== 'label')) { + return null; + } + if (style === undefined) { + return { name, field }; + } + const styled = style === 'decimal' || style === 'units' || style === 'clock' || style === 'at'; + return field === 'reset' && styled ? { name, field, style } : null; +} + +function parseTemplate(template: string): TemplatePart[] { + const parts: TemplatePart[] = []; + let last = 0; + for (const m of template.matchAll(TEMPLATE_TOKEN)) { + const start = m.index ?? 0; + if (start > last) { + parts.push({ kind: 'text', text: template.slice(last, start) }); + } + last = start + m[0].length; + if (m[1] === undefined) { + parts.push({ kind: 'text', text: m[0][0] }); // {{ or }} + continue; + } + const token = parseToken(m[1]); + parts.push(token ? { kind: 'token', ...token } : { kind: 'unknown', text: m[0] }); + } + if (last < template.length) { + parts.push({ kind: 'text', text: template.slice(last) }); + } + return parts; +} + +/** The window a parsed token refers to, or undefined when the account does not + * report it. */ +function tokenWindow(windows: QuotaWindow[], name: string): QuotaWindow | undefined { + if (name === '5h') { + return windows.find((w) => w.kind === 'session'); + } + if (name === 'wk' || name === '7d') { + return windows.find((w) => w.kind === 'weekly_all'); + } + const model = name.slice('model:'.length).trim(); + return windows.find( + (w) => w.kind === 'weekly_scoped' && (w.scopeLabel ?? '').trim().toLowerCase() === model + ); +} + +/** The windows a template actually names, in order, skipping any the account + * does not report. Unlike shownWindows this ignores the 5h-only and + * scoped-weekly toggles: those answer "which windows do you want", and an + * explicit template has already answered it. */ +function templateWindows(windows: QuotaWindow[], template: string): QuotaWindow[] { + const found: QuotaWindow[] = []; + for (const part of parseTemplate(template)) { + if (part.kind !== 'token') { + continue; + } + const w = tokenWindow(windows, part.name); + if (w && !found.includes(w)) { + found.push(w); + } + } + return found; +} + +function templateReset(resetsAt: string, now: number, style?: ResetStyle): string { + if (style !== 'at') { + return compactReset(resetsAt, now, style); + } + const t = Date.parse(resetsAt); + return isNaN(t) ? '' : wallClockReset(new Date(t), now); +} + +/** Renders a template against the live windows. '' when nothing survives, which + * is what hides the status-bar item. */ +function formatQuotaTemplate( + windows: QuotaWindow[], + template: string, + now: number, + resetFormat?: ResetCountdownFormat +): string { + let out = ''; + let sep = ''; // the separator in front of the segment being built + let segment = ''; + let named = 0; // window tokens in this segment... + let reported = 0; // ...and how many of them the account reports + let unknown = false; // ...and whether one of them was a typo + const endSegment = (nextSep: string): void => { + const text = segment + .replace(GAP_RUN, (_m, before: string, after: string) => (before || after ? ' ' : '')) + .trim(); + // Nothing named, or something named and present: print it. A segment that + // named only windows the account lacks goes, unless it also carries a typo, + // which has to stay visible. + if (text && (named === 0 || reported > 0 || unknown)) { + out = out ? out + sep + text : text; + } + sep = nextSep; + segment = ''; + named = 0; + reported = 0; + unknown = false; + }; + + for (const part of parseTemplate(template)) { + if (part.kind === 'text') { + // split() with a capture group alternates text and separator. + part.text.split(SEPARATOR).forEach((piece, i) => { + if (i % 2 === 1) { + endSegment(piece); + } else { + segment += piece; + } + }); + continue; + } + if (part.kind === 'unknown') { + segment += part.text; + unknown = true; + continue; + } + named++; + const w = tokenWindow(windows, part.name); + if (!w) { + segment += GAP; + continue; + } + reported++; + if (part.field === 'pct') { + segment += `${Math.round(w.utilization)}%`; + } else if (part.field === 'label') { + segment += segmentLabel(w); + } else { + // A reset the API left blank closes up the same way a missing window does. + segment += templateReset(w.resetsAt, now, part.style ?? resetFormat) || GAP; + } + } + endSegment(''); + return out; +} + /** * The inner status-bar quota text (no icon prefix). Examples: * default → "5h 6% · wk 1%" * showReset → "5h 6% ↻4.8h | wk 1% ↻1.6d" * fiveHourOnly → "5h 6%" * showScopedWeekly → "5h 6% · wk 1% (fable 12%)" + * A non-empty opts.template replaces all four and is handled above. * Returns '' when there's nothing to show. * * Per-model caps that reset alongside the all-models week are nested into its @@ -223,6 +437,10 @@ function shownWindows(windows: QuotaWindow[] | null, opts: QuotaStatusOptions): */ export function formatQuotaStatusText(windows: QuotaWindow[] | null, opts: QuotaStatusOptions): string { const now = opts.now ?? Date.now(); + const template = (opts.template ?? '').trim(); + if (template) { + return formatQuotaTemplate(windows ?? [], template, now, opts.resetFormat); + } const share = (w: QuotaWindow): string => `${segmentLabel(w)} ${Math.round(w.utilization)}%`; const parts = groupQuotaRows(shownWindows(windows, opts)).map((row) => { let s = share(row.window); @@ -250,7 +468,11 @@ export function formatQuotaStatusText(windows: QuotaWindow[] | null, opts: Quota * excluded on purpose: a red bar with no visible cause reads as a bug. The * tooltip lists every window unconditionally, so the figure stays reachable. */ export function worstShownUtilisation(windows: QuotaWindow[] | null, opts: QuotaStatusOptions): number { - return shownWindows(windows, opts).reduce((worst, w) => Math.max(worst, w.utilization), 0); + const template = (opts.template ?? '').trim(); + const shown = template + ? templateWindows(windows ?? [], template) + : shownWindows(windows, opts); + return shown.reduce((worst, w) => Math.max(worst, w.utilization), 0); } /** How full a bar is, in the three steps the UI paints. */ diff --git a/src/settings.ts b/src/settings.ts index aab0e727..667cc93d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -52,6 +52,9 @@ export interface SettingDef { maxLength?: number; secret?: boolean; // mask the input (apiKey) multiline?: boolean; // render a textarea + // Compatibility-only settings remain readable/resettable but are omitted + // from the dashboard so a superseding control can be the single UI switch. + visible?: boolean; // Dashboard visibility. Omitted settings are Claude-only; explicitly list // both providers for truly shared controls. providers?: SettingProvider[]; @@ -305,7 +308,7 @@ export const SETTINGS: SettingDef[] = [ default: true, storage: 'state', group: 'providers', - label: 'Enable Codex Beta', + label: 'Enable Codex', help: 'Read privacy-safe usage aggregates from local Codex session logs.', providers: ['claude', 'codex'], }, @@ -369,6 +372,7 @@ export const SETTINGS: SettingDef[] = [ 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.', + visible: false, }, { key: 'showEfficiency', @@ -386,7 +390,7 @@ export const SETTINGS: SettingDef[] = [ storage: 'state', group: 'features', label: 'Enable sharing workspace', - help: 'On by default. Show the Compare sharing workspace and provider share card. Turn it off to hide sharing UI; exporting still requires an explicit action.', + help: 'On by default. Show the single preview-first sharing workspace. Turn it off to hide sharing UI; the legacy export commands still open the matching preview explicitly.', providers: ['claude', 'codex'], }, { @@ -465,13 +469,13 @@ export const SETTINGS: SettingDef[] = [ { key: 'codex.statusMetric', type: 'enum', - default: 'fresh', + default: 'processed', storage: 'state', group: 'statusBar', label: 'Codex status metric', - help: "Today's uncached usage, processed tokens, or output tokens.", - enumValues: ['fresh', 'processed', 'output'], - enumLabels: ['Uncached', 'Processed', 'Output'], + help: "Today's processed tokens (default), uncached usage, or output tokens.", + enumValues: ['processed', 'fresh', 'output'], + enumLabels: ['Processed', 'Uncached', 'Output'], providers: ['codex'], }, { @@ -565,6 +569,17 @@ export const SETTINGS: SettingDef[] = [ enumValues: ['decimal', 'units', 'clock'], enumLabels: ['Decimal (4.8h / 1.6d)', 'Units (4h 48m / 1d 14h)', 'Local time (18:20 / 2026-07-22)'], }, + { + // Explicit status-bar layout replacing the built-in one; grammar in quotaFormat.ts. + key: 'statusBarQuotaFormat', + type: 'string', + default: '', + storage: 'state', + group: 'statusBar', + label: 'Quota: status bar format', + help: 'Empty keeps the built-in layout (5h 6% · wk 1%). Otherwise: {5h.pct}, {wk.pct} (or {7d.pct}) and {model:Fable.pct}, each also taking .reset and .label — e.g. "{5h.pct} | {7d.pct} · {model:Fable.pct}". A window your plan does not report renders empty. .reset can name its own style: {5h.reset:units}, :decimal, :clock or :at (wall clock).', + maxLength: 120, + }, { key: 'workflowQuotaWarnPercent', type: 'number', diff --git a/src/shareCardSvg.ts b/src/shareCardSvg.ts index 5928e410..aaf6b6c6 100644 --- a/src/shareCardSvg.ts +++ b/src/shareCardSvg.ts @@ -9,19 +9,26 @@ // • auroraDark — technical, high-contrast, social-share ready. // Landscape 1200×680 only for now (square/portrait/story = a later patch). -import { ShareCardData, rangeLabel } from './shareCard'; +import { ShareCardData, ShareRange } from './shareCard'; +import { escapeSvgAttribute, escapeSvgText } from './svgEscape'; +import { + SHARE_BADGE_TRANSLATIONS, + SHARE_CARD_ARTIFACT_TRANSLATIONS, + ShareCardArtifactTranslations, + artifactLocale, +} from './i18n'; +import { SupportedLanguage } from './types'; 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 esc = escapeSvgText; 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 { +function compact(n: number, lang: CardLang = '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') { @@ -31,6 +38,13 @@ function compact(n: number, lang: 'en' | 'zh-CN' | 'zh-TW' = 'en', keepDecimal = if (a >= 1e4) return t(n / 1e4) + wan; return String(Math.round(n)); } + if (lang !== 'en') { + return new Intl.NumberFormat(lang, { + notation: 'compact', + minimumFractionDigits: keepDecimal ? 1 : 0, + maximumFractionDigits: 1, + }).format(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'; @@ -41,11 +55,15 @@ 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 { +function shortDay(iso: string | undefined, locale: SupportedLanguage): 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; + if (!m) return iso; + return new Intl.DateTimeFormat(locale, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])))); } // ---- Theme tokens ----------------------------------------------------------- @@ -91,10 +109,10 @@ export const SHARE_CARD_THEMES: Record = { panelFill: 'rgba(255,255,255,0.74)', panelBorder: '#F0D8C9', primaryText: '#2B2B2B', - secondaryText: '#6B6B6B', - mutedText: '#9A8478', + secondaryText: '#57443A', + mutedText: '#654E43', softLine: '#F0D8C9', - watermark: 'rgba(43,43,43,0.50)', + watermark: '#654E43', badgeFill: 'rgba(255,255,255,0.86)', badgeBorder: 'rgba(200,90,43,0.16)', }, @@ -111,10 +129,10 @@ export const SHARE_CARD_THEMES: Record = { panelFill: 'rgba(255,255,255,0.82)', panelBorder: 'rgba(200,90,43,0.14)', primaryText: '#6B341F', - secondaryText: '#9A6A55', - mutedText: '#B9907E', + secondaryText: '#5F3527', + mutedText: '#684033', softLine: '#EFD8CB', - watermark: 'rgba(107,52,31,0.58)', + watermark: '#684033', badgeFill: 'rgba(255,255,255,0.86)', badgeBorder: 'rgba(200,90,43,0.20)', }, @@ -131,10 +149,10 @@ export const SHARE_CARD_THEMES: Record = { panelFill: 'rgba(255,255,255,0.10)', panelBorder: 'rgba(255,255,255,0.20)', primaryText: '#FFF7F2', - secondaryText: '#C9D1D9', - mutedText: '#9CA3AF', + secondaryText: '#FFF7F2', + mutedText: '#F5EFEA', softLine: 'rgba(255,255,255,0.18)', - watermark: 'rgba(255,247,242,0.62)', + watermark: '#F5EFEA', badgeFill: 'rgba(255,255,255,0.12)', badgeBorder: 'rgba(255,255,255,0.24)', }, @@ -149,92 +167,41 @@ export function resolveShareCardTheme(theme: ShareCardTheme | undefined, isDark? 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 type CardLang = SupportedLanguage; export function cardLang(lang?: string): CardLang { - return lang === 'zh-CN' || lang === 'zh-TW' ? lang : 'en'; + return artifactLocale(lang); } -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 = + SHARE_CARD_ARTIFACT_TRANSLATIONS; +export const BADGE_COPY = SHARE_BADGE_TRANSLATIONS; + +function localizedRangeLabel( + range: ShareRange, + supplied: string | undefined, + locale: CardLang, + copy: ShareCardArtifactTranslations, +): string { + if (range.startsWith('month:')) { + const match = /^(\d{4})-(\d{2})$/.exec(range.slice('month:'.length)); + if (match) { + return new Intl.DateTimeFormat(locale, { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }).format(new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, 1))); + } + } + switch (range) { + case 'today': return copy.today; + case 'week': return copy.last7; + case 'last30': return copy.last30; + case 'month': return copy.thisMonth; + case 'year': return copy.last12Months; + default: return supplied || String(range); + } } -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 --------------------------------------------------------------- @@ -263,7 +230,7 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption const p: string[] = []; p.push( - `` + `` ); // Aurora background: vertical gradient + two soft radial blobs (deterministic). @@ -280,7 +247,8 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption // --- 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) : ''); + const range = localizedRangeLabel(data.range, data.rangeLabel, L, S) + + (data.projectName ? ' · ' + truncate(data.projectName, 28) : ''); p.push(`${esc(range)}`); // --- Corner (top-right): avatar + badge card + optional name --- @@ -291,7 +259,7 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption const ax = W - M - s; const ay = 46; p.push(``); - p.push(``); + p.push(``); p.push(``); avatarBottom = ay + s; } @@ -338,7 +306,7 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption let heroValue = ''; let heroUnit = ''; if (data.totalTokens != null) { - heroValue = opts.fullNumbers ? data.totalTokens.toLocaleString('en-US') : compact(data.totalTokens, L, true); + heroValue = opts.fullNumbers ? data.totalTokens.toLocaleString(L) : compact(data.totalTokens, L, true); heroUnit = S.totalTokens; } else if (data.estimatedCost != null) { heroValue = formatMoney(data.estimatedCost); @@ -456,10 +424,10 @@ export function renderShareCardSvg(data: ShareCardData, opts: ShareCardSvgOption p.push(``); }); if (data.rhythmStart) { - p.push(`${esc(shortDay(data.rhythmStart))}`); + p.push(`${esc(shortDay(data.rhythmStart, L))}`); } if (data.rhythmEnd && nn > 1) { - p.push(`${esc(shortDay(data.rhythmEnd))}`); + p.push(`${esc(shortDay(data.rhythmEnd, L))}`); } }, }); diff --git a/src/sharingCommandIntent.ts b/src/sharingCommandIntent.ts new file mode 100644 index 00000000..16e9c8b3 --- /dev/null +++ b/src/sharingCommandIntent.ts @@ -0,0 +1,42 @@ +export type SharingTemplate = 'combinedHeatmap' | 'claudeShareCard' | 'claudeHeatmap'; + +export interface SharingCommandIntent { + revision: number; + template: SharingTemplate; +} + +/** + * Provider-lifetime command history with a separate, acknowledgement-gated + * live intent. The revision is never browser state: it only prevents a delayed + * acknowledgement from consuming a newer explicit host command. + */ +export class SharingCommandIntentLedger { + private revision = 0; + private liveIntent: SharingCommandIntent | undefined; + + issue(template: SharingTemplate): SharingCommandIntent { + const intent = { revision: ++this.revision, template }; + this.liveIntent = intent; + return { ...intent }; + } + + pending(): SharingCommandIntent | undefined { + return this.liveIntent ? { ...this.liveIntent } : undefined; + } + + acknowledge(revision: number): boolean { + if (!Number.isSafeInteger(revision) || this.liveIntent?.revision !== revision) { + return false; + } + this.liveIntent = undefined; + return true; + } + + clearPending(): void { + this.liveIntent = undefined; + } + + latestRevision(): number { + return this.revision; + } +} diff --git a/src/statusBar.ts b/src/statusBar.ts index 2f2503fd..40c3604f 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -53,6 +53,7 @@ export class StatusBarManager { private quotaFiveHourOnly: boolean = false; // show only the 5h window private showResetInBar: boolean = false; // append reset countdown to the bar private resetCountdownFormat: ResetCountdownFormat = 'decimal'; // style of that countdown (#74) + private quotaFormat: string = ''; // statusBarQuotaFormat; '' keeps the built-in layout private provider: 'claude' | 'codex' = 'claude'; private lastClaudeUsage: { todayData: UsageData | null; @@ -139,7 +140,8 @@ export class StatusBarManager { showScopedWeekly: boolean = false, quotaFiveHourOnly: boolean = false, showResetInBar: boolean = false, - resetCountdownFormat: ResetCountdownFormat = 'decimal' + resetCountdownFormat: ResetCountdownFormat = 'decimal', + quotaFormat: string = '' ): void { this.showCost = showCost; this.showContext = showContext; @@ -149,6 +151,7 @@ export class StatusBarManager { this.quotaFiveHourOnly = quotaFiveHourOnly; this.showResetInBar = showResetInBar; this.resetCountdownFormat = resetCountdownFormat; + this.quotaFormat = quotaFormat; if (!showContext) { this.contextItem.hide(); } @@ -349,7 +352,9 @@ export class StatusBarManager { showReset: this.showResetInBar, fiveHourOnly: this.quotaFiveHourOnly, showScopedWeekly: this.showScopedWeekly, - resetFormat: this.resetCountdownFormat + resetFormat: this.resetCountdownFormat, + // When set this names the windows itself, so the toggles above no longer apply. + template: this.quotaFormat }; const text = formatQuotaStatusText(live, opts); if (!text) { diff --git a/src/svgEscape.ts b/src/svgEscape.ts new file mode 100644 index 00000000..2f6f08e5 --- /dev/null +++ b/src/svgEscape.ts @@ -0,0 +1,58 @@ +export function escapeSvgText(value: unknown): string { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +export function escapeSvgAttribute(value: unknown): string { + return escapeSvgText(value) + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function approximateGlyphWidthEm(character: string): number { + if (/\s/u.test(character)) return 0.35; + const codePoint = character.codePointAt(0) ?? 0; + if ( + codePoint >= 0x1100 && + (codePoint <= 0x11ff || + (codePoint >= 0x2e80 && codePoint <= 0x9fff) || + (codePoint >= 0xac00 && codePoint <= 0xd7af) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + codePoint >= 0x1f000) + ) { + return 1; + } + if (/[WM@#%&]/u.test(character)) return 0.98; + if (/[A-Z]/u.test(character)) return 0.74; + if (/[a-z0-9]/u.test(character)) return 0.62; + return 0.5; +} + +export function fitSvgText( + value: string, + maxWidthPx: number, + fontSizePx: number, +): string { + const characters = Array.from(value); + if (approximateSvgTextWidth(value, fontSizePx) <= maxWidthPx) return value; + + const available = Math.max(0, maxWidthPx - fontSizePx); + const kept: string[] = []; + let used = 0; + for (const character of characters) { + const advance = approximateGlyphWidthEm(character) * fontSizePx; + if (used + advance > available) break; + kept.push(character); + used += advance; + } + return `${kept.join('').trimEnd()}…`; +} + +export function approximateSvgTextWidth(value: string, fontSizePx: number): number { + return Array.from(value).reduce( + (total, character) => total + approximateGlyphWidthEm(character) * fontSizePx, + 0, + ); +} diff --git a/src/test/claudeIncrementalIndex.test.ts b/src/test/claudeIncrementalIndex.test.ts index 0daad04f..6fd926f1 100644 --- a/src/test/claudeIncrementalIndex.test.ts +++ b/src/test/claudeIncrementalIndex.test.ts @@ -64,6 +64,37 @@ function usageLine( return JSON.stringify(value); } +test('usage records retain numeric evidence but not assistant response bodies', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-compact-usage-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-compact'); + await mkdir(project, { recursive: true }); + const source = JSON.parse(usageLine('large-response', 12, 4)) as Record; + source.message.content = [{ type: 'text', text: 'synthetic-only-'.repeat(17_000) }]; + source.message.usage.cache_creation = { + ephemeral_1h_input_tokens: 2, + ephemeral_5m_input_tokens: 0, + }; + source.costUSD = 1.25; + await writeFile(path.join(project, 'session-compact.jsonl'), `${JSON.stringify(source)}\n`, 'utf8'); + + const incremental = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { + analyzeContent: true, + }); + const full = await ClaudeDataLoader.loadUsageRecords(root, { analyzeContent: true }); + for (const record of [incremental.records[0], full.records[0]]) { + assert.equal(record.message.usage.input_tokens, 12); + assert.equal(record.message.usage.output_tokens, 4); + assert.deepEqual(record.message.usage, source.message.usage); + assert.equal(record.message.model, 'claude-sonnet-4-5'); + assert.equal(record.message.id, 'message-large-response'); + assert.equal(record.requestId, 'request-large-response'); + assert.equal(record.costUSD, 1.25); + assert.equal('content' in record.message, false); + assert.equal(JSON.stringify(record).includes('synthetic-only-'), false); + } +}); + function promptLine(text: string, timestamp = '2026-08-21T08:00:00.000Z'): string { return JSON.stringify({ type: 'user', @@ -235,53 +266,59 @@ test('cold production index preserves the established full-loader record semanti }); test('content analysis is materialized from per-file contributions without a second body scan', async () => { - const { root, first } = await fixture(); - await appendFile(first, [ - JSON.stringify({ - type: 'assistant', - uuid: 'tool-use-line', - timestamp: '2026-08-21T08:02:00.000Z', - message: { - role: 'assistant', - content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/tmp/a' } }], - }, - }), - JSON.stringify({ - type: 'user', - uuid: 'tool-result-line', - timestamp: '2026-08-21T08:03:00.000Z', - message: { - role: 'user', - content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'fixture result' }], - }, - }), - '', - ].join('\n'), 'utf8'); + const previousNow = Date.now; + Date.now = () => Date.parse('2026-09-10T12:00:00.000Z'); + try { + const { root, first } = await fixture(); + await appendFile(first, [ + JSON.stringify({ + type: 'assistant', + uuid: 'tool-use-line', + timestamp: '2026-08-21T08:02:00.000Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/tmp/a' } }], + }, + }), + JSON.stringify({ + type: 'user', + uuid: 'tool-result-line', + timestamp: '2026-08-21T08:03:00.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'fixture result' }], + }, + }), + '', + ].join('\n'), 'utf8'); - const incremental = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { - analyzeContent: true, - windowDays: 30, - }); - const full = await ClaudeDataLoader.loadUsageRecords(root, { - analyzeContent: true, - windowDays: 30, - }); + const incremental = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { + analyzeContent: true, + windowDays: 30, + }); + const full = await ClaudeDataLoader.loadUsageRecords(root, { + analyzeContent: true, + windowDays: 30, + }); - assert.equal(incremental.diagnostics.bodyReads, 2); - assert.deepEqual(incremental.contentAnalysis, full.contentAnalysis); + assert.equal(incremental.diagnostics.bodyReads, 2); + assert.deepEqual(incremental.contentAnalysis, full.contentAnalysis); - const tail = `${promptLine('content tail', '2026-08-21T10:00:00.000Z')}\n`; - await appendFile(first, tail, 'utf8'); - const warm = await updateClaudeUsageIndex(incremental.index, root, { - analyzeContent: true, - windowDays: 30, - }); - const warmFull = await ClaudeDataLoader.loadUsageRecords(root, { - analyzeContent: true, - windowDays: 30, - }); - assert.equal(warm.diagnostics.bodyReads, 1); - assert.deepEqual(warm.contentAnalysis, warmFull.contentAnalysis); + const tail = `${promptLine('content tail', '2026-08-21T10:00:00.000Z')}\n`; + await appendFile(first, tail, 'utf8'); + const warm = await updateClaudeUsageIndex(incremental.index, root, { + analyzeContent: true, + windowDays: 30, + }); + const warmFull = await ClaudeDataLoader.loadUsageRecords(root, { + analyzeContent: true, + windowDays: 30, + }); + assert.equal(warm.diagnostics.bodyReads, 1); + assert.deepEqual(warm.contentAnalysis, warmFull.contentAnalysis); + } finally { + Date.now = previousNow; + } }); test('cross-file tool results retain global tool and Skill preamble attribution', async () => { @@ -445,6 +482,387 @@ test('calibration follows missing request IDs, a cross-file winner, and cutoff e } }); +test('a newly started session file does not rebuild the established corpus', async (t) => { + const previousNow = Date.now; + Date.now = () => Date.parse('2026-09-10T12:00:00.000Z'); + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-new-file-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-new-file'); + await mkdir(project, { recursive: true }); + const files: string[] = []; + try { + for (let index = 0; index < 64; index += 1) { + const file = path.join(project, `session-${String(index).padStart(3, '0')}.jsonl`); + files.push(file); + await writeFile(file, `${analysisTextLine( + `new-file-seed-${index}`, + `seed ${index}`, + new Date(Date.parse('2026-09-09T00:00:00.000Z') + index * 1_000).toISOString(), + )} +`, 'utf8'); + } + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root); + assert.equal(cold.diagnostics.bodyReads, 64); + + // Every new session starts its own transcript while other sessions append. + await appendFile(files[7], `${analysisTextLine( + 'new-file-tail-a', 'tail a', '2026-09-09T00:10:00.000Z', + )} +`, 'utf8'); + await appendFile(files[40], `${analysisTextLine( + 'new-file-tail-b', 'tail b', '2026-09-09T00:11:00.000Z', + )} +`, 'utf8'); + await writeFile(path.join(project, 'session-new.jsonl'), `${analysisTextLine( + 'new-file-fresh', 'fresh session', '2026-09-09T00:12:00.000Z', + )} +`, 'utf8'); + + const warm = await updateClaudeUsageIndex(cold.index, root); + const full = await ClaudeDataLoader.loadUsageRecords(root, { analyzeContent: true }); + + // File identity is (dev, inode), and NTFS reuses file record numbers: a file + // created here can land on the identity of one the fixture already indexed, + // which reads as a move and legitimately forces the full rebuild. That is a + // property of the filesystem, not of this path, so skip rather than assert + // a number the run cannot deliver. The correctness check below still holds. + if (warm.diagnostics.changed.move > 0) { + t.skip('the filesystem reused a file identity, so this refresh is a move, not an addition'); + return; + } + + assert.equal(warm.diagnostics.bodyReads, 3); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('the window drifting past an old event keeps the refresh incremental', async (t) => { + const previousNow = Date.now; + let now = Date.parse('2026-09-10T12:00:00.000Z'); + Date.now = () => now; + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-window-drift-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-window-drift'); + await mkdir(project, { recursive: true }); + const files: string[] = []; + try { + // One file holds an event that is about to fall out of the window, the rest + // are an established corpus that nothing touches. + for (let index = 0; index < 32; index += 1) { + const file = path.join(project, `session-${String(index).padStart(3, '0')}.jsonl`); + files.push(file); + await writeFile(file, `${analysisTextLine( + `drift-seed-${index}`, + `seed ${index}`, + new Date(Date.parse('2026-09-10T00:00:00.000Z') + index * 1_000).toISOString(), + )} +`, 'utf8'); + } + // Only this file holds an event old enough to leave the window when the + // clock moves below; every other file stays entirely inside it. + await appendFile(files[3], `${analysisTextLine( + 'drift-expiring', 'about to expire', '2026-09-09T13:00:00.000Z', + )} +`, 'utf8'); + + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { windowDays: 1 }); + assert.equal(cold.diagnostics.bodyReads, 32); + + // The clock moves past the oldest event of files[3]: it now needs a body + // read to recompute its aggregate ('cutoff'), while another session simply + // appends. Before this path existed, that pair forced every body to be + // re-read — the ordinary case on a long history, where the window is always + // drifting past something. + now = Date.parse('2026-09-10T14:00:00.000Z'); + await appendFile(files[20], `${analysisTextLine( + 'drift-tail', 'tail', '2026-09-10T13:55:00.000Z', + )} +`, 'utf8'); + + const warm = await updateClaudeUsageIndex(cold.index, root, { windowDays: 1 }); + const full = await ClaudeDataLoader.loadUsageRecords(root, { + analyzeContent: true, + windowDays: 1, + }); + + if (warm.diagnostics.changed.move > 0) { + t.skip('the filesystem reused a file identity, so this refresh is a move, not an addition'); + return; + } + + assert.ok( + warm.diagnostics.bodyReads < 32, + `window drift re-read the whole corpus: ${warm.diagnostics.bodyReads} bodies`, + ); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('a file that is appended to while losing an event to the window stays incremental', async (t) => { + const previousNow = Date.now; + let now = Date.parse('2026-09-10T12:00:00.000Z'); + Date.now = () => now; + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-window-append-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-window-append'); + await mkdir(project, { recursive: true }); + const files: string[] = []; + try { + for (let index = 0; index < 8; index += 1) { + const file = path.join(project, `session-${String(index).padStart(3, '0')}.jsonl`); + files.push(file); + await writeFile(file, `${analysisTextLine( + `window-append-seed-${index}`, + `seed ${index}`, + new Date(Date.parse('2026-09-10T00:00:00.000Z') + index * 1_000).toISOString(), + )} +`, 'utf8'); + } + // This file alone holds an event old enough to leave the window below. + await appendFile(files[2], `${analysisTextLine( + 'window-append-expiring', 'about to expire', '2026-09-09T13:00:00.000Z', + )} +`, 'utf8'); + + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { windowDays: 1 }); + assert.equal(cold.diagnostics.bodyReads, 8); + + // The same file is appended to in the refresh where its oldest event leaves + // the window. Its stored aggregate still counts the expired event, so the + // tail cannot simply be added to it and the file is re-read in full — but + // the body only grew, and re-reading one file is no reason to re-read all eight. + now = Date.parse('2026-09-10T14:00:00.000Z'); + await appendFile(files[2], `${analysisTextLine( + 'window-append-tail', 'tail', '2026-09-10T13:55:00.000Z', + )} +`, 'utf8'); + const warm = await updateClaudeUsageIndex(cold.index, root, { windowDays: 1 }); + const full = await ClaudeDataLoader.loadUsageRecords(root, { + analyzeContent: true, + windowDays: 1, + }); + assert.ok( + warm.diagnostics.bodyReads < 8, + `an appended file losing an event re-read the whole corpus: ${warm.diagnostics.bodyReads} bodies, ` + + `changed=${JSON.stringify(warm.diagnostics.changed)}`, + ); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('mixed window rebuild and append give a new UUID to the earliest file', async () => { + const previousNow = Date.now; + let now = Date.parse('2026-09-10T12:00:00.000Z'); + Date.now = () => now; + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-mixed-window-owner-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-mixed-window-owner'); + await mkdir(project, { recursive: true }); + try { + const earlier = path.join(project, 'earlier.jsonl'); + const later = path.join(project, 'later.jsonl'); + await writeFile(earlier, `${analysisTextLine( + 'mixed-earlier-seed', 'early seed', '2026-09-10T00:00:00.000Z', + )}\n`, 'utf8'); + await writeFile(later, [ + analysisTextLine('mixed-later-seed', 'later seed', '2026-09-10T00:01:00.000Z'), + analysisTextLine('mixed-expiring', 'expires', '2026-09-09T13:00:00.000Z'), + ].join('\n') + '\n', 'utf8'); + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { windowDays: 1 }); + + now = Date.parse('2026-09-10T14:00:00.000Z'); + await appendFile(earlier, `${analysisTextLine( + 'mixed-shared-uuid', 'earlier owner', '2026-09-10T13:55:00.000Z', + )}\n`, 'utf8'); + await appendFile(later, `${analysisTextLine( + 'mixed-shared-uuid', 'later should not own', '2026-09-10T13:56:00.000Z', + )}\n`, 'utf8'); + const warm = await updateClaudeUsageIndex(cold.index, root, { windowDays: 1 }); + const full = await ClaudeDataLoader.loadUsageRecords(root, { + analyzeContent: true, + windowDays: 1, + }); + assert.equal(warm.diagnostics.bodyReads, 2); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('same-file expiry plus an earlier UUID preemption falls back to ordered analysis', async () => { + const previousNow = Date.now; + let now = Date.parse('2026-09-10T12:00:00.000Z'); + Date.now = () => now; + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-same-file-uuid-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-same-file-uuid'); + await mkdir(project, { recursive: true }); + try { + const earlier = path.join(project, 'earlier.jsonl'); + const later = path.join(project, 'later.jsonl'); + await writeFile(earlier, [ + analysisTextLine('earlier-seed', 'early seed', '2026-09-10T00:00:00.000Z'), + analysisTextLine('earlier-expiring', 'expires', '2026-09-09T13:00:00.000Z'), + ].join('\n') + '\n', 'utf8'); + await writeFile(later, `${analysisTextLine( + 'shared-window-uuid', 'later owner', '2026-09-10T00:01:00.000Z', + )}\n`, 'utf8'); + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root, { windowDays: 1 }); + + now = Date.parse('2026-09-10T14:00:00.000Z'); + await appendFile(earlier, `${analysisTextLine( + 'shared-window-uuid', 'earlier owner', '2026-09-10T13:55:00.000Z', + )}\n`, 'utf8'); + const warm = await updateClaudeUsageIndex(cold.index, root, { windowDays: 1 }); + const full = await ClaudeDataLoader.loadUsageRecords(root, { + analyzeContent: true, + windowDays: 1, + }); + assert.equal(warm.diagnostics.bodyReads, 3); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('a new file carrying an already-owned UUID falls back to the full rebuild', async () => { + const previousNow = Date.now; + Date.now = () => Date.parse('2026-09-10T12:00:00.000Z'); + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-new-file-owned-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-new-file-owned'); + await mkdir(project, { recursive: true }); + try { + const established = path.join(project, 'session-established.jsonl'); + await writeFile(established, `${analysisTextLine( + 'shared-owned-uuid', 'established content', '2026-09-09T10:00:00.000Z', + )} +`, 'utf8'); + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root); + + // Earlier by timestamp, so the full loader hands ownership to the new file. + await writeFile(path.join(project, 'session-earlier.jsonl'), `${analysisTextLine( + 'shared-owned-uuid', 'a different body for the same uuid', '2026-09-09T09:00:00.000Z', + )} +`, 'utf8'); + + const warm = await updateClaudeUsageIndex(cold.index, root); + const full = await ClaudeDataLoader.loadUsageRecords(root, { analyzeContent: true }); + + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('concurrent appends to several files stay incremental instead of forcing a full rebuild', async () => { + const previousNow = Date.now; + Date.now = () => Date.parse('2026-09-10T12:00:00.000Z'); + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-multi-append-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-multi-append'); + await mkdir(project, { recursive: true }); + const files: string[] = []; + try { + for (let index = 0; index < 64; index += 1) { + const file = path.join(project, `session-${String(index).padStart(3, '0')}.jsonl`); + files.push(file); + await writeFile(file, `${usageLine(`multi-append-${index}`, index + 1, 1, { + timestamp: new Date(Date.parse('2026-09-09T00:00:00.000Z') + index * 1_000).toISOString(), + })} +`, 'utf8'); + } + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root); + assert.equal(cold.diagnostics.bodyReads, 64); + + // Three sessions writing at once is the ordinary case for a machine running + // several agents, not an edge case. + const appended = [files[10], files[30], files[63]]; + let offset = 0; + let appendedBytes = 0; + for (const file of appended) { + offset += 1; + const tail = `${usageLine(`multi-append-tail-${offset}`, 9, 3, { + timestamp: new Date(Date.parse('2026-09-09T00:10:00.000Z') + offset * 1_000).toISOString(), + })} +`; + appendedBytes += Buffer.byteLength(tail); + await appendFile(file, tail, 'utf8'); + } + + const warm = await updateClaudeUsageIndex(cold.index, root); + const full = await ClaudeDataLoader.loadUsageRecords(root, { analyzeContent: true }); + + assert.equal(warm.diagnostics.bodyReads, appended.length); + assert.equal(warm.diagnostics.bytesRead, appendedBytes); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + } finally { + Date.now = previousNow; + } +}); + +test('a 645-session corpus reads only seven live tails and one new session', async (t) => { + const previousNow = Date.now; + Date.now = () => Date.parse('2026-09-10T12:00:00.000Z'); + const root = await mkdtemp(path.join(os.tmpdir(), 'ccu-claude-many-sessions-')); + roots.push(root); + const project = path.join(root, 'projects', '-fixture-many-sessions'); + await mkdir(project, { recursive: true }); + const files: string[] = []; + try { + const start = Date.parse('2026-09-09T00:00:00.000Z'); + for (let index = 0; index < 645; index += 1) { + const file = path.join(project, `session-${String(index).padStart(3, '0')}.jsonl`); + files.push(file); + await writeFile(file, `${usageLine(`many-seed-${index}`, 10, 1, { + timestamp: new Date(start + index * 1_000).toISOString(), + })}\n`, 'utf8'); + } + const cold = await updateClaudeUsageIndex(createClaudeUsageIndex(), root); + assert.equal(cold.diagnostics.bodyReads, 645); + + let expectedBytesRead = 0; + for (const [offset, index] of [7, 91, 183, 275, 367, 459, 644].entries()) { + const tail = `${usageLine(`many-tail-${index}`, 20, 2, { + timestamp: new Date(start + (1_800 + offset) * 1_000).toISOString(), + })}\n`; + expectedBytesRead += Buffer.byteLength(tail); + await appendFile(files[index], tail, 'utf8'); + } + const newFile = `${usageLine('many-new', 30, 3, { + timestamp: '2026-09-09T00:31:00.000Z', + })}\n`; + // A new file is read once to establish canonical source order and once to + // parse its body. Both reads remain bounded to the new file, never history. + expectedBytesRead += 2 * Buffer.byteLength(newFile); + await writeFile(path.join(project, 'session-new.jsonl'), newFile, 'utf8'); + + const warm = await updateClaudeUsageIndex(cold.index, root); + const full = await ClaudeDataLoader.loadUsageRecords(root, { analyzeContent: true }); + assert.deepEqual(warm.contentAnalysis, full.contentAnalysis); + await assertMatchesFull(root, warm.records); + // Some filesystems reuse a removed file identity, legitimately turning a + // new session into a move/rebuild. Correctness still holds; only the + // bounded-read assertion becomes inapplicable. + if (warm.diagnostics.changed.move > 0) { + t.skip('the filesystem reused a file identity, so this refresh is a move'); + return; + } + assert.equal(warm.diagnostics.filesDiscovered, 646); + assert.equal(warm.diagnostics.bodyReads, 8); + assert.equal(warm.diagnostics.linesParsed, 8); + assert.equal(warm.diagnostics.bytesRead, expectedBytesRead); + } finally { + Date.now = previousNow; + } +}); + test('default content analysis does not revisit historical file contributions after a small append', async () => { const previousNow = Date.now; Date.now = () => Date.parse('2026-09-10T12:00:00.000Z'); diff --git a/src/test/codexStatus.test.ts b/src/test/codexStatus.test.ts index ecf63d06..de419a9e 100644 --- a/src/test/codexStatus.test.ts +++ b/src/test/codexStatus.test.ts @@ -59,7 +59,7 @@ function limit( }; } -test('Codex status defaults to fresh and shows remaining weekly quota', () => { +test('the explicit uncached Codex metric preserves its value and shows remaining weekly quota', () => { const formatted = formatCodexStatus(scope, 'fresh', limit(NOW + 60_000, [ { label: 'primary', diff --git a/src/test/combinedHeatmap.test.ts b/src/test/combinedHeatmap.test.ts index 66f74303..fed7699d 100644 --- a/src/test/combinedHeatmap.test.ts +++ b/src/test/combinedHeatmap.test.ts @@ -272,3 +272,24 @@ test('title, filename, and Markdown are bounded and deterministic', () => { '![Claude + Codex activity](claude-codex-activity-90d-2026-07-20.svg)', ); }); + +test('combined exported SVG copy is localized in all eight UI locales', () => { + const expected = { + en: ['Less', 'More', 'Made with Claude Code Usage'], + 'de-DE': ['Weniger', 'Mehr', 'Erstellt mit Claude Code Usage'], + 'zh-TW': ['較少', '較多', '由 Claude Code Usage 製作'], + 'zh-CN': ['较少', '较多', '由 Claude Code Usage 制作'], + ja: ['少ない', '多い', 'Claude Code Usage で作成'], + ko: ['적게', '많이', 'Claude Code Usage로 제작'], + 'pt-BR': ['Menos', 'Mais', 'Criado com Claude Code Usage'], + id: ['Lebih sedikit', 'Lebih banyak', 'Dibuat dengan Claude Code Usage'], + }; + for (const [locale, markers] of Object.entries(expected)) { + const svg = renderCombinedHeatmapSvg({}, { + range: '30d', + endDateISO: '2026-07-22', + locale, + }); + for (const marker of markers) assert.match(svg, new RegExp(marker)); + } +}); diff --git a/src/test/combinedHeatmapI18n.test.ts b/src/test/combinedHeatmapI18n.test.ts index 0fe3b757..6bf01a56 100644 --- a/src/test/combinedHeatmapI18n.test.ts +++ b/src/test/combinedHeatmapI18n.test.ts @@ -1,60 +1,86 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; import { test } from 'node:test'; import * as assert from 'node:assert/strict'; +import { + I18n, + SHARING_WORKSPACE_TRANSLATIONS, + SharingWorkspaceTranslations, +} from '../i18n'; import { SupportedLanguage } from '../types'; -// webview.ts imports vscode, so this repository-policy test inspects the source -// instead of loading the module under plain node:test. This mirrors the settings -// catalogue coverage guard in quotaI18n.test.ts. -const SOURCE = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'webview.ts'), 'utf8'); const LOCALES: SupportedLanguage[] = ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']; const NON_ENGLISH = LOCALES.filter((locale) => locale !== 'en'); +const WEBVIEW_SOURCE = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'webview.ts'), + 'utf8', +); -function copyFields(): string[] { - const start = SOURCE.indexOf('interface CombinedHeatmapUiCopy {'); - const end = SOURCE.indexOf('\n}', start); +function combinedCopyFields(): string[] { + const start = WEBVIEW_SOURCE.indexOf('interface CombinedHeatmapUiCopy {'); + const end = WEBVIEW_SOURCE.indexOf('\n}', start); assert.ok(start >= 0 && end > start, 'CombinedHeatmapUiCopy interface must exist'); - const fields = [...SOURCE.slice(start, end).matchAll(/^\s+(\w+): string;$/gm)] + const fields = [...WEBVIEW_SOURCE.slice(start, end).matchAll(/^\s+(\w+): string;$/gm)] .map((match) => match[1]); - assert.equal(fields.length, 38, 'the Share studio copy contract changed; update its locale coverage'); + assert.equal(fields.length, 38, 'the combined presentation copy contract changed; update locale coverage'); return fields; } -function copyFunction(): string { - const start = SOURCE.indexOf('function combinedHeatmapUiCopy('); - const end = SOURCE.indexOf('\nexport class UsageWebviewProvider', start); +function combinedCopyFunction(): string { + const start = WEBVIEW_SOURCE.indexOf('function combinedHeatmapUiCopy('); + const end = WEBVIEW_SOURCE.indexOf('\nexport class UsageWebviewProvider', start); assert.ok(start >= 0 && end > start, 'combinedHeatmapUiCopy must exist'); - return SOURCE.slice(start, end); + return WEBVIEW_SOURCE.slice(start, end); } -function localeBranch(locale: SupportedLanguage): string | undefined { - const source = copyFunction(); - const marker = `if (locale === '${locale}')`; - const markerStart = source.indexOf(marker); - if (markerStart < 0) { - return undefined; - } +function combinedLocaleBranch(locale: SupportedLanguage): string | undefined { + const source = combinedCopyFunction(); + const markerStart = source.indexOf(`if (locale === '${locale}')`); + if (markerStart < 0) return undefined; const objectStart = source.indexOf('return {', markerStart); const objectEnd = source.indexOf('\n };', objectStart); assert.ok(objectStart >= 0 && objectEnd > objectStart, `${locale}: locale object must be parseable`); return source.slice(objectStart, objectEnd); } -test('every non-English locale owns complete active Share studio copy', () => { - const translatedFields = copyFields().filter((field) => !field.startsWith('intensity')); +test('every non-English locale retains complete combined-presentation copy', () => { + const translatedFields = combinedCopyFields().filter((field) => !field.startsWith('intensity')); const missing: string[] = []; for (const locale of NON_ENGLISH) { - const branch = localeBranch(locale); + const branch = combinedLocaleBranch(locale); if (!branch) { missing.push(`${locale}:branch`); continue; } for (const field of translatedFields) { - if (!new RegExp(`\\b${field}:`).test(branch)) { - missing.push(`${locale}:${field}`); - } + if (!new RegExp(`\\b${field}:`).test(branch)) missing.push(`${locale}:${field}`); } } - assert.deepEqual(missing, [], `missing Share studio translations: ${missing.join(', ')}`); + assert.deepEqual(missing, [], `missing combined-presentation translations: ${missing.join(', ')}`); +}); + +test('every locale owns complete active sharing-workspace copy', () => { + const englishFields = Object.keys( + SHARING_WORKSPACE_TRANSLATIONS.en, + ) as (keyof SharingWorkspaceTranslations)[]; + assert.ok(englishFields.length >= 40, 'the unified sharing workspace copy contract is unexpectedly small'); + + for (const locale of LOCALES) { + const copy = SHARING_WORKSPACE_TRANSLATIONS[locale]; + assert.deepEqual( + Object.keys(copy).sort(), + [...englishFields].sort(), + `${locale}: sharing copy fields`, + ); + for (const field of englishFields) { + assert.ok(copy[field].trim().length > 0, `${locale}:${field}`); + } + } +}); + +test('I18n exposes the selected locale sharing-workspace copy without fallback', () => { + for (const locale of LOCALES) { + I18n.setLanguage(locale); + assert.strictEqual(I18n.sharingWorkspace, SHARING_WORKSPACE_TRANSLATIONS[locale]); + } }); diff --git a/src/test/dashboardOnlySettings.test.ts b/src/test/dashboardOnlySettings.test.ts index 9b3d7f70..3da9ab96 100644 --- a/src/test/dashboardOnlySettings.test.ts +++ b/src/test/dashboardOnlySettings.test.ts @@ -42,3 +42,18 @@ test('currency display settings reformat materialized data without rebuilding pr assert.match(branch, /return;/); assert.doesNotMatch(branch, /restart|createCodexProvider|startWatcher|scan/i); }); + +test('pricing changes also invalidate sharing projections before a dashboard refresh', () => { + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'src', 'extension.ts'), + 'utf8', + ); + assert.match( + source, + /private async refreshPricing\(\)[\s\S]*?this\.invalidateClaudeUsagePricingCache\(\);\s*this\.webviewProvider\.invalidateShareCardPreview\(\);/, + ); + assert.match( + source, + /if \(pricingBackendChanged\) \{\s*this\.invalidateClaudeUsagePricingCache\(\);\s*this\.webviewProvider\.invalidateShareCardPreview\(\);/, + ); +}); diff --git a/src/test/dateKeys.test.ts b/src/test/dateKeys.test.ts index ee9372fa..d73b312e 100644 --- a/src/test/dateKeys.test.ts +++ b/src/test/dateKeys.test.ts @@ -108,3 +108,44 @@ test('timezone resolution returns a usable canonical zone', () => { assert.notEqual(resolveTimeZone(''), ''); assert.equal(resolveTimeZone('Not/AZone'), resolveTimeZone('')); }); + +test('repeated bucketing reuses one formatter per zone instead of building one per record', () => { + // Chatham is not used by the other cases here, so this zone starts cold. + const zone = 'Pacific/Chatham'; + const sample = new Date('2026-07-20T18:20:00.000Z'); + const realDateTimeFormat = Intl.DateTimeFormat; + const intl = Intl as unknown as { DateTimeFormat: typeof Intl.DateTimeFormat }; + let constructed = 0; + intl.DateTimeFormat = new Proxy(realDateTimeFormat, { + construct(target, args: unknown[]): object { + constructed += 1; + return Reflect.construct(target, args); + }, + }) as typeof Intl.DateTimeFormat; + + try { + dayKeyInZone(sample, zone); + hourKeyInZone(sample, zone); + const afterWarmUp = constructed; + assert.ok(afterWarmUp > 0, 'the first call in a zone still has to build its formatters'); + + for (let i = 0; i < 500; i++) { + dayKeyInZone(new Date(sample.getTime() + i * 3_600_000), zone); + monthKeyInZone(new Date(sample.getTime() + i * 3_600_000), zone); + hourKeyInZone(new Date(sample.getTime() + i * 3_600_000), zone); + } + + assert.equal(constructed, afterWarmUp); + } finally { + intl.DateTimeFormat = realDateTimeFormat; + } +}); + +test('memoised zones keep their own calendars apart', () => { + 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'); + assert.equal(dayKeyInZone(d, 'Asia/Hong_Kong'), '2026-07-01'); + assert.equal(hourKeyInZone(d, 'UTC'), '20'); + assert.equal(hourKeyInZone(d, 'Asia/Hong_Kong'), '04'); +}); diff --git a/src/test/extensionWindowActivity.test.ts b/src/test/extensionWindowActivity.test.ts index dbad387b..6399b591 100644 --- a/src/test/extensionWindowActivity.test.ts +++ b/src/test/extensionWindowActivity.test.ts @@ -2328,7 +2328,7 @@ test('Claude refresh diagnostics drain the unnamed quota-watcher event count', a }); test('real Claude filesystem events flow through manifest, index, and dashboard refresh', { - timeout: 10_000, + timeout: 15_000, }, async (t) => { const extension = bareExtension(); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccu-claude-watch-e2e-')); @@ -2444,20 +2444,30 @@ test('real Claude filesystem events flow through manifest, index, and dashboard } const activeWatcher = extension.fileWatcher as fs.FSWatcher; + // Recursive fs.watch can drop the first event for a just-created nested + // file while macOS is attaching its directory handle. Rewriting that + // same test-only file exercises the real watcher path until it observes + // the change; no synthetic callback or polling-based refresh is used. + const nestedBody = `${usageLine('subagent', 20)}\n`; + let rewrite: ReturnType | undefined; const outcomePromise = new Promise<'refreshed' | 'watch-error'>((resolve, reject) => { const timeout = setTimeout(() => { + if (rewrite) clearInterval(rewrite); reject(new Error('timed out waiting for nested Claude refresh')); - }, 5_000); + }, 8_000); nestedRefresh.then(() => { clearTimeout(timeout); + if (rewrite) clearInterval(rewrite); resolve('refreshed'); }); activeWatcher.once('error', () => { clearTimeout(timeout); + if (rewrite) clearInterval(rewrite); resolve('watch-error'); }); + fs.writeFileSync(nestedFile, nestedBody, 'utf8'); + rewrite = setInterval(() => fs.writeFileSync(nestedFile, nestedBody, 'utf8'), 250); }); - fs.writeFileSync(nestedFile, `${usageLine('subagent', 20)}\n`, 'utf8'); const outcome = await outcomePromise; if (outcome === 'watch-error') { t.skip('the test host cannot allocate a recursive fs.watch handle'); diff --git a/src/test/githubHeatmapPublish.test.ts b/src/test/githubHeatmapPublish.test.ts index dc50b2d5..cd0b1a3a 100644 --- a/src/test/githubHeatmapPublish.test.ts +++ b/src/test/githubHeatmapPublish.test.ts @@ -1,10 +1,13 @@ import { test } from 'node:test'; import * as assert from 'node:assert/strict'; import { + GITHUB_HEATMAP_DESTINATION_KEY, GITHUB_PUBLIC_REPO_SCOPE, GitHubApiRequest, + completeSuccessfulGitHubPublish, createGitHubPublishPlan, githubPublishConfirmationDetail, + parseGitHubHeatmapDestination, probePublicGitHubPublishTarget, publishPublicGitHubFile, } from '../githubHeatmapPublish'; @@ -44,22 +47,34 @@ test('mocked public create probe and write use the verified branch and no sha', ); assert.equal(preview.action, 'create'); await publishPublicGitHubFile(preview, 'U0ZH', request); - assert.deepEqual(calls[2], { - method: 'PUT', - path: '/repos/owner/repo/contents/assets/activity.svg', - body: { - message: 'Update Claude Code usage heatmap', - content: 'U0ZH', - branch: 'main', + assert.deepEqual(calls, [ + { + method: 'GET', + path: '/repos/owner/repo', + body: undefined, }, - }); + { + method: 'GET', + path: '/repos/owner/repo/contents/assets/activity.svg?ref=main', + body: undefined, + }, + { + method: 'PUT', + path: '/repos/owner/repo/contents/assets/activity.svg', + body: { + message: 'Update Claude Code usage heatmap', + content: 'U0ZH', + branch: 'main', + }, + }, + ]); }); -test('mocked update carries only the exact probed sha', async () => { +test('mocked update carries only the exact probed endpoint, branch, and sha', async () => { const sha = 'a'.repeat(40); - const calls: Array<{ method: string; body?: any }> = []; - const request: GitHubApiRequest = async (method, _path, body) => { - calls.push({ method, body }); + const calls: Array<{ method: string; path: string; body?: any }> = []; + const request: GitHubApiRequest = async (method, path, body) => { + calls.push({ method, path, body }); if (calls.length === 1) return { status: 200, body: JSON.stringify({ private: false, default_branch: 'release/v2' }) }; if (calls.length === 2) return { status: 200, body: JSON.stringify({ sha }) }; return { status: 200, body: '{}' }; @@ -70,8 +85,24 @@ test('mocked update carries only the exact probed sha', async () => { ); assert.equal(preview.action, 'update'); await publishPublicGitHubFile(preview, 'U0ZH', request); - assert.equal(calls[2].body.sha, sha); - assert.equal(calls[2].body.branch, 'release/v2'); + assert.deepEqual(calls, [ + { method: 'GET', path: '/repos/owner/repo', body: undefined }, + { + method: 'GET', + path: '/repos/owner/repo/contents/activity.svg?ref=release%2Fv2', + body: undefined, + }, + { + method: 'PUT', + path: '/repos/owner/repo/contents/activity.svg', + body: { + message: 'Update Claude Code usage heatmap', + content: 'U0ZH', + branch: 'release/v2', + sha, + }, + }, + ]); }); test('private or unverifiable targets fail closed before any mock write', async () => { @@ -111,3 +142,62 @@ test('mocked request failures expose status only, never response-body canaries', (error: Error) => /status 503/.test(error.message) && !/PRIVACY_CANARY/.test(error.message), ); }); + +test('successful publication is reported before an atomic destination-save warning', async () => { + const events: string[] = []; + const result = await completeSuccessfulGitHubPublish( + { + ...createGitHubPublishPlan('owner/repo', 'images/activity.svg'), + visibility: 'public', + branch: 'main', + action: 'create', + browserUrl: 'https://github.com/owner/repo/blob/main/images/activity.svg', + }, + { + persistDestination: async (key, destination) => { + events.push(`persist:${key}:${JSON.stringify(destination)}`); + throw new Error('synthetic persistence failure'); + }, + showSuccess: async () => { + events.push('success'); + return true; + }, + showPersistenceWarning: async () => { + events.push('warning'); + }, + }, + ); + + assert.deepEqual(result, { openBrowser: true, preferenceSaved: false }); + assert.deepEqual(events, [ + `persist:${GITHUB_HEATMAP_DESTINATION_KEY}:${JSON.stringify({ + schemaVersion: 1, + repository: 'owner/repo', + filePath: 'images/activity.svg', + })}`, + 'success', + 'warning', + ]); +}); + +test('stored GitHub destination accepts only the exact atomic schema', () => { + assert.deepEqual(parseGitHubHeatmapDestination({ + schemaVersion: 1, + repository: 'owner/repo', + filePath: 'images/activity.svg', + }), { + schemaVersion: 1, + repository: 'owner/repo', + filePath: 'images/activity.svg', + }); + assert.equal(parseGitHubHeatmapDestination({ + schemaVersion: 1, + repository: 'owner/repo', + filePath: 'images/activity.svg', + token: 'must-not-survive', + }), undefined); + assert.equal(parseGitHubHeatmapDestination({ + schemaVersion: 1, + repository: 'owner/repo', + }), undefined); +}); diff --git a/src/test/heatmapSvg.test.ts b/src/test/heatmapSvg.test.ts index 504f7ed6..02b87a5d 100644 --- a/src/test/heatmapSvg.test.ts +++ b/src/test/heatmapSvg.test.ts @@ -4,6 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { buildContributionGrid, renderHeatmapSvg, CLAUDE_ORANGE_SCALE } from '../heatmapSvg'; import { DayUsage } from '../heatmap'; +import { renderCombinedHeatmapSvg } from '../combinedHeatmapSvg'; const day = (tokens: number): DayUsage => ({ tokens, cost: tokens / 1000, sessions: 1 }); @@ -14,18 +15,48 @@ function fillForDate(svg: string, dateISO: string): string | undefined { return match?.[1]; } +function relativeLuminance(hex: string): number { + const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255); + const linear = channels.map((value) => + value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4, + ); + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; +} + +function contrastRatio(left: string, right: string): number { + const l1 = relativeLuminance(left); + const l2 = relativeLuminance(right); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +function assertNormalSvgLabelContrast(svg: string): void { + const background = /]*\bfill="(#[0-9a-f]{6})"/i.exec(svg)?.[1]; + assert.ok(background, 'SVG must expose a solid, auditable background'); + const labels = [...svg.matchAll(/]*)>/g)]; + assert.ok(labels.length > 0, 'SVG must contain labels'); + for (const [, attributes] of labels) { + const fill = /\bfill="(#[0-9a-f]{6})"/i.exec(attributes)?.[1]; + const fontSize = Number(/\bfont-size="([\d.]+)"/.exec(attributes)?.[1]); + assert.ok(fill, `normal SVG label at ${fontSize}px must use a solid fill`); + assert.ok( + contrastRatio(fill, background) >= 4.5, + `${fill} at ${fontSize}px must retain 4.5:1 contrast against ${background}`, + ); + } +} + test('the in-dashboard heatmap anchors its end date in the configured timezone', () => { const webviewSource = fs.readFileSync( path.join(__dirname, '..', '..', 'src', 'webview.ts'), 'utf8', ); - const start = webviewSource.indexOf("if (this.setting('showHeatmap', false)"); - const end = webviewSource.indexOf('const dailyBreakdown =', start); - assert.ok(start >= 0 && end > start, 'the live provider heatmap panel must remain discoverable'); + const start = webviewSource.indexOf('private renderClaudeHeatmapPresentation'); + const end = webviewSource.indexOf('private buildShareCardSvgFor', start); + assert.ok(start >= 0 && end > start, 'the Claude heatmap presentation must remain discoverable'); const panel = webviewSource.slice(start, end); assert.match( panel, - /renderHeatmapSvg\(daily,\s*\{\s*endDateISO:\s*dayKeyInZone\(new Date\(\),\s*I18n\.getTimezone\(\)\),?\s*\}\)/, + /renderHeatmapSvg\(daily,\s*\{\s*endDateISO:\s*dayKeyInZone\(new Date\(\),\s*I18n\.getTimezone\(\)\),\s*locale:\s*I18n\.getLocale\(\),?\s*\}\)/, ); }); @@ -90,6 +121,20 @@ test('has legend, watermark and the orange ramp; no crash on empty data', () => assert.ok(svg.includes(CLAUDE_ORANGE_SCALE[0])); }); +test('legacy and combined heatmaps give every normal SVG label at least 4.5:1 contrast', () => { + assertNormalSvgLabelContrast(renderHeatmapSvg({}, { endDateISO: '2026-07-01' })); + assertNormalSvgLabelContrast(renderCombinedHeatmapSvg({ + '2026-06-30': { + dateISO: '2026-06-30', + claudeProcessed: 1_000, + codexProcessed: 2_000, + combinedProcessed: 3_000, + }, + }, { + endDateISO: '2026-07-01', + })); +}); + test('short custom ramps keep a valid watermark and matching legend', () => { const svg = renderHeatmapSvg( { '2026-06-20': day(10) }, @@ -173,8 +218,8 @@ test('short-range legends stay inside a min-width share card', () => { }); assert.doesNotMatch(svg, /<(?:text|rect) x="-/); - assert.match(svg, /]*>Less<\/text>/); - assert.match(svg, /]*>More<\/text>/); + assert.match(svg, /]*>Less<\/text>/); + assert.match(svg, /]*>More<\/text>/); }); test('the default linear renderer preserves the original four active buckets', () => { @@ -211,3 +256,52 @@ test('renderer sanitizes caller-supplied SVG colors at the shared boundary', () assert.doesNotMatch(svg, /javascript|onload|url\(/i); }); + +test('renderer escapes quotes before caller text enters an SVG attribute', () => { + const svg = renderHeatmapSvg({}, { + endDateISO: '2026-07-01', + title: 'Safe title', + ariaLabel: `Safe title\" onload=\"alert(1)' data-canary='unsafe`, + }); + + assert.doesNotMatch(svg, /aria-label="Safe title"\s+onload=/i); + assert.match(svg, /aria-label="Safe title" onload="alert\(1\)' data-canary='unsafe"/); + assert.match(svg, /'/); +}); + +test('maximum ASCII and CJK titles are visually contained in the SVG viewBox', () => { + for (const title of ['W'.repeat(80), '综合活动热力图'.repeat(12)]) { + const svg = renderHeatmapSvg({}, { + startDateISO: '2026-06-23', + endDateISO: '2026-07-22', + minWidth: 720, + title, + }); + const width = Number(/viewBox="0 0 ([\d.]+)/.exec(svg)?.[1]); + const renderedTitle = /]*>(.*?)<\/text>/.exec(svg)?.[1] ?? ''; + assert.equal(width, 720); + assert.ok(renderedTitle.endsWith('…'), `expected a contained title: ${renderedTitle}`); + assert.ok(renderedTitle.length < title.length); + } +}); + +test('Claude heatmap labels and tooltips use all eight UI locales', () => { + const expected = { + en: ['Less', 'More', 'No tokens on June'], + 'de-DE': ['Weniger', 'Mehr', 'Keine Token am'], + 'zh-TW': ['較少', '較多', '無 Token 日期'], + 'zh-CN': ['较少', '较多', '无 Token 日期'], + ja: ['少ない', '多い', 'なし: トークン 日付'], + ko: ['적게', '많이', '없음: 토큰 날짜'], + 'pt-BR': ['Menos', 'Mais', 'Sem tokens em'], + id: ['Lebih sedikit', 'Lebih banyak', 'Tidak ada token pada'], + }; + for (const [locale, markers] of Object.entries(expected)) { + const svg = renderHeatmapSvg({}, { + startDateISO: '2026-06-20', + endDateISO: '2026-06-22', + locale, + }); + for (const marker of markers) assert.match(svg, new RegExp(marker)); + } +}); diff --git a/src/test/i18nAutoLanguage.test.ts b/src/test/i18nAutoLanguage.test.ts new file mode 100644 index 00000000..38efb5a1 --- /dev/null +++ b/src/test/i18nAutoLanguage.test.ts @@ -0,0 +1,24 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { I18n } from '../i18n'; + +test('auto language follows the editor locale for every supported family', () => { + const previous = I18n.getCurrentLanguage(); + try { + for (const [editorLocale, expected] of [ + ['de-CH', 'de-DE'], + ['pt-BR', 'pt-BR'], + ['zh-HK', 'zh-TW'], + ['zh-CN', 'zh-CN'], + ['ja-JP', 'ja'], + ['ko-KR', 'ko'], + ['id-ID', 'id'], + ['en-US', 'en'], + ] as const) { + I18n.setLanguage('auto', editorLocale); + assert.equal(I18n.getCurrentLanguage(), expected, editorLocale); + } + } finally { + I18n.setLanguage(previous); + } +}); diff --git a/src/test/localDataControls.test.ts b/src/test/localDataControls.test.ts index 3a751a82..69fb0ae1 100644 --- a/src/test/localDataControls.test.ts +++ b/src/test/localDataControls.test.ts @@ -394,6 +394,11 @@ test('source policy keeps controls out of Settings while preserving safe command [], 'UI and sharing client allowlists must remain disjoint', ); + assert.doesNotMatch(webviewSource, /__ccuSharingSessionKeys|ccu\.sharing\.commandRevision/); + assert.doesNotMatch( + LOCAL_DATA_ACTION_TARGETS['reset-sharing-preferences'].join('\n'), + /ccu\.sharing\.commandRevision/, + ); const clearAll = sourceSlice( extensionSource, diff --git a/src/test/localDataExtension.test.ts b/src/test/localDataExtension.test.ts index 0fc6164e..052c565a 100644 --- a/src/test/localDataExtension.test.ts +++ b/src/test/localDataExtension.test.ts @@ -228,6 +228,7 @@ test('pending Webview reset tombstone survives failure and clears only after ver const acknowledgements = [false, true]; const extension = Object.create(ClaudeCodeUsageExtension.prototype) as any; extension.context = { globalState }; + extension.localDataActionWrite = Promise.resolve(); extension.pendingClientResetReplay = Promise.resolve(); extension.webviewProvider = { requestClientLocalDataAction: async (action: string) => { @@ -245,6 +246,63 @@ test('pending Webview reset tombstone survives failure and clears only after ver assert.equal(globalState.state.has('ccu.localData.pendingClientReset.v1'), false); }); +test('an acknowledged reset replay cannot clear a newer tombstone revision', async () => { + const key = 'ccu.localData.pendingClientReset.v1'; + const globalState = memoryGlobalState({ + [key]: 'clear-all-client-state', + }); + let releaseAck!: (ok: boolean) => void; + const ack = new Promise((resolve) => { releaseAck = resolve; }); + const extension = Object.create(ClaudeCodeUsageExtension.prototype) as any; + extension.context = { globalState }; + extension.localDataActionWrite = Promise.resolve(); + extension.pendingClientResetReplay = Promise.resolve(); + extension.webviewProvider = { + requestClientLocalDataAction: async () => ack, + }; + + const replay = extension.replayPendingClientReset(); + await new Promise((resolve) => setImmediate(resolve)); + const newer = { + schemaVersion: 1, + revision: 2, + action: 'reset-sharing-preferences', + }; + await globalState.update(key, newer); + releaseAck(true); + await replay; + + assert.deepEqual(globalState.state.get(key), newer); +}); + +test('reset replay is serialized with newly requested local-data actions', async () => { + const globalState = memoryGlobalState({ + 'ccu.localData.pendingClientReset.v1': 'clear-all-client-state', + }); + let releaseAck!: (ok: boolean) => void; + const ack = new Promise((resolve) => { releaseAck = resolve; }); + const extension = Object.create(ClaudeCodeUsageExtension.prototype) as any; + extension.context = { globalState }; + extension.localDataActionWrite = Promise.resolve(); + extension.pendingClientResetReplay = Promise.resolve(); + extension.webviewProvider = { + requestClientLocalDataAction: async () => ack, + }; + + const replay = extension.replayPendingClientReset(); + await new Promise((resolve) => setImmediate(resolve)); + let newerActionStarted = false; + const newerAction = extension.serializeLocalDataAction(async () => { + newerActionStarted = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(newerActionStarted, false); + + releaseAck(true); + await Promise.all([replay, newerAction]); + assert.equal(newerActionStarted, true); +}); + test('clear-all uses the exact allowlist and preserves unrelated state', async () => { const globalState = memoryGlobalState({ 'ccu.setting.showCost': true, diff --git a/src/test/monthDetailRequest.test.ts b/src/test/monthDetailRequest.test.ts new file mode 100644 index 00000000..0f0d4e5b --- /dev/null +++ b/src/test/monthDetailRequest.test.ts @@ -0,0 +1,63 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); + +test('Claude month drill-down accepts the production first-day key and replies to that exact row', async () => { + const originalLoad = (Module as any)._load; + let onMessage: ((message: Record) => Promise) | undefined; + const replies: Array> = []; + const vscode = { + ViewColumn: { One: 1 }, + window: { + createWebviewPanel: () => ({ + webview: { + onDidReceiveMessage: (handler: typeof onMessage) => { onMessage = handler; }, + postMessage: async (message: Record) => { replies.push(message); return true; }, + }, + onDidDispose: () => undefined, + }), + }, + workspace: { workspaceFolders: [] }, + }; + (Module as any)._load = function(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') return vscode; + return originalLoad.call(this, request, parent, isMain); + }; + + try { + const { UsageWebviewProvider } = require('../webview') as typeof import('../webview'); + const provider = new UsageWebviewProvider({} as any) as any; + provider.currentProvider = 'claude'; + provider.allRecords = new Proxy([], { + get() { throw new Error('month drill-down must not inspect raw records'); }, + }); + provider.dailyDataForEveryDay = [ + { date: '2026-06-30', data: { marker: 'june' } }, + { date: '2026-07-02', data: { marker: 'second' } }, + { date: '2026-07-01', data: { marker: 'first' } }, + { date: '2026-08-01', data: { marker: 'august' } }, + ]; + provider.updateWebview = () => undefined; + provider.show(); + assert.ok(onMessage); + + await onMessage!({ command: 'getDailyData', month: '2026-07-01', provider: 'claude' }); + assert.deepEqual(replies, [{ + command: 'dailyDataResponse', + provider: 'claude', + month: '2026-07-01', + data: [ + { date: '2026-07-01', data: { marker: 'first' } }, + { date: '2026-07-02', data: { marker: 'second' } }, + ], + }]); + + await onMessage!({ command: 'getDailyData', month: '2026-07-02', provider: 'claude' }); + assert.equal(replies.length, 1, 'only first-of-month keys are accepted'); + provider.currentProvider = 'codex'; + await onMessage!({ command: 'getDailyData', month: '2026-07-01', provider: 'codex' }); + assert.equal(replies.length, 1, 'Codex still requires its YYYY-MM key'); + } finally { + (Module as any)._load = originalLoad; + } +}); diff --git a/src/test/promptDedup.test.ts b/src/test/promptDedup.test.ts index c2231aec..0a59c35b 100644 --- a/src/test/promptDedup.test.ts +++ b/src/test/promptDedup.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import * as assert from 'node:assert/strict'; -import { isRetryDuplicatePrompt, PROMPT_RETRY_WINDOW_MS } from '../promptDedup'; +import { detachedPromptPrefix, isRetryDuplicatePrompt, PROMPT_RETRY_WINDOW_MS } from '../promptDedup'; const t0 = Date.parse('2026-06-11T16:26:38.000Z'); @@ -40,7 +40,32 @@ 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); + const edge = new Map(); + isRetryDuplicatePrompt('x', t0, edge); + assert.equal(isRetryDuplicatePrompt('x', t0 + PROMPT_RETRY_WINDOW_MS + 1, edge), false); +}); + +test('large prompt dedup never retains the raw prompt as an index key', () => { + const seen = new Map(); + const prompt = 'synthetic-only-'.repeat(17_000); + assert.equal(isRetryDuplicatePrompt(prompt, t0, seen), false); + assert.equal(isRetryDuplicatePrompt(prompt, t0 + 30_000, seen), true); + assert.equal(isRetryDuplicatePrompt(`${prompt}different`, t0 + 31_000, seen), false); + assert.equal(seen.size, 2); + assert.ok([...seen.keys()].every((key) => key.length < 100)); +}); + +test('distinct unpaired UTF-16 surrogates do not become the same digest', () => { + const seen = new Map(); + assert.equal(isRetryDuplicatePrompt('a\ud800b', t0, seen), false); + assert.equal(isRetryDuplicatePrompt('a\udc00b', t0 + 1000, seen), false); +}); + +test('retained excerpts preserve exact UTF-16 prefix semantics', () => { + const source = '甲😀乙'.repeat(100_000); + assert.equal(detachedPromptPrefix(source, 5), source.slice(0, 5)); + assert.equal(detachedPromptPrefix(source, 2), source.slice(0, 2)); + assert.equal(detachedPromptPrefix(source, 2).length, 2); }); test('unusable timestamp is counted, never dropped', () => { diff --git a/src/test/providerSelection.test.ts b/src/test/providerSelection.test.ts index b4ef2e58..ced74686 100644 --- a/src/test/providerSelection.test.ts +++ b/src/test/providerSelection.test.ts @@ -112,7 +112,7 @@ test('Codex dashboard HTML uses only classes already rendered by the Claude dash provider.hourlyDataForRolling30DaysByDay = { '2026-07-20': [{ hour: '12:00', data: usage }], }; - provider.dailyDataForAllTime = [{ date: '2026-07', data: usage }]; + provider.dailyDataForAllTime = [{ date: '2026-07-01', data: usage }]; provider.sessionBreakdown = [session]; provider.projectBreakdown = [project]; provider.claudeProjectUsageMatrix = buildProjectUsageMatrixSnapshot( @@ -484,7 +484,7 @@ test('Codex settings and charts use the shared dashboard renderers', () => { assert.match(webview, /private renderCompositionChart\([\s\S]*provider: SettingProvider/); assert.match( webview, - /snap\.filter\(\(setting\)\s*=>\s*settingAppliesToProvider\(setting, provider\)\)/, + /snap\.filter\(\(setting\)\s*=>\s*setting\.visible\s*!==\s*false\s*&&\s*settingAppliesToProvider\(setting, provider\),?\s*\)/, ); assert.match( settings, @@ -648,8 +648,8 @@ test('provider and Codex view copy is complete in every UI locale', () => { .join('\n'); assert.doesNotMatch(englishVisibleCopy, /\b(?:fresh|new)\b/i); const settingsSource = readFileSync(path.resolve(__dirname, '..', '..', 'src', 'settings.ts'), 'utf8'); - assert.match(settingsSource, /key: 'codex\.statusMetric'[\s\S]*?enumValues: \['fresh', 'processed', 'output'\][\s\S]*?enumLabels: \['Uncached', 'Processed', 'Output'\]/); - assert.match(settingsSource, /help: "Today's uncached usage, processed tokens, or output tokens\."/); + assert.match(settingsSource, /key: 'codex\.statusMetric'[\s\S]*?enumValues: \['processed', 'fresh', 'output'\][\s\S]*?enumLabels: \['Processed', 'Uncached', 'Output'\]/); + assert.match(settingsSource, /help: "Today's processed tokens \(default\), uncached usage, or output tokens\."/); const extensionSource = readFileSync(path.resolve(__dirname, '..', '..', 'src', 'extension.ts'), 'utf8'); assert.match( extensionSource, diff --git a/src/test/quotaFormat.test.ts b/src/test/quotaFormat.test.ts index 58434bab..f7e21866 100644 --- a/src/test/quotaFormat.test.ts +++ b/src/test/quotaFormat.test.ts @@ -6,7 +6,10 @@ import * as assert from 'node:assert/strict'; import { CONTEXT_FILL_THRESHOLDS, + CUSTOM_QUOTA_STATUS_TEMPLATE, + FIVE_HOUR_QUOTA_STATUS_TEMPLATE, QUOTA_FILL_THRESHOLDS, + WEEKLY_QUOTA_STATUS_TEMPLATE, compactReset, fillLevel, formatMonthlyReset, @@ -347,3 +350,213 @@ test('out-of-range fills clamp to the nearest level', () => { assert.equal(fillLevel(-5, QUOTA_FILL_THRESHOLDS), 'normal'); assert.equal(fillLevel(140, QUOTA_FILL_THRESHOLDS), 'error'); }); + + +// --------------------------------------------------------------------------- +// Status-bar format template (statusBarQuotaFormat). +// +// The built-in layout stays the default: the template is empty out of the box +// and everything above keeps passing unchanged. When set, it names the windows +// explicitly, which is why it ignores the 5h-only and scoped-weekly toggles — +// those answer "which windows do you want", and the template already said. +// --------------------------------------------------------------------------- + +const base = { showReset: false, fiveHourOnly: false, showScopedWeekly: false, now: NOW }; + +test('an empty template keeps the built-in layout', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '' }), '5h 6% · wk 1%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: ' ' }), '5h 6% · wk 1%'); +}); + +test('dashboard presets render short, named quota windows', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: FIVE_HOUR_QUOTA_STATUS_TEMPLATE }), '5h 6%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: WEEKLY_QUOTA_STATUS_TEMPLATE }), 'wk 1%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: CUSTOM_QUOTA_STATUS_TEMPLATE }), '5h 6% · wk 1%'); +}); + +test('a template renders the windows it names', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct} | {wk.pct}' }), '6% | 1%'); +}); + +test('model tokens match the API scope label, case-insensitively', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:Fable.pct}' }), '12%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:fable.pct}' }), '12%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:FABLE.pct}' }), '12%'); +}); + +test('a model name containing a dot still parses', () => { + // "Fable 5.1" is a label the API really sends, so the field has to be split + // off at the last dot rather than the first. + const dotted: QuotaWindow[] = [ + { kind: 'weekly_scoped', scopeLabel: 'Fable 5.1', utilization: 7, decimals: 0, resetsAt: at(38.4 * H), isActive: true }, + ]; + assert.equal(formatQuotaStatusText(dotted, { ...base, template: '{model:Fable 5.1.pct}' }), '7%'); + assert.equal(formatQuotaStatusText(dotted, { ...base, template: '{model:Fable 5.1.label}' }), 'fable 5.1'); +}); + +test('7d is an alias for the all-models week', () => { + const a = formatQuotaStatusText(live, { ...base, template: '{7d.pct}' }); + const b = formatQuotaStatusText(live, { ...base, template: '{wk.pct}' }); + assert.equal(a, '1%'); + assert.equal(a, b); +}); + +test('reset tokens follow the reset countdown format', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.reset}' }), '4.8h'); + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.reset}', resetFormat: 'units' }), + '4h 48m' + ); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{wk.reset}' }), '1.6d'); +}); + +test('a clock reset keeps its date intact', () => { + // '-' is a separator, but only in the template's own text: the hyphens of a + // rendered "2023-11-16" must not be taken for one. NOW is local noon, so the + // +38.4h weekly reset lands on the 16th in every timezone. + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{wk.reset}', resetFormat: 'clock' }), + '2023-11-16' + ); + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.pct} | {wk.reset}', resetFormat: 'clock' }), + '6% | 2023-11-16' + ); +}); + +test('a reset token can name its own style, whatever the global one is', () => { + // The global format is one setting for the whole bar; a style on the token is + // what lets a countdown for the 5h window sit beside a wall clock for the week. + const t = '{5h.reset:units} | {wk.reset:decimal} | {5h.reset:clock} | {5h.reset}'; + assert.equal(formatQuotaStatusText(live, { ...base, template: t }), '4h 48m | 1.6d | 16:48 | 4.8h'); + assert.equal( + formatQuotaStatusText(live, { ...base, template: t, resetFormat: 'clock' }), + '4h 48m | 1.6d | 16:48 | 16:48' + ); +}); + +test('reset:at is the wall clock, with a weekday once it is not today', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.reset:at}' }), '16:48'); + assert.match(formatQuotaStatusText(live, { ...base, template: '{wk.reset:at}' }), /^.+ 02:24$/); + assert.match( + formatQuotaStatusText(live, { ...base, template: '{model:Fable 5.1.reset:at}' }), + /^$/ // no such cap on this account: the dotted name still parses, and drops + ); +}); + +test('an unknown reset style, or a style on another field, is a visible typo', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.reset:hours}' }), '{5h.reset:hours}'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct:units}' }), '{5h.pct:units}'); +}); + +test('a reset the API left blank closes up like a missing window', () => { + const fresh: QuotaWindow[] = [{ kind: 'session', utilization: 0, decimals: 0, resetsAt: '', isActive: false }]; + assert.equal(formatQuotaStatusText(fresh, { ...base, template: '{5h.pct} {5h.reset} left' }), '0% left'); +}); + +test('label tokens give the same short names the built-in layout uses', () => { + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.label} {5h.pct} · {wk.label} {wk.pct}' }), + '5h 6% · wk 1%' + ); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:Fable.label}' }), 'fable'); +}); + +test('a window the account does not report renders empty, and takes its dangling separator with it', () => { + // The case that matters: a plan with no per-model cap, or a signed-out weekly. + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct} · {model:Opus.pct}' }), '6%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:Opus.pct} · {5h.pct}' }), '6%'); + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.pct} | {model:Opus.pct} | {wk.pct}' }), + '6% | 1%' + ); +}); + +test('separators come out exactly as typed', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct}/{wk.pct}' }), '6%/1%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct}, {wk.pct}' }), '6%, 1%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct} - {model:Opus.pct}' }), '6%'); +}); + +test('literal text goes with the window it describes', () => { + // Without this a plan with no Opus cap would be left showing a bare "opus". + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct} · opus {model:Opus.pct}' }), '6%'); + assert.equal( + formatQuotaStatusText(live, { ...base, template: 'F {model:Fable.pct} · O {model:Opus.pct}' }), + 'F 12%' + ); + // Text with no token of its own is never dropped. + assert.equal(formatQuotaStatusText(live, { ...base, template: 'Claude | {5h.pct}' }), 'Claude | 6%'); +}); + +test('braces are escaped by doubling', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{{5h.pct}} {5h.pct}' }), '{5h.pct} 6%'); +}); + +test('an unrecognised token is left verbatim so a typo stays visible', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pcnt}' }), '{5h.pcnt}'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{nope.pct}' }), '{nope.pct}'); +}); + +test('literal spacing is kept as typed', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: 'A B {5h.pct}' }), 'A B 6%'); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{5h.pct} {wk.pct}' }), '6% 1%'); +}); + +test('a vanished token closes its own gap without disturbing the rest', () => { + // The space either side of the missing cap collapses to one, so the line does + // not show where something used to be. + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.pct} {model:Opus.pct} {wk.pct}' }), + '6% 1%' + ); + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.pct}{model:Opus.pct}{wk.pct}' }), + '6%1%' + ); +}); + +test('an unrecognised token survives even beside a window the account lacks', () => { + // Dropping the segment would take the typo with it, and a silently shorter + // line is exactly what the verbatim rule exists to prevent. + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{nope.pct} {model:Opus.pct}' }), + '{nope.pct}' + ); + assert.equal( + formatQuotaStatusText(live, { ...base, template: '{5h.pct} | {nope.pct} {model:Opus.pct}' }), + '6% | {nope.pct}' + ); +}); + +test('a template that renders to nothing returns empty so the item hides', () => { + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:Opus.pct}' }), ''); + assert.equal(formatQuotaStatusText(live, { ...base, template: '{model:Opus.pct} | ' }), ''); +}); + +test('the template selects windows itself, ignoring the 5h-only and scoped-weekly toggles', () => { + // fiveHourOnly would hide the week, and showScopedWeekly gates the model cap; + // naming them in the template is the more specific instruction. + assert.equal( + formatQuotaStatusText(live, { ...base, fiveHourOnly: true, template: '{wk.pct}' }), + '1%' + ); + assert.equal( + formatQuotaStatusText(live, { ...base, showScopedWeekly: false, template: '{model:Fable.pct}' }), + '12%' + ); +}); + +test('the warning colour follows the windows the template actually shows', () => { + // Same reasoning as the built-in layout: a red bar with no visible cause + // reads as a bug. A template showing only the quiet 5h window must not turn + // red because of a loud weekly cap it never prints. + const loud: QuotaWindow[] = [ + { kind: 'session', utilization: 6, decimals: 0, resetsAt: at(4.8 * H), isActive: false }, + { kind: 'weekly_all', utilization: 95, decimals: 0, resetsAt: at(38.4 * H), isActive: true }, + ]; + assert.equal(worstShownUtilisation(loud, { ...base, template: '{5h.pct}' }), 6); + assert.equal(worstShownUtilisation(loud, { ...base, template: '{5h.pct} | {wk.pct}' }), 95); + // No template: unchanged, both windows are shown. + assert.equal(worstShownUtilisation(loud, base), 95); +}); diff --git a/src/test/repositoryPolicy.test.ts b/src/test/repositoryPolicy.test.ts index 3edc57cc..1338d17e 100644 --- a/src/test/repositoryPolicy.test.ts +++ b/src/test/repositoryPolicy.test.ts @@ -82,7 +82,7 @@ function assertExactVscePin(commands: readonly WorkflowStepValue[]): void { ); assert.ok(tokens.length > 0, 'workflow must invoke VSCE'); for (const token of tokens) { - assert.equal(token, '@vscode/vsce@3.9.2', `unexpected VSCE invocation ${token}`); + assert.equal(token, '@vscode/vsce@4.0.0', `unexpected VSCE invocation ${token}`); } } @@ -1410,14 +1410,16 @@ test('git and VSIX ignores exclude private and development-only material', () => } }); -test('all seven README files credit both development tools', () => { +test('all nine README files credit both development tools', () => { const readmes = [ 'README.md', 'README-en.md', + 'README-de-DE.md', 'README-zh-CN.md', 'README-zh-TW.md', 'README-ja.md', 'README-ko.md', + 'README-pt-BR.md', 'README-id.md', ]; for (const readme of readmes) { @@ -1427,15 +1429,17 @@ test('all seven README files credit both development tools', () => { } }); -test('all seven README editions explain Codex Beta in their own language', () => { +test('all nine README files explain current Codex support without a Beta label', () => { const expectations: Record = { - 'README.md': [/Codex Beta/, /processed/i, /uncached usage/i, /cached/i, /last-observed/i], - 'README-en.md': [/Codex Beta/, /processed/i, /uncached usage/i, /cached/i, /last-observed/i], - 'README-zh-CN.md': [/Codex Beta/, /已处理/, /未缓存用量/, /缓存/, /最后观测/], - 'README-zh-TW.md': [/Codex Beta/, /已處理/, /未快取用量/, /快取/, /最後觀測/], - 'README-ja.md': [/Codex Beta/, /処理済み/, /非キャッシュ使用量/, /キャッシュ/, /最終観測/], - 'README-ko.md': [/Codex Beta/, /처리된/, /캐시되지 않은 사용량/, /캐시/, /마지막 관측/], - 'README-id.md': [/Codex Beta/, /diproses/i, /penggunaan tanpa cache/i, /cache/i, /terakhir diamati/i], + 'README.md': [/Codex/, /processed/i, /uncached usage/i, /cached/i, /last-observed/i], + 'README-en.md': [/Codex/, /processed/i, /uncached usage/i, /cached/i, /last-observed/i], + 'README-de-DE.md': [/Codex/, /verarbeitet/i, /nicht gecachte Nutzung/i, /gecachte/i, /letzte lokale Beobachtung/i], + 'README-zh-CN.md': [/Codex/, /已处理/, /未缓存用量/, /缓存/, /最后观测/], + 'README-zh-TW.md': [/Codex/, /已處理/, /未快取用量/, /快取/, /最後觀測/], + 'README-ja.md': [/Codex/, /処理済み/, /非キャッシュ使用量/, /キャッシュ/, /最終観測/], + 'README-ko.md': [/Codex/, /처리된/, /캐시되지 않은 사용량/, /캐시/, /마지막 관측/], + 'README-pt-BR.md': [/Codex/, /processado/i, /uso sem cache/i, /cache/i, /última observação local/i], + 'README-id.md': [/Codex/, /diproses/i, /penggunaan tanpa cache/i, /cache/i, /terakhir diamati/i], }; for (const [readme, patterns] of Object.entries(expectations)) { @@ -1443,10 +1447,21 @@ test('all seven README editions explain Codex Beta in their own language', () => for (const pattern of patterns) { assert.match(body, pattern, `${readme} is missing ${pattern}`); } + assert.doesNotMatch(body.slice(0, 2_000), /Codex Beta/, `${readme} still presents Codex as Beta`); assert.doesNotMatch(body, /showOpusWeekly/, `${readme} still documents the retired setting key`); } }); +test('all provider tabs and settings present Codex without a Beta badge', () => { + const i18n = repoFile('src/i18n.ts'); + const settings = repoFile('src/settings.ts'); + + assert.equal((i18n.match(/codexBeta: 'Codex'/g) ?? []).length, 8); + assert.doesNotMatch(i18n, /codexBeta: 'Codex Beta'/); + assert.match(settings, /label: 'Enable Codex'/); + assert.doesNotMatch(settings, /label: 'Enable Codex Beta'/); +}); + test('v2.3 README editions share release evidence and local-data boundaries', () => { const readmes = [ 'README.md', @@ -1514,6 +1529,91 @@ test('v2.3 README editions share release evidence and local-data boundaries', () assert.match(repoFile('LOCAL-DATA.zh-CN.md'), /OAuth access\/refresh token/); }); +test('v2.4 synthetic preview images are present and carry no text metadata', () => { + for (const image of [ + 'images/v2.4.0/compare-sharing-en-light.png', + 'images/v2.4.0/claude-sharing-zh-CN-dark.png', + 'images/v2.4.0/codex-month-zh-CN-dark.png', + 'images/v2.4.0/narrow-sharing-de-DE-dark.png', + ]) { + assert.ok(existsSync(resolve(REPO_ROOT, image)), `missing synthetic preview ${image}`); + const chunkTypes = privacySafePngChunkTypes(image); + for (const metadataChunk of ['eXIf', 'iTXt', 'tEXt', 'zTXt']) { + assert.equal(chunkTypes.includes(metadataChunk), false, `${image} retains ${metadataChunk} metadata`); + } + } +}); + +test('v2.4 README editions describe one preview-first sharing workspace', () => { + const expectations: Record = { + 'README.md': [ + /^## What's new in 2\.4$/m, + /preview-first/i, + /Combined activity heatmap/, + /Claude Share Card/, + /Claude token\s+heatmap/, + ], + 'README-en.md': [ + /^## What's new in v2\.4$/m, + /preview-first/i, + /Combined activity heatmap/, + /Claude Share Card/, + /Claude token\s+heatmap/, + ], + 'README-zh-CN.md': [ + /^## 2\.4 新功能$/m, + /预览优先/, + /综合活动热力图/, + /Claude 分享卡/, + /Claude token\s+热力图/, + ], + 'README-zh-TW.md': [ + /^## v2\.4 新功能$/m, + /預覽優先/, + /綜合活動熱力圖/, + /Claude 分享卡/, + /Claude token\s+熱力圖/, + ], + 'README-ja.md': [ + /^## v2\.4 の新機能$/m, + /プレビュー/, + /統合アクティビティヒートマップ/, + /Claude Share Card/, + /Claude トークンヒートマップ/, + ], + 'README-ko.md': [ + /^## v2\.4 새로운 기능$/m, + /미리보기/, + /통합 활동 히트맵/, + /Claude 공유 카드/, + /Claude 토큰 히트맵/, + ], + 'README-id.md': [ + /^## Yang baru di v2\.4$/m, + /pratinjau/i, + /heatmap aktivitas gabungan/i, + /Claude Share Card/, + /heatmap token Claude/i, + ], + }; + + for (const [readme, patterns] of Object.entries(expectations)) { + const body = repoFile(readme); + for (const pattern of patterns) { + assert.match(body, pattern, `${readme} is missing ${pattern}`); + } + for (const publicId of ['exportShareCard', 'exportHeatmap', 'publishHeatmapToGitHub']) { + assert.ok(body.includes(publicId), `${readme} is missing compatibility command ${publicId}`); + } + assert.match(body, /enableShareCard/, `${readme} is missing the single visible sharing switch`); + assert.doesNotMatch( + body, + /^##[^\n]*2\.4\.\d[^\n]*$/m, + `${readme} must keep What's new at minor-version granularity`, + ); + } +}); + test('Marketplace metadata presents Claude and Codex local usage support', () => { const packageJson = JSON.parse(repoFile('package.json')) as { description: string; @@ -1525,6 +1625,37 @@ test('Marketplace metadata presents Claude and Codex local usage support', () => assert.ok(packageJson.keywords.includes('local-usage')); }); +test('every supported UI locale has a discoverable README edition', () => { + const packageJson = JSON.parse(repoFile('package.json')) as { + description: string; + keywords: string[]; + contributes: { configuration: { properties: Record } }; + }; + const locales = packageJson.contributes.configuration.properties['claudeCodeUsage.language'].enum + ?.filter((value) => value !== 'auto') ?? []; + assert.equal(locales.length, 8); + const home = repoFile('README.md'); + for (const locale of locales) { + const filename = `README-${locale}.md`; + assert.ok(existsSync(resolve(REPO_ROOT, filename)), `${locale} has no README`); + assert.ok(home.includes(`](${filename})`), `Marketplace README does not link ${filename}`); + const body = repoFile(filename); + assert.match(body, /README\.md/, `${filename} cannot return to the main README`); + assert.match(body, /Codex/, `${filename} omits Codex`); + assert.match(body, /https:\/\/claude\.com\/claude-code/, `${filename} omits Claude Code credit`); + assert.match(body, /https:\/\/developers\.openai\.com\/codex\//, `${filename} omits Codex credit`); + } + for (const term of ['token', 'quota', 'status bar']) { + assert.match(packageJson.description, new RegExp(term, 'i')); + } + for (const keyword of ['codex-usage', 'token-usage', 'quota']) { + assert.ok(packageJson.keywords.includes(keyword), `Marketplace lacks ${keyword} tag`); + } + assert.ok(packageJson.keywords.length <= 30); + assert.ok(repoFile('README-en.md').split('\n').length > repoFile('README-de-DE.md').split('\n').length); + assert.ok(repoFile('README-zh-CN.md').split('\n').length > repoFile('README-pt-BR.md').split('\n').length); +}); + test('pull request checklist names the actual eight UI locales', () => { const packageJson = JSON.parse(repoFile('package.json')) as { contributes: { configuration: { properties: Record } }; @@ -1535,7 +1666,7 @@ test('pull request checklist names the actual eight UI locales', () => { const template = repoFile('.github/PULL_REQUEST_TEMPLATE.md'); assert.match(template, /all eight UI locales/); assert.doesNotMatch(template, /all seven languages/); - assert.match(template, /all seven README editions/); + assert.match(template, /all nine README files/); }); test('changelog records the V2.2.2 energy patch after the released V2.2.1 baseline', () => { @@ -1549,18 +1680,24 @@ test('changelog records the V2.2.2 energy patch after the released V2.2.1 baseli assert.doesNotMatch(changelog, /^## \[2\.2\.[01]\] — Unreleased$/m); }); -test('changelog preserves v2.3 release order and keeps named candidate fixes out of v2.3.2', () => { +test('changelog separates unreleased changes from published v2.3.3 and v2.3.2 fixes', () => { const changelog = repoFile('CHANGELOG.md'); - assert.match(changelog, /^## \[2\.3\.3\] — Unreleased$/m); + assert.match(changelog, /^## \[Unreleased\]$/m); + assert.match(changelog, /^## \[2\.3\.3\] — 2026-09-16$/m); assert.match(changelog, /^## \[2\.3\.2\] — 2026-09-12$/m); assert.match(changelog, /^## \[2\.3\.1\] — 2026-09-08$/m); assert.match(changelog, /^## \[2\.3\.0\] — 2026-08-28$/m); - const candidateStart = changelog.indexOf('## [2.3.3]'); + const unreleasedStart = changelog.indexOf('## [Unreleased]'); + const publishedStart = changelog.indexOf('## [2.3.3]'); const releasedStart = changelog.indexOf('## [2.3.2]'); const previousReleaseStart = changelog.indexOf('## [2.3.1]'); - const candidateSection = changelog.slice(candidateStart, releasedStart); + const unreleasedSection = changelog.slice(unreleasedStart, publishedStart); + const publishedSection = changelog.slice(publishedStart, releasedStart); const releasedSection = changelog.slice(releasedStart, previousReleaseStart); + assert.match(unreleasedSection, /statusBarQuotaFormat/); + assert.match(unreleasedSection, /High CPU during indexing \(#99\)/); + assert.doesNotMatch(publishedSection, /statusBarQuotaFormat/); for (const candidateFix of [ 'Smoother live Webview refreshes', 'Stable unchanged Compare refreshes', @@ -1571,7 +1708,7 @@ test('changelog preserves v2.3 release order and keeps named candidate fixes out 'Bounded credentials-watcher recovery', 'Exact bounded Claude content analysis', ]) { - assert.match(candidateSection, new RegExp(candidateFix)); + assert.match(publishedSection, new RegExp(candidateFix)); assert.doesNotMatch(releasedSection, new RegExp(candidateFix)); } }); @@ -1587,7 +1724,7 @@ test('release announcements are exact-version and user-disableable', () => { assert.match(settings, /key:\s*'releaseAnnouncements'/); assert.match(settings, /default:\s*true/); }); -test('Codex beta settings use safe provider-aware defaults', () => { +test('Codex settings default to today processed tokens and preserve provider-aware controls', () => { const settings = repoFile('src/settings.ts'); const webview = repoFile('src/webview.ts'); const packageJson = repoFile('package.json'); @@ -1595,7 +1732,7 @@ test('Codex beta settings use safe provider-aware defaults', () => { assert.match(settings, /key:\s*'codex\.enabled'[\s\S]*?default:\s*true/); assert.match(settings, /key:\s*'codex\.fileWatchSeconds'[\s\S]*?default:\s*'30'/); assert.match(settings, /key:\s*'statusBarProvider'[\s\S]*?default:\s*'auto'/); - assert.match(settings, /key:\s*'codex\.statusMetric'[\s\S]*?default:\s*'fresh'/); + assert.match(settings, /key:\s*'codex\.statusMetric'[\s\S]*?default:\s*'processed'/); assert.match(settings, /key:\s*'codex\.optimization\.enabled'[\s\S]*?default:\s*true/); assert.match(webview, /'general'[\s\S]*?'providers'[\s\S]*?'features'/); assert.match(packageJson, /claudeCodeUsage\.codex\.dataDirectory/); @@ -1687,7 +1824,7 @@ test('CI has separate Node, browser, and package release gates', () => { assert.match(workflow, /PLAYWRIGHT_BROWSERS_PATH:\s*\/ms-playwright/); assert.match(workflow, /needs:\s*\[test, ui\]/); assert.ok(runValues.includes('npm run test:ui')); - const packageAt = runValues.indexOf('npx -y @vscode/vsce@3.9.2 package --out /tmp/claude-code-usage-ci.vsix'); + const packageAt = runValues.indexOf('npx -y @vscode/vsce@4.0.0 package --out /tmp/claude-code-usage-ci.vsix'); const verifyAt = runValues.indexOf('node .github/scripts/verify-vsix.mjs /tmp/claude-code-usage-ci.vsix'); assert.ok(packageAt >= 0 && verifyAt > packageAt, 'smoke VSIX verification must follow packaging'); assertExactVscePin(runs); @@ -1713,10 +1850,10 @@ test('publish pins compatible registry CLIs and isolates all three delivery sink const releaseNodeMatch = workflow.match(/node-version:\s*'(\d+)'/); assert.ok(releaseNodeMatch, 'publish workflow must pin a Node major'); const releaseNodeMajor = Number(releaseNodeMatch[1]); - const packageStep = findRun('npx -y @vscode/vsce@3.9.2 package --out claude-code-usage.vsix'); + const packageStep = findRun('npx -y @vscode/vsce@4.0.0 package --out claude-code-usage.vsix'); const verifyStep = findRun('node .release-policy/.github/scripts/verify-vsix.mjs claude-code-usage.vsix "${RELEASE_TAG#v}"'); const restoreStep = runs.find(({ value }) => value.includes('gh release download "$RELEASE_TAG"')); - const publishStep = runs.find(({ value }) => value.includes('@vscode/vsce@3.9.2 publish')); + const publishStep = runs.find(({ value }) => value.includes('@vscode/vsce@4.0.0 publish')); const openVsxStep = runs.find(({ value }) => value.includes('ovsx@1.2.0 publish')); const resultStep = runs.find(({ value }) => value.includes('VSCODE_OUTCOME')); const attachStep = uses.find(({ value }) => @@ -1762,8 +1899,8 @@ test('publish pins compatible registry CLIs and isolates all three delivery sink test('CONTRIBUTING documents provider-aware privacy and three testing layers', () => { const guide = repoFile('CONTRIBUTING.md'); - assert.match(guide, /Codex Beta is enabled by default and can be turned off in provider settings\./); - assert.doesNotMatch(guide, /(?:opt-in[\s\S]{0,80}Codex Beta|Codex Beta[\s\S]{0,80}opt-in)/i); + assert.match(guide, /Codex is enabled by default and can be turned off in provider settings\./); + assert.doesNotMatch(guide.slice(0, 1_500), /Codex Beta/); assert.doesNotMatch(guide, /Claude-only|Multi-provider monitoring[^\n]*out of scope/i); assert.match(guide, /Usage ingestion is read-only, and Codex data is never mutated\./); assert.match(guide, /Claude session actions are separately gated and disabled by default\./); diff --git a/src/test/settingsPrivacy.test.ts b/src/test/settingsPrivacy.test.ts index f07f8ec2..9903a9ae 100644 --- a/src/test/settingsPrivacy.test.ts +++ b/src/test/settingsPrivacy.test.ts @@ -493,6 +493,10 @@ test('the tracked bilingual data contract covers quota, retention, clearing, and '512', 'ccu.heatmapRepo', 'ccu.heatmapPath', + 'ccu.heatmapDestination.v1', + 'ccu.sharing.template', + 'enableShareCard', + 'showHeatmap', 'ccu.combinedHeatmap.title', 'ccu.combinedHeatmap.range', 'ccu.combinedHeatmap.privacyPreview', @@ -512,10 +516,10 @@ test('the tracked bilingual data contract covers quota, retention, clearing, and } }); -test('GitHub heatmap publication is public-only, exact-target confirmed, and persists destinations after success', () => { +test('GitHub heatmap publication is public-only, exact-target confirmed, and atomically persists its destination after success', () => { const source = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'extension.ts'), 'utf8'); const start = source.indexOf('private async publishHeatmapToGitHub'); - const end = source.indexOf('/** Export a one-page usage share card', start); + const end = source.indexOf('private async getAdvice', start); const publish = source.slice(start, end); assert.ok(start >= 0 && end > start); @@ -525,18 +529,22 @@ test('GitHub heatmap publication is public-only, exact-target confirmed, and per assert.match(publish, /githubPublishConfirmationDetail/); assert.match(publish, /Confirm the exact GitHub write/); const write = publish.indexOf('publishPublicGitHubFile'); - const persistRepo = publish.indexOf("globalState.update('ccu.heatmapRepo'"); - const persistPath = publish.indexOf("globalState.update('ccu.heatmapPath'"); - assert.ok(write >= 0 && persistRepo > write && persistPath > write); + const complete = publish.indexOf('completeSuccessfulGitHubPublish'); + assert.ok(write >= 0 && complete > write); + assert.match(publish, /globalState\.update\(key, destination\)/); + assert.doesNotMatch(publish.slice(write), /globalState\.update\('ccu\.heatmap(?:Repo|Path)'/); + assert.match(publish, /publishPreferenceSaveWarning/); }); -test('reset sharing preferences clears only the remembered GitHub destination', () => { - const source = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'webview.ts'), 'utf8'); - const start = source.indexOf("case 'resetCombinedHeatmapPreferences'"); - const end = source.indexOf("case 'buildShareCard'", start); +test('reset sharing preferences host step clears only sharing-owned state and remembered GitHub destination', () => { + const source = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'extension.ts'), 'utf8'); + const start = source.indexOf('private async resetSharingPreferencesHost'); + const end = source.indexOf('private pendingClientReset', start); const reset = source.slice(start, end); assert.ok(start >= 0 && end > start); + assert.match(reset, /settings\.resetSharingOwnedData\(\)/); + assert.match(reset, /globalState\.update\(GITHUB_HEATMAP_DESTINATION_KEY, undefined\)/); assert.match(reset, /globalState\.update\('ccu\.heatmapRepo', undefined\)/); assert.match(reset, /globalState\.update\('ccu\.heatmapPath', undefined\)/); assert.doesNotMatch(reset, /quota|index|secret|token|cookie|log/i); diff --git a/src/test/shareCardSvg.test.ts b/src/test/shareCardSvg.test.ts index 8abcaa7c..72f1b002 100644 --- a/src/test/shareCardSvg.test.ts +++ b/src/test/shareCardSvg.test.ts @@ -9,15 +9,78 @@ import { renderShareCardSvg, resolveShareCardTheme, SHARE_CARD_THEMES, BADGE_COP const base: ShareCardData = { range: 'month', watermark: true }; +type Rgb = [number, number, number]; + +function hexRgb(hex: string): Rgb { + assert.match(hex, /^#[0-9a-f]{6}$/i); + return [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16)) as Rgb; +} + +function composite(foreground: Rgb, background: Rgb, alpha: number): Rgb { + return foreground.map((value, index) => + Math.round(value * alpha + background[index] * (1 - alpha)), + ) as Rgb; +} + +function compositeCssColor(color: string, background: Rgb): Rgb { + const rgba = /^rgba\((\d+),(\d+),(\d+),([\d.]+)\)$/.exec(color.replace(/\s+/g, '')); + if (rgba) { + return composite( + [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])], + background, + Number(rgba[4]), + ); + } + return hexRgb(color); +} + +function relativeLuminance(rgb: Rgb): number { + const channels = rgb.map((channel) => { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]; +} + +function contrastRatio(foreground: Rgb, background: Rgb): number { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05) / + (Math.min(foregroundLuminance, backgroundLuminance) + 0.05); +} + +function possibleTextSurfaces(theme: (typeof SHARE_CARD_THEMES)[keyof typeof SHARE_CARD_THEMES]): Rgb[] { + const bases = [hexRgb(theme.bgTop), hexRgb(theme.bgBottom)]; + const decorated = bases.flatMap((background) => [ + background, + composite(hexRgb(theme.blobWarm), background, 0.55), + composite(hexRgb(theme.blobCool), background, 0.5), + ]); + return decorated.flatMap((background) => [ + background, + compositeCssColor(theme.panelFill, background), + compositeCssColor(theme.badgeFill, background), + ]); +} + 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, /role="img" aria-label="Claude Code Usage/); assert.match(svg, />Claude Code UsageAI coding usage snapshot { + const svg = renderShareCardSvg(base, { + avatarDataUri: `data:image/svg+xml,\" onload=\"alert(1)' data-canary='unsafe`, + }); + assert.doesNotMatch(svg, /href="data:image\/svg\+xml," onload=/i); + assert.match(svg, /href="data:image\/svg\+xml," onload="alert\(1\)' data-canary='unsafe"/); +}); + test('draws the total-tokens hero compactly with its label + one decimal', () => { const svg = renderShareCardSvg({ ...base, totalTokens: 5_000_000_000 }); assert.match(svg, />5\.0B { } }); -test('BADGE_COPY covers every badge id in en / zh-CN / zh-TW', () => { +test('BADGE_COPY covers every badge id in all eight UI locales', () => { 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) { + for (const lang of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id'] as const) { assert.ok(BADGE_COPY[id]?.[lang]?.title && BADGE_COPY[id]?.[lang]?.line, `${id} ${lang}`); } } @@ -168,7 +231,91 @@ test('card text follows the UI language', () => { assert.match(zh, />总 token缓存命中total tokens${label}<`)); + } + // Unknown UI languages still fall back to English. const de = renderShareCardSvg(data, { lang: 'de-DE' }); - assert.match(de, />total tokenstotal tokenstotal tokens { + const data: ShareCardData = { + ...base, + range: 'week', + rhythm: [1, 2], + rhythmStart: '2026-06-01', + rhythmEnd: '2026-06-02', + }; + const expected = { + en: ['the last 7 days', 'Jun 1'], + 'de-DE': ['letzten 7 Tage', '1. Juni'], + 'zh-TW': ['最近 7 天', '6月1日'], + 'zh-CN': ['最近 7 天', '6月1日'], + ja: ['過去 7 日間', '6月1日'], + ko: ['최근 7일', '6월 1일'], + 'pt-BR': ['últimos 7 dias', '1 de jun.'], + id: ['7 hari terakhir', '1 Jun'], + }; + for (const [lang, markers] of Object.entries(expected)) { + const svg = renderShareCardSvg(data, { lang }); + for (const marker of markers) assert.match(svg, new RegExp(marker)); + } +}); + +test('every normal-size SVG label has at least 4.5:1 contrast on every possible card surface', () => { + const data: ShareCardData = { + ...base, + totalTokens: 5_000_000_000, + estimatedCost: 42, + cacheSharePct: 71, + topModelName: 'Opus 4.8', + sessions: 9, + messages: 20, + composition: { + input: 1_000_000, + output: 3_000_000, + cacheCreate: 2_000_000, + cacheRead: 4_000_000, + }, + rhythm: [1, 2, 5_300_000], + rhythmStart: '2026-06-01', + rhythmEnd: '2026-06-30', + badge: { id: 'cache-saver', label: 'Cache Saver' }, + }; + + for (const themeName of ['claudeClassic', 'claudeCream', 'auroraDark'] as const) { + const svg = renderShareCardSvg(data, { + theme: themeName, + avatarDataUri: 'data:image/png;base64,AAA', + username: 'octocat', + }); + const labels = [...svg.matchAll(/]*)>/g)].flatMap((match) => { + const fontSize = /font-size="([\d.]+)"/.exec(match[1])?.[1]; + const fill = /fill="([^"]+)"/.exec(match[1])?.[1]; + return fontSize && fill && Number(fontSize) <= 22 + ? [{ fontSize: Number(fontSize), fill }] + : []; + }); + assert.ok(labels.length > 0, `${themeName} should render normal-size labels`); + + for (const label of labels) { + assert.match(label.fill, /^#[0-9a-f]{6}$/i, `${themeName} normal text must use an auditable solid fill`); + for (const background of possibleTextSurfaces(SHARE_CARD_THEMES[themeName])) { + assert.ok( + contrastRatio(hexRgb(label.fill), background) >= 4.5, + `${themeName} ${label.fill} at ${label.fontSize}px must retain 4.5:1 contrast`, + ); + } + } + } }); diff --git a/src/test/sharePresentationCache.test.ts b/src/test/sharePresentationCache.test.ts new file mode 100644 index 00000000..9a21aa9a --- /dev/null +++ b/src/test/sharePresentationCache.test.ts @@ -0,0 +1,97 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); + +test('sharing renders only the active presentation and reuses materialized daily aggregates', () => { + const originalLoad = (Module as any)._load; + const activeColorTheme = { kind: 1 }; + (Module as any)._load = function(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') { + return { + ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, + window: { activeColorTheme }, + workspace: { getConfiguration: () => ({ get: () => undefined }) }, + }; + } + return originalLoad.call(this, request, parent, isMain); + }; + + let shareBuilds = 0; + + try { + const { UsageWebviewProvider } = require('../webview') as typeof import('../webview'); + const { I18n } = require('../i18n') as typeof import('../i18n'); + const provider = new UsageWebviewProvider({ + globalState: { get: () => undefined, update: async () => undefined }, + } as any) as any; + provider.buildShareCardSvgFor = () => { + shareBuilds += 1; + return ''; + }; + const initialRecords: any[] = []; + const usage = { + totalInputTokens: 100, + totalOutputTokens: 20, + totalCacheCreationTokens: 30, + totalCacheReadTokens: 50, + totalCost: 1.25, + costBreakdown: { input: 0.1, output: 0.2, cacheWrite: 0.3, cacheRead: 0.65 }, + messageCount: 2, + modelBreakdown: {}, + }; + const initialDaily = [{ date: '2026-09-22', data: usage }]; + provider.updateData( + null, null, null, null, [], [], [], undefined, undefined, initialRecords, + [], [], null, [], [], [], {}, null, initialDaily, + ); + provider.providerAvailability = { claude: true, codex: true, codexData: true }; + + const firstDaily = provider.getClaudeDailyUsageMap(I18n.getTimezone()); + assert.deepEqual(firstDaily, { + '2026-09-22': { tokens: 200, cost: 1.25, sessions: 0 }, + }); + assert.strictEqual( + provider.getClaudeDailyUsageMap(I18n.getTimezone()), + firstDaily, + 'unchanged materialized days reuse the daily projection', + ); + + provider.sharingTemplate = 'combinedHeatmap'; + provider.renderSharingWorkspace(); + assert.equal(shareBuilds, 0, 'the hidden Share Card is not generated'); + + provider.sharingTemplate = 'claudeShareCard'; + provider.renderSharingWorkspace(); + assert.equal(shareBuilds, 1, 'selecting Share Card generates one preview'); + provider.renderSharingWorkspace(); + assert.equal(shareBuilds, 1, 'unchanged Share Card preview is cached'); + + const nextRecords: any[] = []; + const nextDaily = [{ date: '2026-09-23', data: { ...usage, totalInputTokens: 300 } }]; + provider.updateData( + null, null, null, null, [], [], [], undefined, undefined, nextRecords, + [], [], null, [], [], [], {}, null, nextDaily, + ); + const secondDaily = provider.getClaudeDailyUsageMap(I18n.getTimezone()); + assert.notStrictEqual(secondDaily, firstDaily); + assert.equal(secondDaily['2026-09-23'].tokens, 400); + provider.renderSharingWorkspace(); + assert.equal(shareBuilds, 2, 'changed indexed records invalidate the active Share Card once'); + + const alternateZone = I18n.getTimezone() === 'Pacific/Auckland' + ? 'America/Los_Angeles' : 'Pacific/Auckland'; + const alternateDaily = provider.getClaudeDailyUsageMap(alternateZone); + assert.strictEqual(provider.getClaudeDailyUsageMap(alternateZone), alternateDaily); + assert.notStrictEqual(alternateDaily, secondDaily, 'timezone remains part of the cache identity'); + + activeColorTheme.kind = 2; + provider.renderSharingWorkspace(); + assert.equal(shareBuilds, 3, 'theme changes rebuild the active theme-aware card'); + + provider.invalidateShareCardPreview(); + provider.renderSharingWorkspace(); + assert.equal(shareBuilds, 4, 'formatting changes invalidate the active preview once'); + } finally { + (Module as any)._load = originalLoad; + } +}); diff --git a/src/test/shareWorkspaceRouting.test.ts b/src/test/shareWorkspaceRouting.test.ts new file mode 100644 index 00000000..385216d6 --- /dev/null +++ b/src/test/shareWorkspaceRouting.test.ts @@ -0,0 +1,117 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +const source = (file: string): string => fs.readFileSync( + path.join(__dirname, '..', '..', 'src', file), + 'utf8', +); + +test('legacy public share commands enter the matching preview-first workspace presentation', () => { + const extension = source('extension.ts'); + const start = extension.indexOf('private setupCommands'); + const end = extension.indexOf('private buildQuotaScopeOptions', start); + assert.ok(start >= 0 && end > start, 'command setup must remain discoverable'); + const commands = extension.slice(start, end); + + const routes = [ + ['claudeCodeUsage.exportShareCard', 'claudeShareCard'], + ['claudeCodeUsage.exportHeatmap', 'claudeHeatmap'], + ['claudeCodeUsage.publishHeatmapToGitHub', 'claudeHeatmap'], + ] as const; + for (const [command, presentation] of routes) { + const registration = commands.indexOf(`registerCommand('${command}'`); + assert.ok(registration >= 0, `${command} remains public`); + const nextRegistration = commands.indexOf('registerCommand(', registration + 1); + const handler = commands.slice( + registration, + nextRegistration >= 0 ? nextRegistration : commands.length, + ); + assert.match( + handler, + new RegExp(`showSharingWorkspace\\('${presentation}'\\)`), + `${command} should route to ${presentation}`, + ); + } +}); + +test('sharing has one visible on-off control while the old heatmap preference remains compatibility-only', () => { + const settings = source('settings.ts'); + const block = (key: string): string => { + const start = settings.indexOf(`key: '${key}'`); + const end = settings.indexOf('\n },', start); + assert.ok(start >= 0 && end > start, `${key} setting remains catalogued`); + return settings.slice(start, end); + }; + const sharing = block('enableShareCard'); + const legacyHeatmap = block('showHeatmap'); + + assert.match(sharing, /default: true/); + assert.doesNotMatch(sharing, /visible: false/); + assert.match(legacyHeatmap, /visible: false/); + + const webview = source('webview.ts'); + assert.match( + webview, + /message\.key === 'enableShareCard'[\s\S]*?this\.sharingWorkspaceRequested = false/, + 'turning off the one visible switch must dismiss a command-opened workspace', + ); +}); + +test('the old Claude panels no longer render beside the unified workspace', () => { + const webview = source('webview.ts'); + const start = webview.indexOf('private renderAllTimeData'); + const end = webview.indexOf('private weeklyValuePoints', start); + assert.ok(start >= 0 && end > start, 'All-time renderer must remain discoverable'); + const allTime = webview.slice(start, end); + + assert.doesNotMatch(allTime, /setting\('showHeatmap'/); + assert.doesNotMatch(allTime, /renderShareCardPanel\(\) \+ heatmapPanel/); + assert.match(allTime, /renderSharingWorkspace/); +}); + +test('Share Card preview and local export have no GitHub identity or network path', () => { + const webview = source('webview.ts'); + const start = webview.indexOf("case 'buildShareCard'"); + const end = webview.indexOf("case 'setDashboardAutoRefresh'", start); + assert.ok(start >= 0 && end > start, 'Share Card request handlers must remain discoverable'); + const localArtifactHandlers = webview.slice(start, end); + + assert.doesNotMatch( + localArtifactHandlers, + /getGithubIdentity|authentication|getSession|httpsGet|api\.github\.com|message\.(?:avatar|username)/i, + ); + assert.doesNotMatch(webview, /import \* as https from ['"]https['"]/); + assert.doesNotMatch(webview, /private async getGithubIdentity|private httpsGet/); +}); + +test('the in-workspace sharing reset routes through the existing host request and client ACK protocol', () => { + const webview = source('webview.ts'); + const start = webview.indexOf('function resetCombinedHeatmapPreferences'); + const end = webview.indexOf('\nfunction refresh()', start); + assert.ok(start >= 0 && end > start, 'direct sharing reset must remain discoverable'); + const reset = webview.slice(start, end); + + assert.match(reset, /runLocalDataAction\(['"]reset-sharing-preferences['"]\)/); + assert.doesNotMatch(reset, /ccuResetLocalStorageKeys|ccuResetSessionStorageKeys/); + assert.doesNotMatch(reset, /command:\s*['"]resetCombinedHeatmapPreferences['"]/); + assert.doesNotMatch(webview, /case ['"]resetCombinedHeatmapPreferences['"]/); +}); + +test('Share Card drafts keep raw project paths and session IDs memory-only', () => { + const webview = source('webview.ts'); + const start = webview.indexOf('var __ccuShareCardDraft'); + const end = webview.indexOf('\nfunction scApplyDefaultControls', start); + assert.ok(start >= 0 && end > start, 'Share Card draft policy must remain discoverable'); + const draft = webview.slice(start, end); + + assert.match(draft, /var __ccuShareCardDraft = null/); + assert.match(draft, /scope: 'all'/); + assert.match(draft, /JSON\.stringify\(scPersistentDraftConfig\(cfg\)\)/); + assert.match(draft, /__ccuShareCardDraft = scReadConfig\(\)/); + assert.doesNotMatch( + draft, + /localStorage\.setItem\([^\n]*JSON\.stringify\(scReadConfig\(\)\)/, + ); +}); diff --git a/src/test/sharingCommandIntent.test.ts b/src/test/sharingCommandIntent.test.ts new file mode 100644 index 00000000..a4988729 --- /dev/null +++ b/src/test/sharingCommandIntent.test.ts @@ -0,0 +1,123 @@ +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; +import Module = require('node:module'); + +import { SharingCommandIntentLedger } from '../sharingCommandIntent'; + +test('consumed sharing command intent stays one-shot while revision history remains monotonic', () => { + const ledger = new SharingCommandIntentLedger(); + + const first = ledger.issue('claudeHeatmap'); + assert.deepEqual(first, { revision: 1, template: 'claudeHeatmap' }); + assert.deepEqual(ledger.pending(), first); + assert.equal(ledger.acknowledge(0), false, 'a stale ACK cannot consume live intent'); + assert.deepEqual(ledger.pending(), first); + + assert.equal(ledger.acknowledge(first.revision), true); + assert.equal(ledger.pending(), undefined, 'ordinary rerenders have no consumed intent to replay'); + assert.equal(ledger.acknowledge(first.revision), false, 'consumption is idempotent'); + + ledger.clearPending(); + assert.equal(ledger.pending(), undefined, 'reset and dispose/reopen keep consumed intent absent'); + assert.equal(ledger.latestRevision(), 1, 'reset must not reuse an old command revision'); + + const second = ledger.issue('claudeShareCard'); + assert.deepEqual(second, { revision: 2, template: 'claudeShareCard' }); + ledger.clearPending(); + assert.equal(ledger.pending(), undefined); + assert.equal(ledger.latestRevision(), 2); + + assert.deepEqual(ledger.issue('claudeHeatmap'), { + revision: 3, + template: 'claudeHeatmap', + }); +}); + +test('a delayed ACK cannot consume a newer sharing command', () => { + const ledger = new SharingCommandIntentLedger(); + const first = ledger.issue('claudeHeatmap'); + const second = ledger.issue('claudeShareCard'); + + assert.equal(ledger.acknowledge(first.revision), false); + assert.deepEqual(ledger.pending(), second); + assert.equal(ledger.acknowledge(second.revision), true); + assert.equal(ledger.pending(), undefined); +}); + +test('the provider consumes an acknowledged command across rerender, reset, and panel reopen', async () => { + const originalLoad = (Module as any)._load; + let messageHandler: ((message: Record) => Promise) | undefined; + let disposeHandler: (() => void) | undefined; + const panels: Array<{ webview: { html: string } }> = []; + const vscode = { + ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, + ViewColumn: { One: 1 }, + Uri: { file: (fsPath: string) => ({ fsPath }) }, + env: { language: 'en', uriScheme: 'vscode' }, + commands: { executeCommand: async () => undefined }, + extensions: { getExtension: () => undefined }, + window: { + activeColorTheme: { kind: 1 }, + createWebviewPanel: () => { + const webview = { + html: '', + onDidReceiveMessage: (handler: typeof messageHandler) => { + messageHandler = handler; + return { dispose: () => undefined }; + }, + postMessage: async () => true, + }; + const panel = { + webview, + reveal: () => undefined, + onDidDispose: (handler: () => void) => { + disposeHandler = handler; + return { dispose: () => undefined }; + }, + }; + panels.push(panel); + return panel; + }, + showWarningMessage: async () => undefined, + }, + workspace: { + workspaceFolders: [], + getConfiguration: () => ({ get: () => undefined, update: async () => undefined }), + fs: {}, + }, + }; + (Module as any)._load = function(request: string, parent: unknown, isMain: boolean) { + if (request === 'vscode') return vscode; + return originalLoad.call(this, request, parent, isMain); + }; + + try { + const { UsageWebviewProvider } = require('../webview') as typeof import('../webview'); + const provider = new UsageWebviewProvider({ + globalState: { get: () => undefined, update: async () => undefined }, + } as any) as any; + provider.allRecords = [{ timestamp: '2026-09-01T00:00:00.000Z' }]; + + provider.showSharingWorkspace('claudeHeatmap'); + assert.match(panels[panels.length - 1].webview.html, /__sharingTemplateCommand = \{"revision":1,"template":"claudeHeatmap"\}/); + assert.ok(messageHandler); + await messageHandler!({ command: 'sharingTemplateCommandAck', revision: 1 }); + + provider.updateWebview(); + assert.match(panels[panels.length - 1].webview.html, /__sharingTemplateCommand = null/); + provider.clearSharingRuntimeState(); + provider.updateWebview(); + assert.match(panels[panels.length - 1].webview.html, /__sharingTemplateCommand = null/); + + assert.ok(disposeHandler); + disposeHandler!(); + provider.show(); + assert.equal(panels.length, 2); + assert.match(panels[panels.length - 1].webview.html, /__sharingTemplateCommand = null/); + + provider.showSharingWorkspace('claudeShareCard'); + assert.match(panels[panels.length - 1].webview.html, /__sharingTemplateCommand = \{"revision":2,"template":"claudeShareCard"\}/); + } finally { + (Module as any)._load = originalLoad; + } +}); diff --git a/src/test/statusBarThresholds.test.ts b/src/test/statusBarThresholds.test.ts index a6dd3341..a5c3bdc3 100644 --- a/src/test/statusBarThresholds.test.ts +++ b/src/test/statusBarThresholds.test.ts @@ -318,3 +318,46 @@ test('Codex quota tooltip escapes provider labels', () => { assert.match(tooltip.value, /<unsafe & label>/); assert.doesNotMatch(tooltip.value, / { + // quotaFormat.test.ts pins the rendering; this pins the half you would + // otherwise only see in the Extension Development Host — that the template + // argument setVisibility receives is what the bar ends up drawing. + const manager = bareStatusBar(); + manager.statusBarItem = statusItem(); + manager.quotaItem = statusItem(); + manager.contextItem = statusItem(); + manager.provider = 'claude'; + const resetsAt = new Date(Date.now() + 24 * 60 * 60 * 1_000).toISOString(); + const quota = { + five_hour: { utilization: 6, resets_at: resetsAt }, + seven_day: { utilization: 1, resets_at: resetsAt }, + limits: [ + { kind: 'session', group: 'session', percent: 6, resets_at: resetsAt, scope: null, is_active: true }, + { kind: 'weekly_all', group: 'weekly', percent: 1, resets_at: resetsAt, scope: null, is_active: true }, + { + kind: 'weekly_scoped', + group: 'weekly', + percent: 92, + resets_at: resetsAt, + scope: { model: { id: null, display_name: 'Fable' } }, + is_active: true, + }, + ], + }; + + // Empty template, scoped weeklies off: the built-in layout, and the loud + // per-model cap it does not draw must not colour the item either. + manager.setVisibility(true, true, true, 'cost', false, false, false, 'decimal', ''); + manager.updateQuota(quota); + assert.equal(manager.quotaItem.text, '$(dashboard) 5h 6% · wk 1%'); + assert.equal(manager.quotaItem.backgroundColor, undefined); + + // Same data, same toggles: naming that cap in a template both shows it and + // hands it the colour. + manager.setVisibility(true, true, true, 'cost', false, false, false, 'decimal', '{5h.pct} | {7d.pct} · {model:Fable.pct}'); + manager.updateQuota(quota); + assert.equal(manager.quotaItem.text, '$(dashboard) 6% | 1% · 92%'); + assert.equal(manager.quotaItem.backgroundColor?.id, 'statusBarItem.errorBackground'); + assert.equal(manager.quotaItem.visible, true); +}); diff --git a/src/test/usageDateLabels.test.ts b/src/test/usageDateLabels.test.ts index 36f9b213..af1eadf0 100644 --- a/src/test/usageDateLabels.test.ts +++ b/src/test/usageDateLabels.test.ts @@ -25,3 +25,39 @@ test('date keys are rendered verbatim instead of shifting in behind-UTC zones', }; assert.equal(formatUsageDate('2026-06-01', 'en-US', options, true), 'June 2026'); }); + +test('memoised labels match toLocaleDateString for every UI locale and option shape', () => { + const locales = ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']; + const dailyOptions: Intl.DateTimeFormatOptions[] = [ + {}, + { timeZone: 'America/New_York' }, + { timeZone: 'Asia/Shanghai' }, + { year: 'numeric', month: 'numeric', day: 'numeric' }, + { month: 'short', day: 'numeric', weekday: 'short' }, + { dateStyle: 'medium' }, + { hour: '2-digit' }, + ]; + const keys = ['2026-01-01', '2026-02-28', '2026-07-01', '2026-12-31']; + for (const locale of locales) { + for (const options of dailyOptions) { + for (const key of keys) { + const [year, month, day] = key.split('-').map(Number); + const date = new Date(Date.UTC(year, month - 1, day, 12)); + // Twice, so the second call is served by the cached formatter. + for (let i = 0; i < 2; i++) { + assert.equal( + formatUsageDate(key, locale, options), + date.toLocaleDateString(locale, { ...options, timeZone: 'UTC' }), + `${locale} ${JSON.stringify(options)} ${key}` + ); + assert.equal( + formatUsageDate(key, locale, options, true), + new Date(Date.UTC(year, month - 1, 1, 12)) + .toLocaleDateString(locale, { year: 'numeric', month: 'long', timeZone: 'UTC' }), + `${locale} monthly ${key}` + ); + } + } + } + } +}); diff --git a/src/types.ts b/src/types.ts index b9ce531a..c7a2e8a1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -337,6 +337,8 @@ export interface ExtensionConfig { quotaFiveHourOnly: boolean; // Format of the reset countdown appended to the quota item (issue #74). resetCountdownFormat: 'decimal' | 'units' | 'clock'; + // Explicit status-bar layout that replaces the built-in one. '' = built-in. + statusBarQuotaFormat: string; // Fetch real 5-hour / weekly limit utilisation via Claude Code's OAuth session. usageLimitTracking: boolean; // LLM "usage advice" feature (OpenAI-compatible endpoint, e.g. DeepSeek). diff --git a/src/usageDateLabels.ts b/src/usageDateLabels.ts index 611f54ea..f0e484d7 100644 --- a/src/usageDateLabels.ts +++ b/src/usageDateLabels.ts @@ -20,5 +20,34 @@ export function formatUsageDate( const options: Intl.DateTimeFormatOptions = monthly ? { year: 'numeric', month: 'long' } : dailyOptions; - return date.toLocaleDateString(locale, { ...options, timeZone: 'UTC' }); + return usageDateFormatter(locale, { ...options, timeZone: 'UTC' }).format(date); +} + +// The dashboard formats one label per table row and chart point, and +// toLocaleDateString constructs a fresh Intl.DateTimeFormat on every call (the +// same cost #99 removed from dateKeys). Formatters are memoised per locale and +// options; the dashboard only ever asks for a handful of combinations. +const usageDateFormatters = new Map(); + +function usageDateFormatter( + locale: string, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat { + const key = `${locale}|${JSON.stringify(options)}`; + const cached = usageDateFormatters.get(key); + if (cached) { + return cached; + } + // toLocaleDateString always renders a date: with no date field requested it + // defaults to numeric year, month and day. Intl.DateTimeFormat only does so + // when no time field is requested either, so apply the default explicitly. + const hasDateField = options.weekday !== undefined || options.year !== undefined || + options.month !== undefined || options.day !== undefined || + options.dateStyle !== undefined; + const formatter = new Intl.DateTimeFormat( + locale, + hasDateField ? options : { ...options, year: 'numeric', month: 'numeric', day: 'numeric' }, + ); + usageDateFormatters.set(key, formatter); + return formatter; } diff --git a/src/webview.ts b/src/webview.ts index 288206dd..784dcd00 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -30,10 +30,19 @@ import { } from './combinedHeatmapSvg'; import { DEFAULT_SECTIONS, ShareSections, buildShareCardData, shareCardFilename } from './shareCard'; import { renderShareCardSvg, ShareCardTheme } from './shareCardSvg'; +import { + SharingCommandIntentLedger, + SharingTemplate, +} from './sharingCommandIntent'; import { parseConversation } from './conversationLog'; import { renderConversationViewer } from './conversationViewerHtml'; import { formatUsageDate, shortUsageDate } from './usageDateLabels'; import { normalizeQuotaWindows } from './quotaWindows'; +import { + CUSTOM_QUOTA_STATUS_TEMPLATE, + FIVE_HOUR_QUOTA_STATUS_TEMPLATE, + WEEKLY_QUOTA_STATUS_TEMPLATE, +} from './quotaFormat'; import { buildWeeklyValueTimeline, claudeWeeklyEquivalentUsage, @@ -71,7 +80,6 @@ import { } from './projectUsageMatrix'; import * as os from 'os'; import * as path from 'path'; -import * as https from 'https'; import { createHash, randomBytes } from 'crypto'; import { AdviceEffectivenessProvider, @@ -358,6 +366,8 @@ function localDataUiCopy(locale: string): LocalDataUiCopy { }; } +export type { SharingTemplate } from './sharingCommandIntent'; + interface CombinedHeatmapUiCopy { eyebrow: string; panelTitle: string; @@ -934,6 +944,7 @@ export class UsageWebviewProvider { private allTimeData: UsageData | null = null; private dailyDataForRolling30Days: { date: string; data: UsageData }[] = []; private dailyDataForAllTime: { date: string; data: UsageData }[] = []; + private dailyDataForEveryDay: { date: string; data: UsageData }[] = []; private hourlyDataForToday: { hour: string; data: UsageData }[] = []; private hourlyDataForRolling30DaysByDay: Record< string, @@ -944,6 +955,9 @@ export class UsageWebviewProvider { private dataDirectory: string | null = null; private currentTab: string = 'today'; private currentProvider: 'claude' | 'codex' | 'compare' = 'claude'; + private sharingTemplate: SharingTemplate = 'combinedHeatmap'; + private sharingWorkspaceRequested = false; + private readonly sharingCommandIntents = new SharingCommandIntentLedger(); private codexView: CodexUsageView | null = null; private codexInsights: CodexScopedInsights = emptyCodexScopedInsights(); private providerAvailability = { claude: false, codex: false, codexData: false }; @@ -962,7 +976,6 @@ export class UsageWebviewProvider { private branchBreakdown: BranchUsage[] = []; private workflowBreakdown: WorkflowUsage[] = []; private costliestMessages: CostlyMessage[] = []; - private githubIdentity?: { avatar?: string; name?: string }; // fetched on demand // Cache the (slow-moving) cache analyses so they recompute at most weekly, not // every render — Carl: a weekly refresh is plenty for these estimates. private analysisCache?: { @@ -978,16 +991,27 @@ export class UsageWebviewProvider { // 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; + // Reuse expensive local previews only while their data and display context + // remain unchanged. Config is retained independently from a generated SVG. + private shareCardPreviewCache?: { + records: any[]; day: string; timeZone: string; locale: string; themeKind?: number; svg: string; + }; + private claudeHeatmapPreviewCache?: { + dailyRows: { date: string; data: UsageData }[]; + day: string; + timeZone: string; + locale: string; + svg: string; + }; + private claudeDailyUsageCache?: { + dailyRows: { date: string; data: UsageData }[]; + timeZone: string; + daily: ReturnType; + }; private lastShareCardConfig?: { range: string; scope: string; sections: Record; - avatar: boolean; - username: boolean; fullNumbers: boolean; theme: string; }; @@ -1035,7 +1059,8 @@ export class UsageWebviewProvider { action: LocalDataAction, quotaScopeToken?: string, ) => Promise; - public onResetSharingPreferences?: () => Promise; + public onExportClaudeHeatmap?: () => void | Promise; + public onPublishClaudeHeatmap?: () => void | Promise; public onLocalDataClientReady?: () => void; // Shared settings store + a callback to let extension.ts re-apply config when // the user edits a setting in the dashboard's ⚙ Settings tab. Both are set by @@ -1623,14 +1648,26 @@ export class UsageWebviewProvider { } public clearSharingRuntimeState(): void { - this.lastShareCardSvg = undefined; + this.invalidateSharingCaches(); this.lastShareCardConfig = undefined; + this.sharingTemplate = 'combinedHeatmap'; + this.sharingWorkspaceRequested = false; + // Reset clears any live command without rewinding its sequence. A later + // command therefore cannot collide with a command already seen by this + // provider's Webview. + this.sharingCommandIntents.clearPending(); } - /** A display-format change invalidates rendered money but not the user's + /** Pricing or display changes invalidate derived previews but not the user's * chosen share-card range, scope, sections, or theme. */ public invalidateShareCardPreview(): void { - this.lastShareCardSvg = undefined; + this.invalidateSharingCaches(); + } + + private invalidateSharingCaches(): void { + this.shareCardPreviewCache = undefined; + this.claudeHeatmapPreviewCache = undefined; + this.claudeDailyUsageCache = undefined; } private async handleClearAdviceLocalDataMessage(): Promise { @@ -2055,6 +2092,12 @@ export class UsageWebviewProvider { } break; } + case 'sharingTemplateCommandAck': { + if (Number.isSafeInteger(message.revision)) { + this.sharingCommandIntents.acknowledge(message.revision); + } + break; + } case 'requestLocalDataInventory': { const summary = message.clientSummary as Partial | undefined; const clientSummary: LocalDataClientSummary = { @@ -2121,10 +2164,22 @@ export class UsageWebviewProvider { break; } case 'exportHeatmap': - vscode.commands.executeCommand('claudeCodeUsage.exportHeatmap'); + await this.onExportClaudeHeatmap?.(); break; case 'publishHeatmap': - vscode.commands.executeCommand('claudeCodeUsage.publishHeatmapToGitHub'); + await this.onPublishClaudeHeatmap?.(); + break; + case 'sharingTemplateChanged': + if ( + message.template === 'combinedHeatmap' || + message.template === 'claudeShareCard' || + message.template === 'claudeHeatmap' + ) { + if (this.sharingTemplate !== message.template) { + this.sharingTemplate = message.template; + this.updateWebview(); + } + } break; case 'previewCombinedHeatmap': { if (!this.panel) { @@ -2208,51 +2263,31 @@ export class UsageWebviewProvider { } break; } - case 'resetCombinedHeatmapPreferences': { - try { - if (this.onResetSharingPreferences) { - await this.onResetSharingPreferences(); - } else { - await this.context.globalState.update('ccu.heatmapRepo', undefined); - await this.context.globalState.update('ccu.heatmapPath', undefined); - this.clearSharingRuntimeState(); - } - this.panel?.webview.postMessage({ - command: 'combinedHeatmapPreferencesReset', - ok: true, - }); - } catch { - this.panel?.webview.postMessage({ - command: 'combinedHeatmapPreferencesReset', - ok: false, - }); - } - break; - } case 'buildShareCard': { - // On-demand preview: build the SVG from the panel's config and send - // it back for injection (no full re-render). + // Preview is a pure local render using only the indexed usage data. 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.shareCardPreviewCache = { + records: this.allRecords, + day: dayKeyInZone(new Date(), I18n.getTimezone()), + timeZone: I18n.getTimezone(), + locale: I18n.getLocale(), + themeKind: vscode.window.activeColorTheme?.kind, + svg, + }; this.lastShareCardConfig = { range, scope, sections, - avatar: !!message.avatar, - username: !!message.username, fullNumbers: !!message.fullNumbers, theme, }; @@ -2264,16 +2299,13 @@ export class UsageWebviewProvider { break; } case 'exportShareCard': { - // Export using the panel's config (range / scope / sections). + // Local export uses only already-indexed usage and the chosen config. 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', }); @@ -2408,6 +2440,12 @@ export class UsageWebviewProvider { case 'updateSetting': if (this.settings && typeof message.key === 'string') { await this.settings.set(message.key, message.value); + if ( + message.key === 'enableShareCard' && + this.settings.get('enableShareCard') === false + ) { + this.sharingWorkspaceRequested = false; + } // Re-apply config. Pass the key so status-bar-only toggles skip the // dashboard reload (which flickers); globalState changes don't fire // onDidChangeConfiguration, so this callback is the only signal. @@ -2475,7 +2513,13 @@ export class UsageWebviewProvider { const requestedProvider = message.provider === 'codex' || message.provider === 'claude' ? message.provider : ''; - if (!/^\d{4}-\d{2}$/.test(monthString) || !this.panel || requestedProvider !== this.currentProvider) { + // Claude's materialized all-time month rows use YYYY-MM-01, while + // Codex rows use YYYY-MM. Keep the response key unchanged so the + // matching detail row can consume it after the host round-trip. + const validMonth = requestedProvider === 'claude' + ? /^\d{4}-\d{2}(?:-01)?$/.test(monthString) + : /^\d{4}-\d{2}$/.test(monthString); + if (!validMonth || !this.panel || requestedProvider !== this.currentProvider) { break; } if (requestedProvider === 'codex') { @@ -2490,8 +2534,10 @@ export class UsageWebviewProvider { }); break; } - const { ClaudeDataLoader } = await import('./dataLoader'); - const dailyData = ClaudeDataLoader.getDailyDataForSpecificMonth(this.allRecords, monthString); + const monthKey = monthString.slice(0, 7); + const dailyData = this.dailyDataForEveryDay + .filter((row) => row.date.startsWith(`${monthKey}-`)) + .sort((left, right) => left.date.localeCompare(right.date)); await this.panel.webview.postMessage({ command: 'dailyDataResponse', provider: 'claude', @@ -2506,6 +2552,23 @@ export class UsageWebviewProvider { this.updateWebview(); } + /** Compatibility commands enter the same preview-first workspace instead of + * bypassing it with an immediate picker or network flow. */ + public showSharingWorkspace(template: SharingTemplate): void { + if (!this.allRecords || this.allRecords.length === 0) { + void vscode.window.showWarningMessage(I18n.t.popup.noDataMessage); + return; + } + this.sharingTemplate = template; + this.sharingWorkspaceRequested = true; + this.sharingCommandIntents.issue(template); + this.currentTab = 'all'; + this.currentProvider = this.providerAvailability.claude && this.providerAvailability.codexData + ? 'compare' + : 'claude'; + this.show(); + } + updateData( sessionData: SessionData | null, todayData: UsageData | null, @@ -2528,6 +2591,7 @@ export class UsageWebviewProvider { { hour: string; data: UsageData }[] > = {}, projectUsageMatrix: ProjectUsageMatrixSnapshot | null = null, + dailyDataForEveryDay: { date: string; data: UsageData }[] = [], ): void { this.currentSessionData = sessionData; this.todayData = todayData; @@ -2535,12 +2599,19 @@ export class UsageWebviewProvider { this.allTimeData = allTimeData; this.dailyDataForRolling30Days = dailyDataForRolling30Days; this.dailyDataForAllTime = dailyDataForAllTime; + if (this.dailyDataForEveryDay !== dailyDataForEveryDay) { + this.invalidateSharingCaches(); + } + this.dailyDataForEveryDay = dailyDataForEveryDay; this.hourlyDataForToday = hourlyDataForToday; this.hourlyDataForRolling30DaysByDay = hourlyDataForRolling30DaysByDay; this.error = error || null; this.dataDirectory = dataDirectory || null; this.isLoading = false; if (allRecords) { + if (this.allRecords !== allRecords) { + this.invalidateSharingCaches(); + } this.allRecords = allRecords; } this.sessionBreakdown = sessionBreakdown; @@ -2972,7 +3043,7 @@ export class UsageWebviewProvider { if (!endDateISO) { throw new Error('Could not resolve the configured timezone date.'); } - const claudeDaily = ClaudeDataLoader.getDailyUsageMap(this.allRecords ?? [], timeZone); + const claudeDaily = this.getClaudeDailyUsageMap(timeZone); const daily = mergeCombinedDailyUsage( claudeDailyPointsFromUsage(claudeDaily), codexDailyPointsFromUsage(this.codexView?.daily ?? []), @@ -2989,6 +3060,7 @@ export class UsageWebviewProvider { range, endDateISO, title, + locale: I18n.getLocale(), palette, customAccent, intensityMode, @@ -3002,12 +3074,25 @@ export class UsageWebviewProvider { }; } - private renderCombinedHeatmapPanel(): string { + private renderSharingWorkspace(): string { const copy = combinedHeatmapUiCopy(I18n.getLocale()); - const artifact = this.buildCombinedHeatmapArtifact('year', copy.defaultTitle); - const preview = artifact.hasData - ? artifact.svg - : '

' + this.escapeHtml(copy.noData) + '

'; + const workspaceCopy = I18n.sharingWorkspace; + const combinedAvailable = this.providerAvailability.claude && this.providerAvailability.codexData; + const selected: SharingTemplate = this.sharingTemplate === 'combinedHeatmap' && !combinedAvailable + ? 'claudeShareCard' + : this.sharingTemplate; + this.sharingTemplate = selected; + const selectedOption = (template: SharingTemplate): string => template === selected ? ' selected' : ''; + const presentationState = (template: SharingTemplate): string => + ' data-sharing-presentation="' + template + '"' + (template === selected ? '' : ' hidden'); + const artifact = selected === 'combinedHeatmap' + ? this.buildCombinedHeatmapArtifact('year', copy.defaultTitle) + : undefined; + const preview = artifact + ? (artifact.hasData + ? artifact.svg + : '

' + this.escapeHtml(copy.noData) + '

') + : ''; const paletteChoice = ( value: string, label: string, @@ -3025,12 +3110,12 @@ export class UsageWebviewProvider { paletteChoice('codexBlue', copy.codexBlue, ['#eff6ff', '#93c5fd', '#3b82f6', '#1d4ed8']) + paletteChoice('githubGreen', copy.githubGreen, ['#dafbe1', '#6fdd8b', '#2da44e', '#116329']) + paletteChoice('custom', copy.customPalette, ['#eee8f8', '#bca5e6', '#8668c7', '#4f2f87']); - return '
' + - '
' + - '' + this.escapeHtml(copy.eyebrow) + '' + - '

' + this.escapeHtml(copy.panelTitle) + '

' + - '

' + this.escapeHtml(copy.description) + '

' + - '◆ ' + this.escapeHtml(copy.privateBadge) + '
' + + const combinedPresentation = artifact ? ''; + '
' : this.renderSharingPresentationPlaceholder( + 'combinedHeatmap', + workspaceCopy.combinedPresentation, + workspaceCopy.combinedPresentationDescription, + ); + + const combinedOption = ''; + + return ''; + } + + private renderSharingPresentationPlaceholder( + template: SharingTemplate, + title: string, + description: string, + ): string { + return ''; } private renderCodexCompare(): string { @@ -3102,8 +3240,8 @@ export class UsageWebviewProvider { '' + this.escapeHtml(copy.cachedInput) + '' + I18n.formatNumber(cache) + '' + '' + this.escapeHtml(copy.output) + '' + I18n.formatNumber(output) + '' + ''; - const sharingWorkspace = this.setting('enableShareCard', true) - ? this.renderCombinedHeatmapPanel() + const sharingWorkspace = (this.setting('enableShareCard', true) || this.sharingWorkspaceRequested) + ? this.renderSharingWorkspace() : ''; const summaryGrid = '
' + card( @@ -3312,7 +3450,9 @@ export class UsageWebviewProvider { private renderSettingsPanel(provider: SettingProvider): string { const t = I18n.t.popup; const snap: SettingView[] = this.settings ? this.settings.snapshot() : []; - const visible = snap.filter((setting) => settingAppliesToProvider(setting, provider)); + const visible = snap.filter((setting) => + setting.visible !== false && settingAppliesToProvider(setting, provider), + ); const groups: { key: string; label: string }[] = [ { key: 'general', label: t.settingsGroupGeneral }, { key: 'providers', label: t.settingsGroupProviders }, @@ -3383,7 +3523,44 @@ export class UsageWebviewProvider { /** One row in the settings panel: label + help + the right input control. The * label / help follow the plugin language (I18n.settingText), falling back to * the catalog English for the English UI. */ + private renderQuotaFormatSettingRow(it: SettingView): string { + const esc = (value: string): string => this.escapeHtml(value); + const copy = I18n.t.popup; + const tr = I18n.settingText(it.key); + const label = tr.label ?? it.label; + const detailedHelp = tr.help ?? it.help ?? ''; + const id = 'set_statusBarQuotaFormat'; + const presetId = `${id}_preset`; + const raw = String(it.value); + const selected = raw === '' || raw === FIVE_HOUR_QUOTA_STATUS_TEMPLATE || + raw === WEEKLY_QUOTA_STATUS_TEMPLATE ? raw : 'custom'; + const option = (value: string, text: string): string => + ''; + const options = option('', copy.quotaFormatBuiltIn) + + option(FIVE_HOUR_QUOTA_STATUS_TEMPLATE, copy.quotaFormatFiveHour) + + option(WEEKLY_QUOTA_STATUS_TEMPLATE, copy.quotaFormatWeekly) + + option('custom', copy.quotaFormatCustom); + const custom = selected === 'custom'; + return '
' + esc(copy.quotaFormatShortHelp) + '
' + + '
' + + '
' + + '' + + '
' + esc(detailedHelp) + '
' + + '
'; + } + private renderSettingRow(it: SettingView): string { + if (it.key === 'statusBarQuotaFormat') { + return this.renderQuotaFormatSettingRow(it); + } const esc = (s: string): string => this.escapeHtml(s); const tr = I18n.settingText(it.key); const label = tr.label ?? it.label; @@ -4515,21 +4692,13 @@ export class UsageWebviewProvider { const allTimeSummary = this.renderUsageData(this.allTimeData, provider); - // Optional GitHub-style token heatmap (off by default; mainly a shareable - // view). Inline SVG renders with working hover tooltips inside the webview. - let heatmapPanel = ''; - if (this.setting('showHeatmap', false) && this.allRecords && this.allRecords.length > 0) { - const daily = ClaudeDataLoader.getDailyUsageMap(this.allRecords, I18n.getTimezone()); - heatmapPanel = - '

Token heatmap

' + - '
' + renderHeatmapSvg(daily, { - endDateISO: dayKeyInZone(new Date(), I18n.getTimezone()), - }) + '
' + - '
'; - } + // With both providers available, sharing has one home in Compare. A + // Claude-only installation receives the exact same workspace here. + const sharingWorkspace = + !this.providerAvailability.codexData && + (this.setting('enableShareCard', true) || this.sharingWorkspaceRequested) + ? this.renderSharingWorkspace() + : ''; const dailyBreakdown = this.dailyDataForAllTime.length > 0 @@ -4614,9 +4783,8 @@ export class UsageWebviewProvider { ` : ''; - // Heatmap sits right above the "monthly usage" breakdown, below the totals. return allTimeSummary + this.renderWeeklyValuePanel(provider) + - this.renderShareCardPanel() + heatmapPanel + dailyBreakdown; + sharingWorkspace + dailyBreakdown; } private weeklyValuePoints(provider: SettingProvider): WeeklyValuePoint[] { @@ -4792,18 +4960,12 @@ export class UsageWebviewProvider { tableRows + '
'; } - /** The "Share card" panel (All tab). On by default (`enableShareCard`); when - * enabled, a config form — range / scope / which metrics — with a Generate button. - * The preview is built on demand (no per-keystroke re-render), and export uses - * the same config. Privacy-safe: built from buildShareCardData, aggregate only. */ - private renderShareCardPanel(): string { + /** Claude's legacy Share Card remains a provider-truthful presentation inside + * the one sharing workspace. Its complete preview precedes every control. */ + private renderShareCardPanel(selected: SharingTemplate): string { const esc = (s: string): string => this.escapeHtml(s); - if (!this.setting('enableShareCard', true)) { - return ''; - } - if (!this.allRecords || this.allRecords.length === 0) { - return ''; - } + const copy = I18n.sharingWorkspace; + const combinedCopy = combinedHeatmapUiCopy(I18n.getLocale()); // Range: grouped like the scope dropdown — rolling presets vs. a specific // calendar month (last 12), so the two kinds are visually distinct. @@ -4812,8 +4974,8 @@ export class UsageWebviewProvider { for (let i = 1; i <= 12; i++) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; - const label = d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); - monthOpts.push(``); + const label = d.toLocaleDateString(I18n.getLocale(), { month: 'long', year: 'numeric' }); + monthOpts.push(``); } // Rehydrate the last config so an auto-refresh re-render doesn't wipe the // user's picks or their generated preview (Carl: refresh shouldn't blow away @@ -4826,14 +4988,14 @@ export class UsageWebviewProvider { const rangeSelect = ''; @@ -4850,9 +5012,9 @@ export class UsageWebviewProvider { .join(''); const scopeSelect = ''; // Section toggles. Default ON: cost, cache-hit, model (+ the token hero, @@ -4861,57 +5023,156 @@ export class UsageWebviewProvider { ''; const toggles = - check('totalTokens', 'Total tokens', true) + - check('estimatedCost', 'Est. cost', true) + - check('cacheEfficiency', 'Cache-hit rate', true) + - check('topModel', 'Top model', true) + - check('sessions', 'Session count', true) + - check('tokenComposition', 'Token mix', true) + - check('rhythm', 'Daily pulse', true) + - check('badge', 'Badge', true) + - check('messages', 'Message count', false) + - check('projectName', 'Project name', false); - const avatarChecked = cfg?.avatar ? ' checked' : ''; - const nameChecked = cfg?.username ? ' checked' : ''; + check('totalTokens', copy.totalTokens, true) + + check('estimatedCost', copy.estimatedCost, true) + + check('cacheEfficiency', copy.cacheHitRate, true) + + check('topModel', copy.topModel, true) + + check('sessions', copy.sessionCount, true) + + check('tokenComposition', copy.tokenMix, true) + + check('rhythm', copy.dailyPulse, true) + + check('badge', copy.badge, true) + + check('messages', copy.messageCount, false) + + check('projectName', copy.projectName, false); const fullChecked = cfg?.fullNumbers ? ' checked' : ''; const curTheme = cfg?.theme || 'claudeClassic'; const themeSelect = ''; - const preview = this.lastShareCardSvg - ? this.lastShareCardSvg - : '

Preview appears here after you click Generate.

'; + const sections: Record = { + totalTokens: secOn('totalTokens', true), + estimatedCost: secOn('estimatedCost', true), + cacheEfficiency: secOn('cacheEfficiency', true), + topModel: secOn('topModel', true), + sessions: secOn('sessions', true), + tokenComposition: secOn('tokenComposition', true), + rhythm: secOn('rhythm', true), + badge: secOn('badge', true), + messages: secOn('messages', false), + projectName: secOn('projectName', false), + }; + const day = dayKeyInZone(now, I18n.getTimezone()); + const cachedPreview = this.shareCardPreviewCache; + let preview = cachedPreview?.records === this.allRecords && + cachedPreview.day === day && cachedPreview.timeZone === I18n.getTimezone() && + cachedPreview.locale === I18n.getLocale() && + cachedPreview.themeKind === vscode.window.activeColorTheme?.kind + ? cachedPreview.svg : undefined; + if (!preview) { + try { + preview = this.buildShareCardSvgFor(curRange, curScope, sections, { + fullNumbers: cfg?.fullNumbers, + theme: curTheme as ShareCardTheme, + }); + this.shareCardPreviewCache = { + records: this.allRecords, + day, + timeZone: I18n.getTimezone(), + locale: I18n.getLocale(), + themeKind: vscode.window.activeColorTheme?.kind, + svg: preview, + }; + } catch { + preview = '

' + esc(copy.previewPending) + '

'; + } + } return ( - '' ); } - /** Build the share-card SVG for a webview request ({range, scope, sections, - * size, avatar}). Shared by the preview (postMessage back) and export paths. */ + private renderClaudeHeatmapPresentation(selected: SharingTemplate): string { + const copy = I18n.sharingWorkspace; + const combinedCopy = combinedHeatmapUiCopy(I18n.getLocale()); + const day = dayKeyInZone(new Date(), I18n.getTimezone()); + const cachedPreview = this.claudeHeatmapPreviewCache; + let svg = cachedPreview?.dailyRows === this.dailyDataForEveryDay && + cachedPreview.day === day && cachedPreview.timeZone === I18n.getTimezone() && + cachedPreview.locale === I18n.getLocale() + ? cachedPreview.svg : undefined; + if (!svg) { + const daily = this.getClaudeDailyUsageMap(I18n.getTimezone()); + svg = renderHeatmapSvg(daily, { + endDateISO: dayKeyInZone(new Date(), I18n.getTimezone()), + locale: I18n.getLocale(), + }); + this.claudeHeatmapPreviewCache = { + dailyRows: this.dailyDataForEveryDay, + day, + timeZone: I18n.getTimezone(), + locale: I18n.getLocale(), + svg, + }; + } + return ''; + } + + /** The Compare and Claude heatmaps consume the same per-day projection. + * Share it across Webview renders; a changed record array is invalidated by + * updateData, and the timezone remains part of the cache identity. */ + private getClaudeDailyUsageMap(timeZone: string): ReturnType { + const cached = this.claudeDailyUsageCache; + if (cached?.dailyRows === this.dailyDataForEveryDay && cached.timeZone === timeZone) { + return cached.daily; + } + const daily = Object.fromEntries(this.dailyDataForEveryDay.map(({ date, data }) => [ + date, + { + tokens: data.totalInputTokens + data.totalOutputTokens + + data.totalCacheCreationTokens + data.totalCacheReadTokens, + cost: data.totalCost, + // The unified sharing surfaces currently render token activity only. + // Do not invent a distinct-session count from message aggregates. + sessions: 0, + }, + ])); + this.claudeDailyUsageCache = { + dailyRows: this.dailyDataForEveryDay, + timeZone, + daily, + }; + return daily; + } + + /** Build the share-card SVG from already-indexed local usage. Shared by the + * preview (postMessage back) and local export paths. */ private buildShareCardSvgFor( range: string, scope: string, @@ -4931,75 +5192,6 @@ export class UsageWebviewProvider { }); } - /** Fetch the signed-in GitHub user's avatar (data: URI) and display name, - * cached. Only called when the user ticks "GitHub avatar" / "GitHub name", so - * auth is on demand (just `read:user`). Missing parts come back undefined. */ - private async getGithubIdentity(): Promise<{ avatar?: string; name?: string }> { - if (this.githubIdentity) { - return this.githubIdentity; - } - let session: vscode.AuthenticationSession | undefined; - try { - session = await vscode.authentication.getSession('github', ['read:user'], { createIfNone: true }); - } catch { - return {}; - } - if (!session) { - return {}; - } - const out: { avatar?: string; name?: string } = {}; - try { - const user = await this.httpsGet('https://api.github.com/user', session.accessToken); - const parsed = JSON.parse(user.body.toString('utf8')) as { avatar_url?: string; name?: string; login?: string }; - out.name = (parsed.name && parsed.name.trim()) || parsed.login; - if (parsed.avatar_url) { - const url = parsed.avatar_url + (parsed.avatar_url.includes('?') ? '&' : '?') + 's=160'; - const img = await this.httpsGet(url); - out.avatar = `data:${img.contentType || 'image/png'};base64,${img.body.toString('base64')}`; - } - } catch { - /* keep whatever we got */ - } - this.githubIdentity = out; - return out; - } - - /** Minimal GET over https returning the raw body + content-type. Follows - * redirects (GitHub avatar URLs 302 to avatars.githubusercontent.com — not - * following them was why the avatar came back empty / broken). */ - private httpsGet(url: string, token?: string, hops = 0): Promise<{ body: Buffer; contentType: string }> { - return new Promise((resolve, reject) => { - const req = https.get( - url, - { - headers: { - 'User-Agent': 'ClaudeCodeUsage-VSCode', - ...(token ? { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' } : {}), - }, - timeout: 15000, - }, - (res) => { - const status = res.statusCode || 0; - const loc = res.headers.location; - if (status >= 300 && status < 400 && loc && hops < 4) { - res.resume(); // drain - // Don't forward the auth token to a redirected (CDN) host. - const next = new URL(loc, url).toString(); - this.httpsGet(next, undefined, hops + 1).then(resolve, reject); - return; - } - const chunks: Buffer[] = []; - res.on('data', (c) => chunks.push(c as Buffer)); - res.on('end', () => - resolve({ body: Buffer.concat(chunks), contentType: String(res.headers['content-type'] || '') }) - ); - } - ); - req.on('error', reject); - req.on('timeout', () => req.destroy(new Error('timeout'))); - }); - } - private renderSessionData(provider: SettingProvider = 'claude'): string { if (provider === 'codex') { const copy = I18n.t.providers.codex; @@ -8366,6 +8558,9 @@ export class UsageWebviewProvider { } .set-control input[type="number"] { min-width: 96px; width: 96px; } .set-control textarea { resize: vertical; min-width: 260px; } + .quota-format-control { flex-direction: column; align-items: stretch; gap: 6px; } + .quota-format-custom[hidden] { display: none; } + .quota-format-custom input { width: 100%; box-sizing: border-box; } .set-switch { position: relative; display: inline-block; width: 40px; height: 22px; } .set-switch input { opacity: 0; width: 0; height: 0; } .set-slider { @@ -9620,6 +9815,12 @@ export class UsageWebviewProvider { gap: 8px 10px; margin-bottom: 14px; padding-top: 12px; + padding-right: 0; + padding-bottom: 0; + padding-left: 0; + border-right: 0; + border-bottom: 0; + border-left: 0; border-top: 1px solid var(--vscode-panel-border); } .sc-check { @@ -9643,20 +9844,17 @@ export class UsageWebviewProvider { border: 1px solid var(--vscode-panel-border); border-radius: 10px; overflow: auto; + overscroll-behavior-inline: contain; + -webkit-overflow-scrolling: touch; max-height: 640px; - background: - linear-gradient(45deg, #f3f3f3 25%, transparent 25%, transparent 75%, #f3f3f3 75%), - linear-gradient(45deg, #f3f3f3 25%, #fff 25%, #fff 75%, #f3f3f3 75%); - background-size: 20px 20px; - background-position: 0 0, 10px 10px; + background: var(--vscode-editor-background); } .share-preview svg { display: block; width: 100%; height: auto; - max-width: 560px; + max-width: none; margin: 0 auto; - box-shadow: 0 2px 12px rgba(0,0,0,0.18); border-radius: 8px; } .share-preview p { @@ -9679,7 +9877,7 @@ export class UsageWebviewProvider { border: 1px solid var(--vscode-panel-border); border-radius: 6px; padding: 6px; - background: #ffffff; + background: var(--vscode-editor-background); } .heatmap-svg svg { display: block; @@ -9689,7 +9887,7 @@ export class UsageWebviewProvider { overflow: hidden; padding: var(--ccu-space-5); border: 1px solid var(--ccu-border); - border-left: 4px solid var(--vscode-charts-purple, #4f2f87); + border-left: 4px solid var(--vscode-charts-purple); border-radius: var(--ccu-radius-lg); background: var(--ccu-surface-raised); } @@ -9710,7 +9908,7 @@ export class UsageWebviewProvider { line-height: 1.5; } .combined-share-eyebrow { - color: var(--vscode-charts-purple, #8668c7); + color: var(--vscode-charts-purple); font-size: 10px; font-weight: 700; letter-spacing: 0.12em; @@ -9732,6 +9930,39 @@ export class UsageWebviewProvider { gap: var(--ccu-space-4); align-items: start; } + .sharing-template-field { + max-width: 520px; + margin: 0 0 var(--ccu-space-5); + } + .sharing-template-field select { + min-height: 34px; + font-family: var(--vscode-font-family); + } + .sharing-preview-stage, + .sharing-presentation { + min-width: 0; + } + .sharing-presentation[hidden] { + display: none; + } + .sharing-presentation-title { + margin: 0 0 var(--ccu-space-1); + font-size: 15px; + } + .sharing-presentation-description { + margin: 0 0 var(--ccu-space-3); + color: var(--vscode-descriptionForeground); + line-height: 1.45; + } + .sharing-controls-panel { + box-sizing: border-box; + width: 100%; + margin-top: var(--ccu-space-4); + } + .sharing-artifact-preview { + box-sizing: border-box; + width: 100%; + } .combined-preview-column, .combined-config-card { min-width: 0; @@ -9755,11 +9986,12 @@ export class UsageWebviewProvider { border-radius: var(--ccu-radius-panel); background: var(--ccu-surface-subtle); } - .combined-config-card h3 { + .combined-config-card h4, + .sc-config h4 { margin: 0 0 var(--ccu-space-3); font-size: 13px; } - .combined-config-card .sc-field > span { + .sharing-controls-panel .sc-field > span { color: var(--vscode-foreground); } .combined-heatmap-controls { @@ -9861,7 +10093,7 @@ export class UsageWebviewProvider { } .combined-privacy-preview { padding: var(--ccu-space-2) var(--ccu-space-3); - border-left: 3px solid var(--vscode-testing-iconPassed, #2ea043); + border-left: 3px solid var(--vscode-testing-iconPassed); border-radius: var(--ccu-radius-control); background: var(--ccu-surface-muted); color: var(--vscode-foreground); @@ -9918,7 +10150,10 @@ export class UsageWebviewProvider { max-width: none; height: auto; border-radius: 12px; - box-shadow: 0 8px 26px rgba(24, 16, 36, 0.18); + } + .claude-heatmap-actions { + padding-top: var(--ccu-space-3); + border-top: 1px solid var(--ccu-border); } /* Clickable month bars in the all-time composition chart (drill to daily). */ @@ -10747,6 +10982,10 @@ export class UsageWebviewProvider { width: auto; max-width: none; } + .claude-share-card-presentation .share-preview svg { + width: 1200px; + min-width: 1200px; + } .action-card-head { align-items: flex-start; flex-wrap: wrap; @@ -11113,8 +11352,20 @@ document.addEventListener('toggle', function(e){ }, true); // Restore the active tab from localStorage before first paint, so a reload can't change it. +function ccuSharingCommandPending() { + return !!__sharingTemplateCommand; +} function restoreActiveTab() { try { + if ( + ccuSharingCommandPending() && + document.getElementById('tab-all') && + document.getElementById('all') + ) { + showTab('all', true); + try { localStorage.setItem('ccu.activeTab', 'all'); } catch (e) {} + return; + } var t = localStorage.getItem('ccu.activeTab'); if (t && document.getElementById('tab-' + t) && document.getElementById(t)) { showTab(t, true); @@ -11136,7 +11387,9 @@ function restoreUi() { initializeHourlyOverviewSelections(); restoreHourlyOverviewSelections(); initializeStatusRegions(); + restoreSharingTemplate(); restoreCombinedHeatmapConfig(); + restoreShareCardDraft(); restoreProjectMatrixState(); requestLocalDataInventoryForVisibleSettings(); restoreScrollPosition(); @@ -11211,6 +11464,8 @@ const __currencyDisplay = ${JSON.stringify({ decimalPlaces: I18n.getDecimalPlaces(), })}; const __combinedHeatmapCopy = ${JSON.stringify(combinedHeatmapUiCopy(I18n.getLocale()))}; +const __sharingWorkspaceCopy = ${JSON.stringify(I18n.sharingWorkspace)}; +const __sharingTemplateCommand = ${JSON.stringify(this.sharingCommandIntents.pending() ?? null)}; const __localDataCopy = ${JSON.stringify(localDataUiCopy(I18n.getLocale()))}; const __dateOpts = (extra) => { const opts = Object.assign({}, extra || {}); @@ -11278,6 +11533,56 @@ function showProvider(provider, tab) { ${getProviderNavClientScript()} +function selectSharingTemplate(template, save) { + if (['combinedHeatmap', 'claudeShareCard', 'claudeHeatmap'].indexOf(template) < 0) { return; } + var selector = document.getElementById('sharingTemplate'); + var option = selector ? selector.querySelector('option[value="' + template + '"]') : null; + if (!selector || !option || option.disabled) { return; } + selector.value = template; + document.querySelectorAll('[data-sharing-presentation]').forEach(function(presentation) { + presentation.hidden = presentation.getAttribute('data-sharing-presentation') !== template; + }); + if (save) { + try { localStorage.setItem('ccu.sharing.template', template); } catch (e) {} + vscode.postMessage({ command: 'sharingTemplateChanged', template: template }); + } +} +function restoreSharingTemplate() { + var selector = document.getElementById('sharingTemplate'); + if (!selector) { return; } + var stored = ''; + var commandPending = ccuSharingCommandPending(); + try { + if (commandPending) { + stored = __sharingTemplateCommand.template; + localStorage.setItem('ccu.sharing.template', stored); + } else { + stored = localStorage.getItem('ccu.sharing.template') || ''; + } + } catch (e) { + if (commandPending) { stored = __sharingTemplateCommand.template; } + } + var option = stored ? selector.querySelector('option[value="' + stored + '"]') : null; + var initial = selector.value; + var resolved = option && !option.disabled ? stored : selector.value; + selectSharingTemplate(resolved, false); + if (!commandPending && resolved !== initial) { + vscode.postMessage({ command: 'sharingTemplateChanged', template: resolved }); + } + if (commandPending) { + vscode.postMessage({ + command: 'sharingTemplateCommandAck', + revision: __sharingTemplateCommand.revision + }); + } +} +function selectDefaultSharingTemplate() { + var selector = document.getElementById('sharingTemplate'); + if (!selector) { return; } + var combined = selector.querySelector('option[value="combinedHeatmap"]'); + selectSharingTemplate(combined && !combined.disabled ? 'combinedHeatmap' : 'claudeShareCard', false); +} + function scReadConfig() { var rangeEl = document.getElementById('scRange'); var scopeEl = document.getElementById('scScope'); @@ -11286,29 +11591,98 @@ function scReadConfig() { 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 }; } +// Project paths and session IDs are valid in-memory selector values, but they +// must not become durable UI preferences. Keep the full draft only for the +// lifetime of this Webview and persist the non-identifying controls. +var __ccuShareCardDraft = null; +function scPersistentDraftConfig(cfg) { + return { + range: cfg && cfg.range, + scope: 'all', + theme: cfg && cfg.theme, + fullNumbers: !!(cfg && cfg.fullNumbers), + sections: cfg && cfg.sections + }; +} +function scWritePersistentDraft(cfg) { + try { + localStorage.setItem( + 'ccu.sharing.shareCardDraft', + JSON.stringify(scPersistentDraftConfig(cfg)) + ); + } catch (e) {} +} +function scSaveDraft() { + __ccuShareCardDraft = scReadConfig(); + scWritePersistentDraft(__ccuShareCardDraft); +} +function scSetSelectValue(id, value) { + var select = document.getElementById(id); + if (!select || typeof value !== 'string') { return; } + for (var i = 0; i < select.options.length; i++) { + if (select.options[i].value === value) { select.value = value; return; } + } +} +function restoreShareCardDraft() { + var controls = document.querySelector('.claude-share-card-presentation .sc-config'); + if (!controls) { return; } + controls.addEventListener('change', scSaveDraft); + try { + var cfg = __ccuShareCardDraft; + if (!cfg) { + var raw = localStorage.getItem('ccu.sharing.shareCardDraft'); + if (!raw || raw.length > 4096) { return; } + cfg = JSON.parse(raw); + // Migrate older drafts that persisted a raw project path or session ID. + __ccuShareCardDraft = cfg; + scWritePersistentDraft(cfg); + } + if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) { return; } + scSetSelectValue('scRange', cfg.range); + scSetSelectValue('scScope', cfg.scope); + scSetSelectValue('scTheme', cfg.theme); + var full = document.getElementById('scFull'); + if (full && typeof cfg.fullNumbers === 'boolean') { full.checked = cfg.fullNumbers; } + if (cfg.sections && typeof cfg.sections === 'object' && !Array.isArray(cfg.sections)) { + controls.querySelectorAll('.sc-sec').forEach(function(box) { + var key = box.getAttribute('data-sec'); + if (key && typeof cfg.sections[key] === 'boolean') { box.checked = cfg.sections[key]; } + }); + } + } catch (e) {} +} +function scApplyDefaultControls() { + scSetSelectValue('scRange', 'last30'); + scSetSelectValue('scScope', 'all'); + scSetSelectValue('scTheme', 'claudeClassic'); + var full = document.getElementById('scFull'); + if (full) { full.checked = false; } + var optional = { messages: true, projectName: true }; + document.querySelectorAll('.claude-share-card-presentation .sc-sec').forEach(function(box) { + box.checked = !optional[box.getAttribute('data-sec')]; + }); +} function generateShareCard() { var cfg = scReadConfig(); + scSaveDraft(); 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 }); + if (prev) { prev.innerHTML = '

' + __sharingWorkspaceCopy.generating + '

'; } + vscode.postMessage({ command: 'buildShareCard', range: cfg.range, scope: cfg.scope, theme: cfg.theme, 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 }); + scSaveDraft(); + vscode.postMessage({ command: 'exportShareCard', range: cfg.range, scope: cfg.scope, theme: cfg.theme, fullNumbers: cfg.fullNumbers, sections: cfg.sections }); } function exportHeatmap() { vscode.postMessage({ command: 'exportHeatmap' }); @@ -11410,39 +11784,10 @@ function copyCombinedHeatmapMarkdown() { vscode.postMessage({ command: 'copyCombinedHeatmapMarkdown', title: config.title, range: config.range, intensityMode: config.intensityMode, palette: config.palette, customAccent: config.customAccent }); } function resetCombinedHeatmapPreferences() { - var title = document.getElementById('combinedHeatmapTitle'); - var range = document.getElementById('combinedHeatmapRange'); - var intensityMode = document.getElementById('combinedHeatmapIntensityMode'); - var privacy = document.getElementById('combinedHeatmapPrivacy'); - var customAccent = document.getElementById('combinedHeatmapCustomAccent'); - try { - localStorage.removeItem('ccu.combinedHeatmap.title'); - localStorage.removeItem('ccu.combinedHeatmap.range'); - localStorage.removeItem('ccu.combinedHeatmap.intensityMode'); - localStorage.removeItem('ccu.combinedHeatmap.privacyPreview'); - localStorage.removeItem('ccu.combinedHeatmap.palette'); - localStorage.removeItem('ccu.combinedHeatmap.customAccent'); - } catch (e) {} - if (title) { title.value = __combinedHeatmapCopy.defaultTitle; } - if (range) { range.value = 'year'; } - if (intensityMode) { intensityMode.value = 'quantile'; } - if (privacy) { privacy.checked = true; } - var defaultPalette = document.querySelector('input[name="combinedHeatmapPalette"][value="academicViolet"]'); - if (defaultPalette) { defaultPalette.checked = true; } - if (customAccent) { customAccent.value = '#4f2f87'; } - toggleCombinedCustomAccent(); - toggleCombinedHeatmapPrivacy(false); + __ccuDirectSharingResetPending = true; var status = document.getElementById('combinedHeatmapStatus'); if (status) { status.textContent = __combinedHeatmapCopy.resetSharing + '…'; } - vscode.postMessage({ - command: 'previewCombinedHeatmap', - title: __combinedHeatmapCopy.defaultTitle, - range: 'year', - intensityMode: 'quantile', - palette: 'academicViolet', - customAccent: '#4f2f87' - }); - vscode.postMessage({ command: 'resetCombinedHeatmapPreferences' }); + runLocalDataAction('reset-sharing-preferences'); } function refresh() { vscode.postMessage({ command: 'refresh' }); @@ -11469,6 +11814,30 @@ function setSetting(key, value, type) { vscode.postMessage({ command: 'updateSetting', key: key, value: value }); } +function setQuotaFormatPreset(select) { + var input = document.getElementById('set_statusBarQuotaFormat'); + var custom = document.getElementById('set_statusBarQuotaFormat_custom'); + if (!input || !custom) { return; } + var isCustom = select.value === 'custom'; + custom.hidden = !isCustom; + if (isCustom) { + if (!input.value) { input.value = select.dataset.defaultTemplate || ''; } + setSetting('statusBarQuotaFormat', input.value, 'string'); + input.focus(); + } else { + setSetting('statusBarQuotaFormat', select.value, 'string'); + } +} + +function setQuotaFormatCustom(input) { + var preset = document.getElementById('set_statusBarQuotaFormat_preset'); + // Leaving the input can fire its change event after a preset was selected. + // Do not let a stale custom draft overwrite that deliberate choice. + if (preset && preset.value === 'custom') { + setSetting('statusBarQuotaFormat', input.value, 'string'); + } +} + function resetAllSettings(keys) { vscode.postMessage({ command: 'resetAllSettings', keys: Array.isArray(keys) ? keys : undefined }); } @@ -11480,6 +11849,8 @@ var __ccuUiPreferenceKeys = [ 'ccu.sessionModel' ]; var __ccuSharingPreferenceKeys = [ + 'ccu.sharing.template', + 'ccu.sharing.shareCardDraft', 'ccu.combinedHeatmap.title', 'ccu.combinedHeatmap.range', 'ccu.combinedHeatmap.intensityMode', @@ -11487,17 +11858,20 @@ var __ccuSharingPreferenceKeys = [ 'ccu.combinedHeatmap.palette', 'ccu.combinedHeatmap.customAccent' ]; - -function ccuCountPresentLocalStorageKeys(keys) { +function ccuCountPresentStorageKeys(storage, keys) { var count = 0; try { keys.forEach(function(key) { - if (localStorage.getItem(key) !== null) { count += 1; } + if (storage.getItem(key) !== null) { count += 1; } }); } catch (e) {} return count; } +function ccuCountPresentLocalStorageKeys(keys) { + try { return ccuCountPresentStorageKeys(localStorage, keys); } catch (e) { return 0; } +} + function ccuLocalDataClientSummary() { var state = ccuReadUiState(); return { @@ -11533,15 +11907,48 @@ function runLocalDataAction(action) { }); } -function ccuResetLocalStorageKeys(keys) { +function ccuResetStorageKeys(storage, keys) { try { - keys.forEach(function(key) { localStorage.removeItem(key); }); - return keys.every(function(key) { return localStorage.getItem(key) === null; }); + keys.forEach(function(key) { storage.removeItem(key); }); + return keys.every(function(key) { return storage.getItem(key) === null; }); } catch (e) { return false; } } +function ccuResetLocalStorageKeys(keys) { + try { return ccuResetStorageKeys(localStorage, keys); } catch (e) { return false; } +} + +var __ccuDirectSharingResetPending = false; + +function ccuApplyDefaultSharingControls() { + var title = document.getElementById('combinedHeatmapTitle'); + var range = document.getElementById('combinedHeatmapRange'); + var intensityMode = document.getElementById('combinedHeatmapIntensityMode'); + var privacy = document.getElementById('combinedHeatmapPrivacy'); + var customAccent = document.getElementById('combinedHeatmapCustomAccent'); + if (title) { title.value = __combinedHeatmapCopy.defaultTitle; } + if (range) { range.value = 'year'; } + if (intensityMode) { intensityMode.value = 'quantile'; } + if (privacy) { privacy.checked = true; } + var defaultPalette = document.querySelector('input[name="combinedHeatmapPalette"][value="academicViolet"]'); + if (defaultPalette) { defaultPalette.checked = true; } + if (customAccent) { customAccent.value = '#4f2f87'; } + selectDefaultSharingTemplate(); + scApplyDefaultControls(); + toggleCombinedCustomAccent(); + toggleCombinedHeatmapPrivacy(false); + vscode.postMessage({ + command: 'previewCombinedHeatmap', + title: __combinedHeatmapCopy.defaultTitle, + range: 'year', + intensityMode: 'quantile', + palette: 'academicViolet', + customAccent: '#4f2f87' + }); +} + function ccuApplyLocalDataClientAction(action) { var ok = true; if (action === 'reset-ui-state' || action === 'clear-all-client-state') { @@ -11555,8 +11962,17 @@ function ccuApplyLocalDataClientAction(action) { } } if (action === 'reset-sharing-preferences' || action === 'clear-all-client-state') { - ok = ccuResetLocalStorageKeys(__ccuSharingPreferenceKeys) && ok; - restoreCombinedHeatmapConfig(); + var sharingStorageReset = ccuResetLocalStorageKeys(__ccuSharingPreferenceKeys); + ok = sharingStorageReset && ok; + if (sharingStorageReset) { + ccuApplyDefaultSharingControls(); + var sharingMasterSwitch = document.getElementById('set_enableShareCard'); + if (sharingMasterSwitch && sharingMasterSwitch.type === 'checkbox') { + // Host-side reset has already restored this sole visible switch to its + // catalog default. Reflect that immediately without posting a new write. + sharingMasterSwitch.checked = true; + } + } } return ok; } @@ -12202,6 +12618,20 @@ function restoreClaudeDrilldownDetails() { toggleMonthlyDetail(date, true); } }); + restoreClaudeAllTimeHourlyDetails(document); + } catch (e) {} +} + +function restoreClaudeAllTimeHourlyDetails(root) { + try { + const scope = root || document; + const saved = ccuReadUiState().claudeDrilldownDetails || {}; + scope.querySelectorAll('[data-claude-alltime-hourly-detail-row]').forEach(function(row) { + const date = row.getAttribute('data-date'); + if (date && saved[claudeDrilldownStateKey(row, 'hourly')] === date) { + toggleClaudeAllTimeHourlyDetail(date, row, true); + } + }); } catch (e) {} } @@ -12280,6 +12710,63 @@ function toggleHourlyDetail(date, restoring) { } } +function claudeAllTimeHourlyElements(date, source) { + const sourceScope = source && source.closest + ? source.closest('[data-claude-alltime-daily]') + : null; + const searchScope = sourceScope || document.querySelector('#all [data-claude-alltime-daily]'); + if (!searchScope) { return {}; } + const detailRow = searchScope.querySelector( + '[data-claude-alltime-hourly-detail-row][data-date="' + date + '"]', + ); + return { + detailRow: detailRow, + button: searchScope.querySelector( + '[data-claude-alltime-hourly-toggle][data-date="' + date + '"]', + ), + container: detailRow + ? detailRow.querySelector('.hourly-detail-container[id]') + : null, + chartBar: searchScope.querySelector( + '.hc-col[data-date="' + date + '"] .chart-bar.clickable', + ), + }; +} + +function toggleClaudeAllTimeHourlyDetail(date, source, restoring) { + try { + const elements = claudeAllTimeHourlyElements(date, source); + const detailRow = elements.detailRow; + const button = elements.button; + const container = elements.container; + const chartBar = elements.chartBar; + if (!detailRow || !button || !container) { return; } + const isExpanded = detailRow.style.display !== 'none' && detailRow.style.display !== ''; + if (!isExpanded) { + closeAllHourlyDetails(); + detailRow.style.display = 'table-row'; + button.classList.add('expanded'); + setChartDrilldownExpanded('claude-alltime-hourly-detail-' + date, true); + if (chartBar) { chartBar.classList.add('selected'); } + persistClaudeDrilldown(detailRow, 'hourly', date); + if (!container.dataset.loaded) { + installMaterializedClaudeHourlyDetail(container, date); + } + if (!restoring) { + try { detailRow.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (e) {} + } + } else { + detailRow.style.display = 'none'; + button.classList.remove('expanded'); + setChartDrilldownExpanded('claude-alltime-hourly-detail-' + date, false); + if (chartBar) { chartBar.classList.remove('selected'); } + persistClaudeDrilldown(detailRow, 'hourly', ''); + } + } catch (error) { + console.error('Error in toggleClaudeAllTimeHourlyDetail:', error); + } +} + function codexHourlyDetailElements(date, source) { const sourceScope = source && source.closest ? source.closest('[data-codex-alltime-daily], [data-codex-last30-daily]') @@ -12409,6 +12896,9 @@ function closeAllHourlyDetails() { document.querySelectorAll('[aria-controls^="hourly-detail-"]').forEach(function(control) { control.setAttribute('aria-expanded', 'false'); }); + document.querySelectorAll('[aria-controls^="claude-alltime-hourly-detail-"]').forEach(function(control) { + control.setAttribute('aria-expanded', 'false'); + }); } @@ -12680,7 +13170,7 @@ window.addEventListener('message', async function(event) { } if (message.command === 'dashboardDataPatch') { - ccuApplyDashboardDataPatch(message); + ccuQueueDashboardDataPatch(message); return; } @@ -12743,6 +13233,17 @@ window.addEventListener('message', async function(event) { ? localDataResult.message : __localDataCopy.unavailable; } + if (__ccuDirectSharingResetPending) { + var directSharingResetStatus = document.getElementById('combinedHeatmapStatus'); + if (directSharingResetStatus) { + directSharingResetStatus.textContent = typeof localDataResult.message === 'string' + ? localDataResult.message + : (localDataResult.ok === true + ? __combinedHeatmapCopy.resetComplete + : __combinedHeatmapCopy.resetFailed); + } + __ccuDirectSharingResetPending = false; + } requestLocalDataInventory(); } @@ -12950,7 +13451,7 @@ window.addEventListener('message', async function(event) { if (message.error) { var shareCardError = document.createElement('p'); shareCardError.className = 'table-hint'; - shareCardError.textContent = 'Could not build the card: ' + String(message.error); + shareCardError.textContent = __sharingWorkspaceCopy.cardBuildFailed + ' ' + String(message.error); prev.replaceChildren(shareCardError); } else { prev.innerHTML = message.svg || ''; @@ -12986,15 +13487,6 @@ window.addEventListener('message', async function(event) { } } - if (message.command === 'combinedHeatmapPreferencesReset') { - var resetStatus = document.getElementById('combinedHeatmapStatus'); - if (resetStatus) { - resetStatus.textContent = message.ok - ? __combinedHeatmapCopy.resetComplete - : __combinedHeatmapCopy.resetFailed; - } - } - if (message.command === 'dailyDataResponse') { const container = document.getElementById('monthly-detail-' + message.month); const responseProvider = message.provider === 'codex' ? 'codex' : 'claude'; @@ -13009,6 +13501,7 @@ window.addEventListener('message', async function(event) { restoreChartMetrics(container); initializeChartDrilldowns(container); restoreCodexHourlyDetails(); + restoreClaudeAllTimeHourlyDetails(container); initializeStatusRegions(container); } } @@ -13093,6 +13586,8 @@ function chartDrilldownInfo(element) { var containingTab = element && element.closest ? element.closest('.tab-content') : null; var kind = element.closest('[data-codex-alltime-daily]') ? 'codex-alltime-hourly' + : element.closest('[data-claude-alltime-daily]') + ? 'claude-alltime-hourly' : containingTab && containingTab.id === 'all' ? 'monthly' : element.closest('[data-codex-last30-daily]') @@ -13100,6 +13595,8 @@ function chartDrilldownInfo(element) { : 'hourly'; var prefix = kind === 'monthly' ? 'monthly-detail-' + : kind === 'claude-alltime-hourly' + ? 'claude-alltime-hourly-detail-' : kind === 'codex-alltime-hourly' ? 'codex-alltime-hourly-detail-' : kind === 'codex-hourly' @@ -13116,6 +13613,9 @@ function activateChartDrilldown(element) { var info = chartDrilldownInfo(element); if (!info) { return; } if (info.kind === 'monthly') { toggleMonthlyDetail(info.date); } + else if (info.kind === 'claude-alltime-hourly') { + toggleClaudeAllTimeHourlyDetail(info.date, element); + } else if (info.kind === 'codex-hourly' || info.kind === 'codex-alltime-hourly') { toggleCodexHourlyDetail(info.date, element); } @@ -13443,7 +13943,9 @@ function updateMainChart(metric, container) { // Hourly chart: tooltip shows the value only (the hour is on the x-axis). bar.title = valueWithHelp; } else if (date) { - bar.title = ccuFormatUsageDateKey(date) + ': ' + valueWithHelp; + // Claude's all-time month key includes its sentinel first day. The chart + // scope, not the presence of a day component, determines its label. + bar.title = ccuFormatUsageDateKey(date, null, !!bar.closest('#allTimeChart')) + ': ' + valueWithHelp; } // Drill-down bars keep their accessible name synchronized with the // currently selected metric; otherwise a keyboard user would continue to @@ -13662,7 +14164,12 @@ function renderDailyData(dailyData, monthDate) { return '
${I18n.t.popup.noDataMessage}
'; } - let html = '
'; + const expandableDays = new Set(dailyData + .filter(function(item) { + return Object.prototype.hasOwnProperty.call(__claudeLast30HoursByDay, item.date); + }) + .map(function(item) { return item.date; })); + let html = '
'; html += '

' + ccuFormatUsageDateKey(monthDate, null, true) + ' ${I18n.t.popup.dailyBreakdown}

'; html += '
'; @@ -13676,7 +14183,7 @@ function renderDailyData(dailyData, monthDate) { // hc-wrap is self-contained (own Y-axis + scroll); no chart-container. html += '
'; - html += renderDailyChart(dailyData, 'cost'); + html += renderDailyChart(dailyData, 'cost', expandableDays); html += '
'; // Per-day token composition for this month (the drill-down of the all-time @@ -13697,12 +14204,14 @@ function renderDailyData(dailyData, monthDate) { html += '${I18n.t.popup.cacheRead}'; html += '${I18n.t.popup.cacheHitRate}'; html += '${I18n.t.popup.messages}'; + html += ''; html += ''; dailyData.forEach(function(item) { const formattedDate = ccuFormatUsageDateKey(item.date, { month: 'numeric', day: 'numeric' }); - html += ''; + const canExpand = expandableDays.has(item.date); + html += ''; html += '' + formattedDate + ''; html += '' + formatValue(item.data.totalCost, 'cost') + ''; html += '' + item.data.totalInputTokens.toLocaleString(__locale) + ''; @@ -13711,7 +14220,24 @@ function renderDailyData(dailyData, monthDate) { html += '' + item.data.totalCacheReadTokens.toLocaleString(__locale) + ''; html += '' + cacheHitPct(item.data) + ''; html += '' + item.data.messageCount.toLocaleString(__locale) + ''; + html += ''; + if (canExpand) { + html += ''; + } + html += ''; html += ''; + if (canExpand) { + html += '' + + '
' + + '
${I18n.t.statusBar.loading}
'; + } }); html += '
'; @@ -13765,7 +14291,10 @@ function griddedChart(items, metric, opts) { inner = seg(cb.input, 'seg-input') + seg(cb.cacheRead, 'seg-cache-read') + seg(cb.cacheWrite, 'seg-cache-creation') + seg(cb.output, 'seg-output'); } - if (opts.clickable) { cls += ' clickable'; } + const clickable = typeof opts.clickable === 'function' + ? opts.clickable(it) + : opts.clickable; + if (clickable) { cls += ' clickable'; } const visibleValue = opts.hideZeroLabels && value === 0 ? '' @@ -13797,14 +14326,14 @@ function griddedChart(items, metric, opts) { '
'; } -function renderDailyChart(dailyData, metric) { +function renderDailyChart(dailyData, metric, expandableDays) { // Parse 'YYYY-MM-DD' textually: new Date('YYYY-MM-DD') is interpreted as // UTC midnight, so getMonth()/getDate() shift back a day in negative-UTC // timezones. String parts are timezone-proof. function parts(dateStr) { return dateStr.split('-').map(Number); } return griddedChart(dailyData, metric, { keyName: 'date', - clickable: false, + clickable: function(it) { return !!(expandableDays && expandableDays.has(it.date)); }, // Show month/day (not just the day number) so the axis isn't ambiguous. getLabel: function(it) { var p = parts(it.date); return p[1] + '/' + p[2]; }, getTitle: function(it, v) { diff --git a/tests/perf/measure-large-claude-index.mjs b/tests/perf/measure-large-claude-index.mjs new file mode 100644 index 00000000..925fcc0f --- /dev/null +++ b/tests/perf/measure-large-claude-index.mjs @@ -0,0 +1,195 @@ +// Opt-in local diagnostic for a large synthetic Claude transcript history. +// It never reads the user's Claude profile. The fixture is removed on exit. +// Run npm run compile first. For the retained-heap gate, use: +// node --expose-gc tests/perf/measure-large-claude-index.mjs --gib 2.4 --files 645 --analyze-content [--assistant-payload] +// Content analysis is off by default to isolate usage-index I/O. Use the +// opt-in flag to measure the more expensive default-on production path. +import assert from 'node:assert/strict'; +import { appendFile, mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const { createClaudeUsageIndex, updateClaudeUsageIndex } = require(resolve(scriptDirectory, '../../out/claudeIncrementalIndex.js')); + +function option(name, fallback) { + const at = process.argv.indexOf(name); + if (at < 0) return fallback; + const parsed = Number(process.argv[at + 1]); + if (!Number.isFinite(parsed)) throw new Error(`${name} needs a finite number`); + return parsed; +} + +const targetGiB = option('--gib', 2.4); +const fileCount = option('--files', 645); +const analyzeContent = process.argv.includes('--analyze-content'); +const assistantPayload = process.argv.includes('--assistant-payload'); +if (analyzeContent && typeof global.gc !== 'function') { + throw new Error('Content-analysis memory gate requires node --expose-gc'); +} +if (targetGiB <= 0 || targetGiB > 3 || !Number.isInteger(fileCount) || fileCount < 8 || fileCount > 1000) { + throw new Error('Expected 0 < --gib <= 3 and 8 <= --files <= 1000'); +} + +const timestamp = new Date().toISOString(); +const payload = 'synthetic-only-'.repeat(17_000); // ~255 KiB; safely below the 1 MiB line cap. +const fillerLine = `${JSON.stringify(assistantPayload ? { + type: 'assistant', timestamp, cwd: '/fixture/large-history', + message: { + role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'text', text: payload }], + usage: { input_tokens: 10, output_tokens: 1 }, + }, +} : { + type: 'user', timestamp, cwd: '/fixture/large-history', + message: { role: 'user', content: payload }, +})}\n`; +const fillerBytes = Buffer.byteLength(fillerLine); +const seedLine = (index) => `${JSON.stringify({ + type: 'assistant', timestamp, cwd: '/fixture/large-history', + requestId: `synthetic-request-${index}`, + message: { + id: `synthetic-message-${index}`, model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'synthetic response' }], + usage: { input_tokens: 10, output_tokens: 1, cache_creation_input_tokens: 2, cache_read_input_tokens: 3 }, + }, +})}\n`; +const seedBytes = Buffer.byteLength(seedLine(0)); +const targetBytes = Math.ceil(targetGiB * 1024 ** 3); +const fillerCount = Math.ceil(Math.max(0, targetBytes - fileCount * seedBytes) / fillerBytes); +const root = await mkdtemp(join(tmpdir(), 'ccu-v240-large-synthetic-')); +const project = join(root, 'projects', '-fixture-large-history'); +const files = []; +let peakRss = process.memoryUsage().rss; +const sampler = setInterval(() => { + peakRss = Math.max(peakRss, process.memoryUsage().rss); +}, 50); + +async function measured(label, work) { + const start = performance.now(); + const result = await work(); + const memory = process.memoryUsage(); + return { + label, + elapsedMs: Math.round(performance.now() - start), + rssBytes: memory.rss, + heapUsedBytes: memory.heapUsed, + result, + }; +} + +try { + await mkdir(project, { recursive: true }); + let writtenBytes = 0; + const generated = await measured('generate', async () => { + for (let index = 0; index < fileCount; index += 1) { + const file = join(project, `session-${String(index).padStart(4, '0')}.jsonl`); + files.push(file); + const copies = Math.floor(fillerCount / fileCount) + (index < fillerCount % fileCount ? 1 : 0); + const body = seedLine(index) + fillerLine.repeat(copies); + writtenBytes += Buffer.byteLength(body); + await writeFile(file, body, 'utf8'); + if ((index + 1) % 100 === 0) process.stdout.write(`generated ${index + 1}/${fileCount} files\n`); + } + }); + assert.ok(writtenBytes >= targetBytes, 'fixture must meet the declared byte target'); + process.stdout.write(`fixture bytes=${writtenBytes} files=${fileCount} contentAnalysis=${analyzeContent} assistantPayload=${assistantPayload}\n`); + + const cold = await measured('cold', () => updateClaudeUsageIndex(createClaudeUsageIndex(), root, { + analyzeContent, + })); + assert.equal(cold.result.diagnostics.bodyReads, fileCount); + assert.ok(cold.result.records.length >= fileCount); + const coldRecords = cold.result.records.length; + const unchanged = await measured('unchanged', () => updateClaudeUsageIndex(cold.result.index, root, { + analyzeContent, + })); + assert.equal(unchanged.result.diagnostics.bodyReads, 0); + assert.equal(unchanged.result.diagnostics.bytesRead, 0); + + let appendedBytes = 0; + const tailFiles = fileCount >= 645 ? [7, 91, 183, 275, 367, 459, 644] : [0, 1, 2, 3, 4, 5, 6]; + for (const index of tailFiles) { + const line = seedLine(fileCount + index); + appendedBytes += Buffer.byteLength(line); + await appendFile(files[index], line, 'utf8'); + } + const newLine = seedLine(fileCount * 2); + await writeFile(join(project, 'session-new.jsonl'), newLine, 'utf8'); + const warm = await measured('seven-tails-plus-new', () => updateClaudeUsageIndex(unchanged.result.index, root, { + analyzeContent, + })); + assert.equal(warm.result.diagnostics.bodyReads, 8); + // The first-timestamp probe may read some/all of the new file before its + // one normal body scan; keep the bound without pinning that probe's size. + assert.ok(warm.result.diagnostics.bytesRead >= appendedBytes + Buffer.byteLength(newLine)); + assert.ok(warm.result.diagnostics.bytesRead <= appendedBytes + 2 * Buffer.byteLength(newLine)); + assert.equal(warm.result.records.length, coldRecords + 8); + + const replacementLine = seedLine(fileCount * 3); + await writeFile(files[0], replacementLine, 'utf8'); + const truncated = await measured('truncate-one-file', () => updateClaudeUsageIndex(warm.result.index, root, { + analyzeContent, + })); + // Content analysis currently falls back to a full ordered rebuild on an + // unsafe source mutation so cross-file UUID ownership stays correct. Do not + // lock that expensive implementation in as a desired future contract. + assert.ok(truncated.result.diagnostics.bodyReads >= 1 && + truncated.result.diagnostics.bodyReads <= fileCount + 1); + if (!analyzeContent) { + assert.equal(truncated.result.diagnostics.bodyReads, 1); + assert.deepEqual(truncated.result.diagnostics.changed, + { append: 0, rebuild: 1, move: 0, delete: 0 }); + assert.ok(truncated.result.diagnostics.bytesRead <= 4 * Buffer.byteLength(replacementLine), + 'usage-only truncation must not reread untouched large files'); + } + assert.ok(truncated.result.records.length <= warm.result.records.length); + + await unlink(files[1]); + const deleted = await measured('delete-one-file', () => updateClaudeUsageIndex(truncated.result.index, root, { + analyzeContent, + })); + assert.ok(deleted.result.diagnostics.bodyReads <= fileCount); + if (!analyzeContent) { + assert.equal(deleted.result.diagnostics.bodyReads, 0); + assert.equal(deleted.result.diagnostics.bytesRead, 0); + assert.deepEqual(deleted.result.diagnostics.changed, + { append: 0, rebuild: 0, move: 0, delete: 1 }); + } + assert.ok(deleted.result.records.length < truncated.result.records.length); + + const afterGc = typeof global.gc === 'function' + ? await measured('post-forced-gc', async () => { global.gc(); }) + : null; + if (analyzeContent && afterGc) { + assert.ok(afterGc.heapUsedBytes < Math.max(200 * 1024 ** 2, writtenBytes / 2), + 'retained heap must remain below the synthetic transcript memory ceiling'); + } + + process.stdout.write(`${JSON.stringify({ + fixtureBytes: writtenBytes, + files: fileCount, + coldRecords, + contentAnalysis: analyzeContent, + assistantPayload, + peakRssBytes: peakRss, + phases: [ + { label: generated.label, elapsedMs: generated.elapsedMs, + rssBytes: generated.rssBytes, heapUsedBytes: generated.heapUsedBytes }, + ...[cold, unchanged, warm, truncated, deleted].map(({ label, elapsedMs, rssBytes, heapUsedBytes, result }) => ({ + label, elapsedMs, rssBytes, heapUsedBytes, + bodyReads: result.diagnostics.bodyReads, + bytesRead: result.diagnostics.bytesRead, + linesParsed: result.diagnostics.linesParsed, + changed: result.diagnostics.changed, + })), + ...(afterGc ? [{ label: afterGc.label, elapsedMs: afterGc.elapsedMs, + rssBytes: afterGc.rssBytes, heapUsedBytes: afterGc.heapUsedBytes }] : []), + ], + })}\n`); +} finally { + clearInterval(sampler); + await rm(root, { recursive: true, force: true }); +} diff --git a/tests/perf/measure-large-codex-index.mjs b/tests/perf/measure-large-codex-index.mjs new file mode 100644 index 00000000..b2f2779c --- /dev/null +++ b/tests/perf/measure-large-codex-index.mjs @@ -0,0 +1,203 @@ +// Opt-in, synthetic-only Codex index diagnostic. No real Codex home is read. +// Run npm run compile first; for example: +// node --expose-gc tests/perf/measure-large-codex-index.mjs --gib 1.4 --files 645 --line-kib 16 +import assert from 'node:assert/strict'; +import { appendFile, mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const directory = dirname(fileURLToPath(import.meta.url)); +const { createEmptyCodexIndex, updateCodexIndex, + CODEX_REFRESH_BACKFILL_MAX_BYTES, + CODEX_REFRESH_BACKFILL_MAX_FILE_PASSES } = require(resolve(directory, '../../out/providers/codex/codexIndex.js')); +const { scanCodexManifest } = require(resolve(directory, '../../out/providers/codex/codexManifest.js')); +const { defaultCodexJsonlReader } = require(resolve(directory, '../../out/providers/codex/codexJsonlScanner.js')); + +function option(name, fallback) { + const position = process.argv.indexOf(name); + if (position < 0) return fallback; + const value = Number(process.argv[position + 1]); + if (!Number.isFinite(value)) throw new Error(`${name} requires a finite number`); + return value; +} + +const gib = option('--gib', 1.4); +const fileCount = option('--files', 645); +const lineKiB = option('--line-kib', 16); +if (gib <= 0 || gib > 3 || !Number.isInteger(fileCount) || fileCount < 8 || fileCount > 1000) { + throw new Error('Expected 0 < --gib <= 3 and 8 <= --files <= 1000'); +} +if (lineKiB < 1 || lineKiB > 256) { + throw new Error('Expected 1 <= --line-kib <= 256'); +} +const targetBytes = Math.ceil(gib * 1024 ** 3); +const timestamp = '2026-07-20T00:01:00.000Z'; +const now = Date.parse('2026-07-21T04:00:00.000Z'); +const salt = 'synthetic-test-salt'; +const timeZone = 'UTC'; +const filler = `${JSON.stringify({ timestamp, type: 'event_msg', payload: { + type: 'user_message', message: 'synthetic-only-'.repeat( + Math.floor(lineKiB * 1024 / Buffer.byteLength('synthetic-only-')), + ), +} })}\n`; +const fillerBytes = Buffer.byteLength(filler); +function seed(index, input = 100, output = 20) { + return [ + { timestamp, type: 'session_meta', payload: { + id: `synthetic-session-${index}`, cwd: '/fixture/codex-history', + } }, + { timestamp, type: 'turn_context', payload: { model: 'gpt-5.6-sol', effort: 'high' } }, + { timestamp, type: 'event_msg', payload: { type: 'token_count', info: { + total_token_usage: { + input_tokens: input, cached_input_tokens: Math.floor(input / 2), + output_tokens: output, reasoning_output_tokens: Math.floor(output / 2), + total_tokens: input + output, + }, + } } }, + ].map((row) => JSON.stringify(row)).join('\n') + '\n'; +} +function tail(input = 150, output = 30) { + return `${JSON.stringify({ timestamp, type: 'event_msg', payload: { + type: 'token_count', info: { total_token_usage: { + input_tokens: input, cached_input_tokens: Math.floor(input / 2), + output_tokens: output, reasoning_output_tokens: Math.floor(output / 2), + total_tokens: input + output, + } }, + } })}\n`; +} + +const root = await mkdtemp(join(tmpdir(), 'ccu-v240-codex-synthetic-')); +const sessions = join(root, 'sessions'); +const files = []; +let peakRssBytes = process.memoryUsage().rss; +const sampler = setInterval(() => { + peakRssBytes = Math.max(peakRssBytes, process.memoryUsage().rss); +}, 50); +const phases = []; +function instrumentedIo() { + const reads = []; + return { + reads, + async *read(entry, start, end) { + let bytes = 0; + for await (const chunk of defaultCodexJsonlReader.read(entry, start, end)) { + bytes += chunk.length; + yield chunk; + } + reads.push({ fileKey: entry.fileKey, start, bytes }); + }, + }; +} +async function measured(label, work) { + const io = instrumentedIo(); + const start = performance.now(); + const result = await work(io); + const rssBytes = process.memoryUsage().rss; + peakRssBytes = Math.max(peakRssBytes, rssBytes); + const phase = { + label, elapsedMs: Math.round(performance.now() - start), + rssBytes, + bodyReads: result.bodyReads, + readCalls: io.reads.length, + bytesRead: io.reads.reduce((sum, read) => sum + read.bytes, 0), + migration: result.migration, + }; + phases.push(phase); + return { result, phase, io }; +} +const budget = { + maxFilePasses: CODEX_REFRESH_BACKFILL_MAX_FILE_PASSES, + maxBytes: CODEX_REFRESH_BACKFILL_MAX_BYTES, +}; +async function update(index, io) { + const manifest = await scanCodexManifest(root, salt); + return updateCodexIndex(index, manifest, { + salt, timeZone, now: () => now, budget, io, + }); +} + +try { + await mkdir(sessions, { recursive: true }); + const seedBytes = Buffer.byteLength(seed(0)); + const fillerCount = Math.ceil(Math.max(0, targetBytes - fileCount * seedBytes) / fillerBytes); + let writtenBytes = 0; + for (let index = 0; index < fileCount; index += 1) { + const file = join(sessions, `rollout-${String(index).padStart(4, '0')}.jsonl`); + files.push(file); + const copies = Math.floor(fillerCount / fileCount) + + (index < fillerCount % fileCount ? 1 : 0); + const body = seed(index) + filler.repeat(copies); + writtenBytes += Buffer.byteLength(body); + await writeFile(file, body, 'utf8'); + if ((index + 1) % 100 === 0) process.stdout.write(`generated ${index + 1}/${fileCount} files\n`); + } + assert.ok(writtenBytes >= targetBytes); + process.stdout.write(`fixture bytes=${writtenBytes} files=${fileCount} fillerLines=${fillerCount} fillerLineBytes=${fillerBytes}\n`); + + const cold = await measured('cold', (io) => update(createEmptyCodexIndex(timeZone), io)); + assert.equal(cold.result.failedFiles, 0); + assert.equal(cold.result.index.coverage.complete, true); + assert.equal(cold.result.index.coverage.period.allTime.complete, true); + assert.equal(cold.result.index.coverage.hourly?.complete, true); + assert.equal(cold.result.index.aggregate.total.inputTotal, 100 * fileCount); + + const unchanged = await measured('unchanged', (io) => update(cold.result.index, io)); + assert.strictEqual(unchanged.result.index, cold.result.index); + assert.equal(unchanged.phase.readCalls, 0); + assert.equal(unchanged.phase.bytesRead, 0); + + const tailIndexes = fileCount >= 645 ? [7, 91, 183, 275, 367, 459, 644] : [0, 1, 2, 3, 4, 5, 6]; + for (const index of tailIndexes) await appendFile(files[index], tail(), 'utf8'); + const added = join(sessions, 'rollout-new.jsonl'); + await writeFile(added, seed(fileCount), 'utf8'); + const warm = await measured('seven-tails-plus-new', (io) => update(unchanged.result.index, io)); + assert.equal(warm.result.failedFiles, 0); + assert.equal(warm.result.index.coverage.complete, true); + assert.equal(warm.result.index.aggregate.total.inputTotal, 100 * (fileCount + 1) + 50 * 7); + const changedKeys = new Set((await scanCodexManifest(root, salt)).files + .filter((entry) => tailIndexes.some((index) => files[index] === entry.absolutePath) || + entry.absolutePath === added) + .map((entry) => entry.fileKey)); + assert.equal(changedKeys.size, 8); + assert.ok(warm.io.reads.every((read) => changedKeys.has(read.fileKey)), + 'append/new-file refresh must not read untouched JSONL bodies'); + assert.ok(warm.phase.bytesRead < 8 * 1024 * 1024); + + await writeFile(files[0], seed(0, 40, 10), 'utf8'); + const truncated = await measured('truncate-one-file', (io) => update(warm.result.index, io)); + assert.equal(truncated.result.failedFiles, 0); + assert.equal(truncated.result.index.aggregate.total.inputTotal, + warm.result.index.aggregate.total.inputTotal - + (100 + (tailIndexes.includes(0) ? 50 : 0) - 40)); + const truncatedKey = (await scanCodexManifest(root, salt)).files + .find((entry) => entry.absolutePath === files[0]).fileKey; + assert.ok(truncated.io.reads.every((read) => read.fileKey === truncatedKey), + 'one-file truncation must not rescan untouched JSONL bodies'); + + await unlink(files[1]); + const deleted = await measured('delete-one-file', (io) => update(truncated.result.index, io)); + assert.equal(deleted.result.failedFiles, 0); + assert.equal(deleted.result.index.aggregate.total.inputTotal, + truncated.result.index.aggregate.total.inputTotal - + (100 + (tailIndexes.includes(1) ? 50 : 0))); + assert.equal(deleted.phase.bytesRead, 0, + 'one-file deletion must not rescan unaffected JSONL bodies'); + + if (typeof global.gc === 'function') { + global.gc(); + assert.ok(process.memoryUsage().heapUsed < + Math.max(200 * 1024 ** 2, writtenBytes / 2), + 'retained heap must remain below the synthetic corpus memory ceiling'); + } + process.stdout.write(`${JSON.stringify({ + fixtureBytes: writtenBytes, files: fileCount, fillerLines: fillerCount, + fillerLineBytes: fillerBytes, peakRssBytes, + postGcHeapBytes: process.memoryUsage().heapUsed, phases, + })}\n`); +} finally { + clearInterval(sampler); + await rm(root, { recursive: true, force: true }); +} diff --git a/tests/ui/chart-date-labels.spec.mjs b/tests/ui/chart-date-labels.spec.mjs index dc0971ea..dc05df9e 100644 --- a/tests/ui/chart-date-labels.spec.mjs +++ b/tests/ui/chart-date-labels.spec.mjs @@ -15,7 +15,7 @@ test('chart metric switches preserve daily and monthly key labels west of UTC', await page.locator('#tab-all').click(); const monthlyBar = page.locator( - '#all #allTimeChart .hc-col[data-date="2026-07"] .chart-bar', + '#all #allTimeChart .hc-col[data-date="2026-07-01"] .chart-bar', ); await expect(monthlyBar).toHaveAttribute('title', /^July 2026:/); await page.locator('#all .chart-tab[data-metric="inputTokens"]').click(); diff --git a/tests/ui/claude-settings.spec.mjs b/tests/ui/claude-settings.spec.mjs index 9c52c760..37cd572b 100644 --- a/tests/ui/claude-settings.spec.mjs +++ b/tests/ui/claude-settings.spec.mjs @@ -30,3 +30,58 @@ test('Claude pricing backend dropdown posts the selected Bedrock backend', async value: 'aws-bedrock-in-region', }); }); + +test('quota format offers compact presets and reveals template syntax only for custom layouts', async ({ page }) => { + await openClaude(page); + await page.locator('#tab-settings').click(); + + const preset = page.locator('#set_statusBarQuotaFormat_preset'); + const custom = page.locator('#set_statusBarQuotaFormat'); + await expect(preset).toBeVisible(); + await expect(preset).toHaveValue(''); + await expect(custom).toBeHidden(); + await expect(preset.locator('option')).toHaveCount(4); + + await preset.selectOption('{wk.label} {wk.pct}'); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.at(-1))).toEqual({ + command: 'updateSetting', + key: 'statusBarQuotaFormat', + value: '{wk.label} {wk.pct}', + }); + await expect(custom).toBeHidden(); + + await preset.selectOption('custom'); + await expect(custom).toBeVisible(); + await expect(custom).toHaveAttribute('maxlength', '120'); + await custom.fill('{5h.pct} | {wk.pct}'); + await custom.dispatchEvent('change'); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.at(-1))).toEqual({ + command: 'updateSetting', + key: 'statusBarQuotaFormat', + value: '{5h.pct} | {wk.pct}', + }); + + await preset.selectOption(''); + await expect(custom).toBeHidden(); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.at(-1))).toEqual({ + command: 'updateSetting', + key: 'statusBarQuotaFormat', + value: '', + }); +}); + +for (const locale of ['en', 'de-DE', 'zh-TW', 'zh-CN', 'ja', 'ko', 'pt-BR', 'id']) { + test(`${locale} quota format remains usable at narrow width`, async ({ page }) => { + await openClaude(page, { locale, width: 360, height: 800 }); + await page.locator('#tab-settings').click(); + const preset = page.locator('#set_statusBarQuotaFormat_preset'); + await expect(preset).toBeVisible(); + await preset.selectOption('custom'); + await expect(page.locator('#set_statusBarQuotaFormat')).toBeVisible(); + const bounds = await page.locator('.settings-panel').evaluate((element) => ({ + client: element.clientWidth, + scroll: element.scrollWidth, + })); + expect(bounds.scroll).toBeLessThanOrEqual(bounds.client + 1); + }); +} diff --git a/tests/ui/codex-accessibility.spec.mjs b/tests/ui/codex-accessibility.spec.mjs index 6fc03574..7a3ea38c 100644 --- a/tests/ui/codex-accessibility.spec.mjs +++ b/tests/ui/codex-accessibility.spec.mjs @@ -198,7 +198,7 @@ test('shared chart controls expose and update aria-pressed from the keyboard', a for (const scenario of [ { name: 'Claude', label: 'Claude', open: openClaude }, - { name: 'Codex', label: 'Codex Beta', open: openCodex }, + { name: 'Codex', label: 'Codex', open: openCodex }, { name: 'Compare', label: 'Compare', open: openCompare }, ]) { test(`${scenario.name} chart regions have unique provider-qualified accessible names`, async ({ page }) => { diff --git a/tests/ui/codex-interactions.spec.mjs b/tests/ui/codex-interactions.spec.mjs index dd6276aa..5961d358 100644 --- a/tests/ui/codex-interactions.spec.mjs +++ b/tests/ui/codex-interactions.spec.mjs @@ -349,11 +349,25 @@ test('materialized Claude hourly detail sends no host message and survives a ful ))).toEqual([]); }); -test('Claude monthly chart drill-down survives a full webview reload', async ({ page }) => { +test('Claude all-time months drill into materialized days and supported hours', async ({ page }) => { await openClaude(page); await page.locator('#tab-all').click(); - const month = '2026-07'; + const month = '2026-07-01'; + const day = '2026-07-19'; + const dailyData = [{ + date: day, + data: { + totalInputTokens: 400, + totalOutputTokens: 80, + totalCacheCreationTokens: 120, + totalCacheReadTokens: 200, + totalCost: 1.6, + costBreakdown: { input: 0.2, output: 0.4, cacheWrite: 0.6, cacheRead: 0.4 }, + messageCount: 10, + modelBreakdown: {}, + }, + }]; const chartBar = page.locator( `#all #allTimeChart .hc-col[data-date="${month}"] .chart-bar.clickable`, ); @@ -367,6 +381,30 @@ test('Claude monthly chart drill-down survives a full webview reload', async ({ return state.claudeDrilldownDetails?.['claude:all:monthly']; })).toBe(month); + await page.evaluate(({ month, dailyData }) => { + window.dispatchEvent(new MessageEvent('message', { + data: { command: 'dailyDataResponse', provider: 'claude', month, data: dailyData }, + })); + }, { month, dailyData }); + const daily = detail.locator('[data-claude-alltime-daily]'); + const hourlyId = `claude-alltime-hourly-detail-${day}`; + const dailyBar = daily.locator(`.chart-content .hc-col[data-date="${day}"] .chart-bar.clickable`); + const dailyToggle = daily.locator(`[data-claude-alltime-hourly-toggle][data-date="${day}"]`); + await expect(dailyBar).toHaveAttribute('aria-controls', hourlyId); + await expect(dailyToggle).toHaveAttribute('aria-controls', hourlyId); + const postedBeforeHourlyOpen = await page.evaluate(() => window.__ccuPostedMessages.length); + await dailyBar.focus(); + await page.keyboard.press('Space'); + const hourlyDetail = daily.locator(`[data-claude-alltime-hourly-detail-row][data-date="${day}"]`); + await expect(hourlyDetail).toBeVisible(); + await expect(hourlyDetail.locator('[data-claude-materialized-hours="true"]')).toBeVisible(); + await expect(hourlyDetail.locator('.daily-table tbody tr')).toHaveCount(24); + expect(await page.evaluate(() => window.__ccuPostedMessages.length)).toBe(postedBeforeHourlyOpen); + await expect.poll(() => page.evaluate(() => { + const state = JSON.parse(localStorage.getItem('__ccu-vscode-state') || '{}'); + return state.claudeDrilldownDetails?.['claude:all:hourly']; + })).toBe(day); + await page.reload({ waitUntil: 'load' }); await expect(page.locator('#tab-all')).toHaveClass(/active/); @@ -377,6 +415,17 @@ test('Claude monthly chart drill-down survives a full webview reload', async ({ await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.find( (message) => message.command === 'getDailyData', ))).toEqual({ command: 'getDailyData', month, provider: 'claude' }); + await page.evaluate(({ month, dailyData }) => { + window.dispatchEvent(new MessageEvent('message', { + data: { command: 'dailyDataResponse', provider: 'claude', month, data: dailyData }, + })); + }, { month, dailyData }); + await expect(page.locator( + `#all [data-claude-alltime-daily] [data-claude-alltime-hourly-detail-row][data-date="${day}"]`, + )).toBeVisible(); + await expect(page.locator( + `#all [data-claude-alltime-daily] .chart-content .hc-col[data-date="${day}"] .chart-bar.clickable`, + )).toHaveAttribute('aria-expanded', 'true'); }); test('Codex all-time months drill into indexed days and available hours on demand', async ({ page }) => { diff --git a/tests/ui/codex-visual.spec.mjs b/tests/ui/codex-visual.spec.mjs index 5f23bf14..70080ede 100644 --- a/tests/ui/codex-visual.spec.mjs +++ b/tests/ui/codex-visual.spec.mjs @@ -219,8 +219,10 @@ test('Claude middle dashboard scope is labelled as a rolling 30-day range', asyn }); test('legacy share-card errors are rendered as text instead of executable HTML', async ({ page }) => { - await openClaude(page, { fixture: 'combined-heatmap' }); - await page.locator('#tab-all').click(); + await openCompare(page, { + fixture: 'combined-heatmap', + commandTemplate: 'claudeShareCard', + }); await expect(page.locator('#scPreview')).toBeVisible(); const hostileError = ''; @@ -701,7 +703,7 @@ test('weekly allowance value uses the shared all-time chart and exposes uncertai await page.locator('#tab-all').click(); const panel = page.locator('#all .daily-breakdown').filter({ - hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex Beta', + hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex', }); await expect(panel).toBeVisible(); await expect(panel).toContainText('not an official balance, bill, or cash value'); @@ -752,7 +754,7 @@ test('weekly allowance value can be hidden without hiding Codex API-equivalent c await openCodex(page, { weeklyValue: false }); await page.locator('#tab-all').click(); - await expect(page.locator('#all')).not.toContainText('Weekly subscription allowance · API-equivalent estimate · Codex Beta'); + await expect(page.locator('#all')).not.toContainText('Weekly subscription allowance · API-equivalent estimate · Codex'); await expect(page.locator('#all .summary-grid .summary-item').first()).toContainText( 'API-equivalent cost', ); @@ -769,22 +771,22 @@ test('weekly allowance value can be hidden from Claude All time', async ({ page test('Compare shows both weekly panels by default and hides only those panels when disabled', async ({ page }) => { await openCompare(page); await expect(page.locator('#provider-panel')).toContainText('Weekly subscription allowance · API-equivalent estimate · Claude'); - await expect(page.locator('#provider-panel')).toContainText('Weekly subscription allowance · API-equivalent estimate · Codex Beta'); + await expect(page.locator('#provider-panel')).toContainText('Weekly subscription allowance · API-equivalent estimate · Codex'); await expect(page.locator('#provider-panel .usage-summary .summary-item')).toHaveCount(2); await openCompare(page, { weeklyValue: false }); await expect(page.locator('#provider-panel')).not.toContainText('Weekly subscription allowance · API-equivalent estimate · Claude'); - await expect(page.locator('#provider-panel')).not.toContainText('Weekly subscription allowance · API-equivalent estimate · Codex Beta'); + await expect(page.locator('#provider-panel')).not.toContainText('Weekly subscription allowance · API-equivalent estimate · Codex'); await expect(page.locator('#provider-panel .usage-summary .summary-item')).toHaveCount(2); await expect(page.locator('#provider-panel .usage-summary')).toContainText('Claude'); - await expect(page.locator('#provider-panel .usage-summary')).toContainText('Codex Beta'); + await expect(page.locator('#provider-panel .usage-summary')).toContainText('Codex'); }); test('weekly allowance table fits at 1280px in the longest locale', async ({ page }) => { await openCodex(page, { locale: 'de-DE', width: 1280 }); await page.locator('#tab-all').click(); const panel = page.locator('#all .daily-breakdown').filter({ - hasText: 'Wöchentliches Abo-Kontingent · API-Äquivalenzschätzung · Codex Beta', + hasText: 'Wöchentliches Abo-Kontingent · API-Äquivalenzschätzung · Codex', }); await panel.locator('.weekly-value-details summary').click(); const container = panel.locator('.daily-table-container'); @@ -804,7 +806,7 @@ test('weekly chart uses a non-overflowing current-period label in German', async await openCodex(page, { locale: 'de-DE', width: 1280 }); await page.locator('#tab-all').click(); const panel = page.locator('#all .daily-breakdown').filter({ - hasText: 'Wöchentliches Abo-Kontingent · API-Äquivalenzschätzung · Codex Beta', + hasText: 'Wöchentliches Abo-Kontingent · API-Äquivalenzschätzung · Codex', }); const labels = panel.locator('.hc-xlabel'); const layout = await labels.evaluateAll((elements) => { @@ -837,7 +839,7 @@ test('weekly used-value history remains visible without historical quota samples await page.locator('#tab-all').click(); const panel = page.locator('#all .daily-breakdown').filter({ - hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex Beta', + hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex', }); await expect(panel).toContainText('Monday-to-Monday UTC calendar weeks'); await expect(panel.locator('.weekly-value-details')).not.toHaveAttribute('open', ''); diff --git a/tests/ui/combined-heatmap.spec.mjs b/tests/ui/combined-heatmap.spec.mjs index 8e5c5c9f..389778f0 100644 --- a/tests/ui/combined-heatmap.spec.mjs +++ b/tests/ui/combined-heatmap.spec.mjs @@ -1,12 +1,17 @@ import AxeBuilder from '@axe-core/playwright'; -import { test, expect, openCompare } from './support/app.mjs'; +import { createRequire } from 'node:module'; +import { test, expect, openClaude, openCompare } from './support/app.mjs'; -test('Compare opens with the combined Claude + Codex heatmap and privacy-bounded share card', async ({ page }) => { +const require = createRequire(import.meta.url); +const { renderCombinedHeatmapSvg } = require('../../out/combinedHeatmapSvg.js'); + +test('Compare opens one preview-first sharing workspace on the combined presentation', async ({ page }) => { await openCompare(page, { fixture: 'combined-heatmap', locale: 'en' }); - const panel = page.locator('.combined-heatmap-panel'); + const panel = page.locator('.sharing-workspace'); await expect(panel).toBeVisible(); - await expect(panel.getByRole('heading', { name: 'Combined activity heatmap and share card' })).toBeVisible(); + await expect(panel.getByRole('heading', { name: 'Sharing workspace' })).toBeVisible(); + await expect(page.getByLabel('Presentation')).toHaveValue('combinedHeatmap'); await expect(page.getByRole('region', { name: 'Live preview' })).toBeVisible(); await expect(page.locator('#combinedHeatmapPreview svg')).toHaveAttribute('role', 'img'); await expect(page.getByRole('radio', { name: 'Academic Violet' })).toBeChecked(); @@ -30,7 +35,7 @@ test('Compare opens with the combined Claude + Codex heatmap and privacy-bounded )).toBe(true); await expect(page.locator('#combinedHeatmapPrivacyPreview')).toContainText('accounts, projects, thread titles, local paths, and log content'); await expect(panel).toContainText('not productivity, subscription billing, or provider capability equivalence'); - const desktopPreviewGeometry = await page.locator('.combined-heatmap-preview').evaluate((preview) => { + const desktopPreviewGeometry = await page.locator('#combinedHeatmapPreview').evaluate((preview) => { const svg = preview.querySelector('svg'); const more = [...preview.querySelectorAll('text')].find((element) => element.textContent === 'More'); if (!svg || !more) { @@ -71,6 +76,181 @@ test('Compare opens with the combined Claude + Codex heatmap and privacy-bounded expect(verticalLayout.settingsWidth).toBeLessThanOrEqual(verticalLayout.panelWidth); }); +test('presentation selection swaps truthful previews and survives reload', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', locale: 'en' }); + + const selector = page.getByLabel('Presentation'); + await expect(selector.locator('option')).toHaveText([ + 'Combined activity heatmap', + 'Claude Share Card', + 'Claude token heatmap', + ]); + + await selector.selectOption('claudeShareCard'); + await expect(page.locator('[data-sharing-presentation="combinedHeatmap"]')).toBeHidden(); + await expect(page.locator('[data-sharing-presentation="claudeShareCard"]')).toHaveClass(/sharing-presentation-placeholder/); + await expect(page.locator('[data-sharing-presentation="claudeShareCard"]')).toBeVisible(); + await expect(page.locator('[data-sharing-presentation="claudeShareCard"] svg')).toHaveCount(0); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter( + (message) => message.command === 'sharingTemplateChanged', + ))).toEqual([{ command: 'sharingTemplateChanged', template: 'claudeShareCard' }]); + + await openCompare(page, { + fixture: 'combined-heatmap', + locale: 'en', + commandTemplate: 'claudeShareCard', + }); + const card = page.locator('[data-sharing-presentation="claudeShareCard"]'); + await expect(card).toBeVisible(); + await expect(card).not.toHaveClass(/sharing-presentation-placeholder/); + await expect(card.locator('#scPreview svg')).toBeVisible(); + await expect(card.getByLabel('GitHub avatar')).toHaveCount(0); + await expect(card.getByLabel('GitHub name')).toHaveCount(0); + const cardLayout = await card.evaluate((element) => { + const preview = element.querySelector('#scPreview'); + const controls = element.querySelector('.sharing-controls-panel'); + if (!preview || !controls) return null; + return { + previewBottom: preview.getBoundingClientRect().bottom, + controlsTop: controls.getBoundingClientRect().top, + previewWidth: preview.getBoundingClientRect().width, + presentationWidth: element.getBoundingClientRect().width, + }; + }); + expect(cardLayout).not.toBeNull(); + expect(cardLayout.controlsTop).toBeGreaterThanOrEqual(cardLayout.previewBottom); + expect(cardLayout.previewWidth).toBeGreaterThanOrEqual(cardLayout.presentationWidth * 0.9); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await card.getByRole('button', { name: 'Update preview' }).click(); + await card.getByRole('button', { name: 'Export SVG…' }).click(); + const cardMessages = await page.evaluate(() => window.__ccuPostedMessages); + expect(cardMessages.map(({ command }) => command)).toEqual(['buildShareCard', 'exportShareCard']); + expect(cardMessages.every((message) => !('avatar' in message) && !('username' in message))).toBe(true); + const cardA11y = await new AxeBuilder({ page }) + .include('[data-sharing-presentation="claudeShareCard"]') + .analyze(); + expect(cardA11y.violations).toEqual([]); + + await page.reload({ waitUntil: 'load' }); + await expect(page.getByLabel('Presentation')).toHaveValue('claudeShareCard'); + await expect(page.locator('[data-sharing-presentation="claudeShareCard"]')).toBeVisible(); + + await page.getByLabel('Presentation').selectOption('claudeHeatmap'); + await expect(page.locator('[data-sharing-presentation="claudeHeatmap"]')).toHaveClass(/sharing-presentation-placeholder/); + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.filter( + (message) => message.command === 'sharingTemplateChanged', + ).at(-1))).toEqual({ command: 'sharingTemplateChanged', template: 'claudeHeatmap' }); + await openCompare(page, { + fixture: 'combined-heatmap', + locale: 'en', + commandTemplate: 'claudeHeatmap', + }); + const heatmap = page.locator('[data-sharing-presentation="claudeHeatmap"]'); + await expect(heatmap).toBeVisible(); + await expect(heatmap).not.toHaveClass(/sharing-presentation-placeholder/); + await expect(heatmap.locator('#claudeHeatmapPreview svg')).toBeVisible(); + const heatmapA11y = await new AxeBuilder({ page }) + .include('[data-sharing-presentation="claudeHeatmap"]') + .analyze(); + expect(heatmapA11y.violations).toEqual([]); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + await heatmap.getByRole('button', { name: 'Export SVG…' }).click(); + await heatmap.getByRole('button', { name: 'Publish to GitHub…' }).click(); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([ + { command: 'exportHeatmap' }, + { command: 'publishHeatmap' }, + ]); +}); + +test('unapplied Share Card controls survive a dashboard refresh and can be reset', async ({ page }) => { + await openCompare(page, { + fixture: 'combined-heatmap', + locale: 'en', + commandTemplate: 'claudeShareCard', + }); + await page.locator('#scRange').selectOption('week'); + await page.locator('#scTheme').selectOption('auroraDark'); + await page.locator('.sc-sec[data-sec="messages"]').check(); + await page.reload(); + await expect(page.locator('#scRange')).toHaveValue('week'); + await expect(page.locator('#scTheme')).toHaveValue('auroraDark'); + await expect(page.locator('.sc-sec[data-sec="messages"]')).toBeChecked(); + await page.evaluate(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { command: 'localDataClientAction', action: 'reset-sharing-preferences' }, + })); + }); + await expect(page.locator('#scRange')).toHaveValue('last30'); + await expect(page.locator('#scTheme')).toHaveValue('claudeClassic'); + await expect(page.locator('.sc-sec[data-sec="messages"]')).not.toBeChecked(); +}); + +test('explicit legacy command intent overrides a saved Claude tab after a full render', async ({ page }) => { + await openClaude(page, { fixture: 'combined-heatmap', claudeOnly: true }); + await page.getByRole('tab', { name: 'Last 30 Days' }).click(); + await expect(page.locator('#tab-month')).toHaveClass(/active/); + + await openClaude(page, { + fixture: 'combined-heatmap', + claudeOnly: true, + commandTemplate: 'claudeHeatmap', + }); + + await expect(page.locator('#tab-all')).toHaveClass(/active/); + await expect(page.locator('#all .sharing-workspace')).toBeVisible(); + await expect(page.getByLabel('Presentation')).toHaveValue('claudeHeatmap'); + await expect.poll(() => page.evaluate(() => localStorage.getItem('ccu.activeTab'))).toBe('all'); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter( + (message) => message.command === 'sharingTemplateCommandAck', + ))).toEqual([{ command: 'sharingTemplateCommandAck', revision: 1 }]); +}); + +test('sharing reset cannot collide with the next legacy command revision', async ({ page }) => { + await openCompare(page, { + fixture: 'combined-heatmap', + commandTemplate: 'claudeHeatmap', + }); + await expect(page.getByLabel('Presentation')).toHaveValue('claudeHeatmap'); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter( + (message) => message.command === 'sharingTemplateCommandAck', + ))).toEqual([{ command: 'sharingTemplateCommandAck', revision: 1 }]); + + await page.evaluate(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'localDataClientAction', + action: 'reset-sharing-preferences', + requestId: 'sharing-reset-between-commands', + }, + })); + }); + await page.getByLabel('Presentation').selectOption('claudeShareCard'); + + await openCompare(page, { + fixture: 'combined-heatmap', + commandTemplate: 'claudeHeatmap', + commandAfterReset: true, + }); + await expect(page.getByLabel('Presentation')).toHaveValue('claudeHeatmap'); + await expect(page.locator('[data-sharing-presentation="claudeHeatmap"]')).toBeVisible(); + expect(await page.evaluate(() => window.__ccuPostedMessages.filter( + (message) => message.command === 'sharingTemplateCommandAck', + ))).toEqual([{ command: 'sharingTemplateCommandAck', revision: 2 }]); + expect(await page.evaluate(() => sessionStorage.getItem('ccu.sharing.commandRevision'))).toBeNull(); +}); + +test('legacy Claude panels are consolidated and sharing exposes only one visible toggle', async ({ page }) => { + await openClaude(page, { fixture: 'combined-heatmap', locale: 'en' }); + + await page.getByRole('tab', { name: 'All Time' }).click(); + await expect(page.locator('.share-panel')).toHaveCount(0); + await expect(page.locator('#all .sharing-workspace')).toHaveCount(0); + + await page.getByRole('tab', { name: 'Settings' }).click(); + await expect(page.getByText('Enable sharing workspace', { exact: true })).toBeVisible(); + await expect(page.getByText('Show token heatmap (All-time tab)', { exact: true })).toHaveCount(0); +}); + test('sharing workspace is on by default and can be hidden without removing Compare data', async ({ page }) => { await openCompare(page, { fixture: 'combined-heatmap', locale: 'en' }); await expect(page.locator('.combined-heatmap-panel')).toBeVisible(); @@ -90,7 +270,7 @@ test('Compare keeps provider costs and allowance estimates separate instead of s const summaryCards = page.locator('#provider-panel > .usage-summary .summary-item'); await expect(summaryCards).toHaveCount(2); - await expect(summaryCards.locator(':scope > h3')).toHaveText(['Claude', 'Codex Beta']); + await expect(summaryCards.locator(':scope > h3')).toHaveText(['Claude', 'Codex']); const summaryText = (await summaryCards.allTextContents()).join('\n'); expect(summaryText).not.toContain('$'); expect(summaryText).not.toMatch(/quota|allowance/i); @@ -101,7 +281,7 @@ test('Compare keeps provider costs and allowance estimates separate instead of s await expect(allowancePanels).toHaveCount(2); await expect(allowancePanels.locator(':scope > .section-header > h3')).toHaveText([ 'Weekly subscription allowance · API-equivalent estimate · Claude', - 'Weekly subscription allowance · API-equivalent estimate · Codex Beta', + 'Weekly subscription allowance · API-equivalent estimate · Codex', ]); }); @@ -154,7 +334,35 @@ test('combined share preferences stay local and every export action is explicit' ]); expect(state.messages.some((message) => /publish/i.test(message.command))).toBe(false); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); await page.getByRole('button', { name: 'Reset sharing preferences' }).click(); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([ + { command: 'runLocalDataAction', action: 'reset-sharing-preferences' }, + ]); + await page.evaluate(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'localDataClientAction', + action: 'reset-sharing-preferences', + requestId: 'direct-sharing-reset-success', + }, + })); + }); + expect(await page.evaluate(() => window.__ccuPostedMessages.findLast( + (message) => message.command === 'localDataClientActionAck', + ))).toEqual({ + command: 'localDataClientActionAck', + requestId: 'direct-sharing-reset-success', + ok: true, + }); + await page.evaluate(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'localDataActionResult', + result: { ok: true, message: 'Sharing preferences and destination strings were reset.' }, + }, + })); + }); await expect(page.locator('#combinedHeatmapTitle')).toHaveValue('Claude + Codex local activity'); await expect(page.locator('#combinedHeatmapRange')).toHaveValue('year'); await expect(page.locator('#combinedHeatmapIntensityMode')).toHaveValue('quantile'); @@ -167,59 +375,140 @@ test('combined share preferences stay local and every export action is explicit' intensityMode: localStorage.getItem('ccu.combinedHeatmap.intensityMode'), palette: localStorage.getItem('ccu.combinedHeatmap.palette'), customAccent: localStorage.getItem('ccu.combinedHeatmap.customAccent'), - messages: window.__ccuPostedMessages.slice(-2), + messages: window.__ccuPostedMessages, })); - expect(reset).toEqual({ + expect(reset).toMatchObject({ title: null, range: null, privacy: null, intensityMode: null, palette: null, customAccent: null, - messages: [ - { command: 'previewCombinedHeatmap', title: 'Claude + Codex local activity', range: 'year', intensityMode: 'quantile', palette: 'academicViolet', customAccent: '#4f2f87' }, - { command: 'resetCombinedHeatmapPreferences' }, - ], }); + expect(reset.messages.some((message) => message.command === 'resetCombinedHeatmapPreferences')).toBe(false); + await expect(page.locator('#combinedHeatmapStatus')).toContainText('Sharing preferences and destination strings were reset.'); + + await page.reload({ waitUntil: 'load' }); + await expect(page.locator('#combinedHeatmapTitle')).toHaveValue('Claude + Codex local activity'); + await expect(page.locator('#combinedHeatmapRange')).toHaveValue('year'); + await expect(page.locator('#combinedHeatmapIntensityMode')).toHaveValue('quantile'); + await expect(page.getByRole('radio', { name: 'Academic Violet' })).toBeChecked(); + await expect(page.locator('#combinedHeatmapPrivacy')).toBeChecked(); +}); + +test('direct sharing reset reports browser-storage removal failure through the host protocol', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', locale: 'en' }); + await page.locator('#combinedHeatmapTitle').fill('Must survive failed reset'); + await page.getByRole('button', { name: 'Update preview' }).click(); + await page.evaluate(() => { window.__ccuPostedMessages = []; }); + + await page.getByRole('button', { name: 'Reset sharing preferences' }).click(); + expect(await page.evaluate(() => window.__ccuPostedMessages)).toEqual([ + { command: 'runLocalDataAction', action: 'reset-sharing-preferences' }, + ]); + await page.evaluate(() => { + const original = Storage.prototype.removeItem; + Storage.prototype.removeItem = function failingSharingRemoveItem(key) { + if (key === 'ccu.combinedHeatmap.title') { + throw new Error('synthetic-sharing-storage-failure'); + } + return original.call(this, key); + }; + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'localDataClientAction', + action: 'reset-sharing-preferences', + requestId: 'direct-sharing-reset-failure', + }, + })); + Storage.prototype.removeItem = original; + }); + expect(await page.evaluate(() => window.__ccuPostedMessages.findLast( + (message) => message.command === 'localDataClientActionAck', + ))).toEqual({ + command: 'localDataClientActionAck', + requestId: 'direct-sharing-reset-failure', + ok: false, + }); + expect(await page.evaluate(() => localStorage.getItem('ccu.combinedHeatmap.title'))) + .toBe('Must survive failed reset'); + + await page.evaluate(() => { + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'localDataActionResult', + result: { + ok: false, + message: 'Host data was handled, but the Webview did not confirm its local reset.', + }, + }, + })); + }); + await expect(page.locator('#combinedHeatmapStatus')).toContainText( + 'Host data was handled, but the Webview did not confirm its local reset.', + ); }); for (const theme of ['light', 'dark']) { test(`combined heatmap controls and preview are accessible in ${theme} theme`, async ({ page }) => { await openCompare(page, { fixture: 'combined-heatmap', locale: 'zh-CN', theme }); - await expect(page.getByLabel('分享标题')).toBeVisible(); - await expect(page.getByLabel('时间范围')).toBeVisible(); - await expect(page.getByLabel('色阶映射')).toHaveValue('quantile'); - await expect(page.getByRole('group', { name: '热力图配色' })).toBeVisible(); - await expect(page.getByLabel('显示隐私预览')).toBeChecked(); + const presentation = page.locator('[data-sharing-presentation="combinedHeatmap"]'); + await expect(presentation.getByLabel('分享标题')).toBeVisible(); + await expect(presentation.getByLabel('时间范围')).toBeVisible(); + await expect(presentation.getByLabel('色阶映射')).toHaveValue('quantile'); + await expect(presentation.getByRole('group', { name: '热力图配色' })).toBeVisible(); + await expect(presentation.getByLabel('显示隐私预览')).toBeChecked(); await page.locator('.combined-output-panel').evaluate((element) => { element.open = true; }); await expect(page.getByLabel('README / Markdown 引用片段')).toBeVisible(); const results = await new AxeBuilder({ page }) .include('.combined-heatmap-panel') - // Chromium/axe cannot resolve overlapping SVG primitives reliably. - // Export-card text/background pairs have deterministic contrast tests - // in combinedHeatmap.test.ts; Axe owns the surrounding HTML controls. - .exclude('#combinedHeatmapPreview svg') .analyze(); expect(results.violations).toEqual([]); - expect(results.incomplete.filter((item) => item.id === 'color-contrast')).toEqual([]); + const unresolvedHtmlContrast = results.incomplete + .filter((item) => item.id === 'color-contrast') + .flatMap((item) => item.nodes) + .filter((node) => !/^ { const expected = { - en: ['Intensity scale', 'Combined activity heatmap and share card'], - 'de-DE': ['Intensitätsskala', 'Kombinierte Aktivitäts-Heatmap und Freigabekarte'], - 'zh-TW': ['色階映射', '綜合活動熱力圖與分享卡'], - 'zh-CN': ['色阶映射', '综合活动热力图与分享卡'], - ja: ['強度スケール', '統合アクティビティヒートマップと共有カード'], - ko: ['강도 스케일', '통합 활동 히트맵과 공유 카드'], - 'pt-BR': ['Escala de intensidade', 'Mapa de calor de atividade combinado e cartão de compartilhamento'], - id: ['Skala intensitas', 'Heatmap aktivitas gabungan dan kartu berbagi'], + en: ['Intensity scale', 'Sharing workspace', 'Presentation', 'More', 'total tokens'], + 'de-DE': ['Intensitätsskala', 'Freigabe-Arbeitsbereich', 'Darstellung', 'Mehr', 'Gesamttoken'], + 'zh-TW': ['色階映射', '分享工作區', '呈現方式', '較多', '總 token'], + 'zh-CN': ['色阶映射', '分享工作台', '呈现方式', '较多', '总 token'], + ja: ['強度スケール', '共有ワークスペース', '表示形式', '多い', '総トークン'], + ko: ['강도 스케일', '공유 작업 공간', '표시 형식', '많이', '총 토큰'], + 'pt-BR': ['Escala de intensidade', 'Espaço de compartilhamento', 'Apresentação', 'Mais', 'total de tokens'], + id: ['Skala intensitas', 'Ruang kerja berbagi', 'Presentasi', 'Lebih banyak', 'total token'], }; - for (const [locale, [intensityLabel, heading]] of Object.entries(expected)) { + for (const [locale, [intensityLabel, heading, presentationLabel, combinedMarker, cardMarker]] of Object.entries(expected)) { await openCompare(page, { fixture: 'combined-heatmap', locale }); await expect(page.getByLabel(intensityLabel)).toHaveValue('quantile'); await expect(page.getByRole('heading', { name: heading })).toBeVisible(); + await expect(page.getByLabel(presentationLabel)).toHaveValue('combinedHeatmap'); + await expect(page.locator('#combinedHeatmapPreview svg')).toContainText(combinedMarker); + const outOfBoundsLabels = await page.locator('#combinedHeatmapPreview svg').evaluate((svg) => { + const viewBox = svg.viewBox.baseVal; + return [...svg.querySelectorAll('text')].flatMap((label) => { + const box = label.getBBox(); + return box.x < -0.5 || box.x + box.width > viewBox.width + 0.5 + ? [label.textContent] + : []; + }); + }); + expect(outOfBoundsLabels, { locale, outOfBoundsLabels }).toEqual([]); + await openCompare(page, { + fixture: 'combined-heatmap', + locale, + commandTemplate: 'claudeShareCard', + }); + await expect(page.locator('#scPreview svg')).toContainText(cardMarker); + await page.evaluate(() => localStorage.setItem('ccu.sharing.template', 'combinedHeatmap')); } }); @@ -228,9 +517,118 @@ test('combined heatmap remains usable at a 360px webview width', async ({ page } await expect(page.locator('.combined-heatmap-controls')).toHaveCSS('grid-template-columns', /\d+(?:\.\d+)?px/); const pageOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); expect(pageOverflow).toBeLessThanOrEqual(1); - const previewOverflow = await page.locator('.combined-heatmap-preview').evaluate((node) => ({ - scrollWidth: node.scrollWidth, - clientWidth: node.clientWidth, - })); + const previewOverflow = await page.locator('#combinedHeatmapPreview').evaluate((node) => { + const svg = node.querySelector('svg'); + const viewBox = svg?.viewBox.baseVal; + const outOfBoundsLabels = svg && viewBox + ? [...svg.querySelectorAll('text')].flatMap((label) => { + const box = label.getBBox(); + const epsilon = 0.5; + return box.x < -epsilon || box.y < -epsilon || + box.x + box.width > viewBox.width + epsilon || + box.y + box.height > viewBox.height + epsilon + ? [label.textContent] + : []; + }) + : ['missing-svg']; + return { + scrollWidth: node.scrollWidth, + clientWidth: node.clientWidth, + outOfBoundsLabels, + }; + }); expect(previewOverflow.scrollWidth).toBeGreaterThan(previewOverflow.clientWidth); + expect(previewOverflow.outOfBoundsLabels, previewOverflow).toEqual([]); +}); + +test('maximum ASCII and CJK titles remain inside the rendered SVG viewBox', async ({ page }) => { + await openCompare(page, { fixture: 'combined-heatmap', locale: 'en', width: 360, height: 900 }); + for (const title of ['W'.repeat(80), '综合活动热力图'.repeat(12)]) { + const svg = renderCombinedHeatmapSvg({}, { + range: '30d', + endDateISO: '2026-07-22', + title, + }); + await page.evaluate((renderedSvg) => { + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'combinedHeatmapResult', + svg: renderedSvg, + markdown: '', + filename: 'contained.svg', + hasData: true, + }, + })); + }, svg); + const geometry = await page.locator('#combinedHeatmapPreview svg').evaluate((element) => { + const viewBox = element.viewBox.baseVal; + const titleNode = element.querySelector('text'); + const box = titleNode?.getBBox(); + return { + text: titleNode?.textContent, + right: box ? box.x + box.width : Number.POSITIVE_INFINITY, + width: viewBox.width, + }; + }); + expect(geometry.text).toMatch(/…$/); + expect(geometry.right, geometry).toBeLessThanOrEqual(geometry.width + 0.5); + } }); + +for (const template of ['claudeShareCard', 'claudeHeatmap']) { + test(`${template} remains preview-first and accessible at a 360px webview width`, async ({ page }) => { + await openCompare(page, { + fixture: 'combined-heatmap', + commandTemplate: template, + width: 360, + height: 900, + }); + const presentation = page.locator(`[data-sharing-presentation="${template}"]`); + await expect(presentation).toBeVisible(); + const geometry = await presentation.evaluate((element) => { + const preview = element.querySelector('.sharing-artifact-preview'); + const controls = element.querySelector('.sharing-controls-panel'); + const svg = preview?.querySelector('svg'); + const viewBox = svg?.viewBox.baseVal; + const scale = svg && viewBox?.width + ? svg.getBoundingClientRect().width / viewBox.width + : null; + const outOfBoundsLabels = svg && viewBox + ? [...svg.querySelectorAll('text')].flatMap((label) => { + const box = label.getBBox(); + const epsilon = 0.5; + return box.x < -epsilon || box.y < -epsilon || + box.x + box.width > viewBox.width + epsilon || + box.y + box.height > viewBox.height + epsilon + ? [label.textContent] + : []; + }) + : ['missing-svg']; + return { + pageOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + previewBottom: preview?.getBoundingClientRect().bottom, + previewWidth: preview?.getBoundingClientRect().width, + previewClientWidth: preview?.clientWidth, + previewScrollWidth: preview?.scrollWidth, + controlsTop: controls?.getBoundingClientRect().top, + presentationWidth: element.getBoundingClientRect().width, + intrinsicWidth: viewBox?.width, + scale, + outOfBoundsLabels, + }; + }); + expect(geometry.pageOverflow).toBeLessThanOrEqual(1); + expect(geometry.controlsTop).toBeGreaterThanOrEqual(geometry.previewBottom); + expect(geometry.previewWidth).toBeGreaterThanOrEqual(geometry.presentationWidth * 0.9); + expect(geometry.previewScrollWidth).toBeGreaterThan(geometry.previewClientWidth); + expect(geometry.outOfBoundsLabels, geometry).toEqual([]); + if (template === 'claudeShareCard') { + expect(geometry.intrinsicWidth).toBe(1200); + expect(geometry.scale, geometry).toBeGreaterThanOrEqual(0.99); + } + const results = await new AxeBuilder({ page }) + .include(`[data-sharing-presentation="${template}"]`) + .analyze(); + expect(results.violations).toEqual([]); + }); +} diff --git a/tests/ui/currency-display.spec.mjs b/tests/ui/currency-display.spec.mjs index 77128961..88f2cf01 100644 --- a/tests/ui/currency-display.spec.mjs +++ b/tests/ui/currency-display.spec.mjs @@ -25,7 +25,7 @@ test('currency preset reaches Codex summaries, charts, weekly values, and settin await page.locator('#tab-all').click(); const weekly = page.locator('#all .daily-breakdown').filter({ - hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex Beta', + hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex', }); await expect(weekly.locator('.hc-yaxis .hc-yval').first()).toHaveText(/^≈EUR \d/); await weekly.locator('.weekly-value-details summary').click(); diff --git a/tests/ui/dashboard-live-refresh.spec.mjs b/tests/ui/dashboard-live-refresh.spec.mjs index 546d6a0a..194aad63 100644 --- a/tests/ui/dashboard-live-refresh.spec.mjs +++ b/tests/ui/dashboard-live-refresh.spec.mjs @@ -1,4 +1,95 @@ -import { test, expect, openClaude } from './support/app.mjs'; +import { test, expect, openClaude, openCodex } from './support/app.mjs'; + +test('a live Codex refresh waits for an active scroll burst before replacing the panel', async ({ page }) => { + await openCodex(page, { height: 420 }); + const before = await page.evaluate(() => { + const panel = document.getElementById('provider-panel'); + const filler = document.createElement('div'); + filler.style.height = '1600px'; + panel.appendChild(filler); + scrollTo(0, 160); + window.dispatchEvent(new Event('scroll')); + window.__ccuScrollPanel = panel.firstElementChild; + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', + provider: 'codex', + tab: 'today', + revision: 1, + html: panel.innerHTML, + claudeLast30HoursByDay: {}, + } })); + return { scrollY, calls: window.__ccuSetStateCalls }; + }); + expect(before.scrollY).toBeGreaterThan(0); + expect(await page.evaluate(() => document.getElementById('provider-panel').firstElementChild === window.__ccuScrollPanel)).toBe(true); + expect(await page.evaluate(() => window.__ccuPostedMessages.some((message) => message.command === 'dashboardDataPatchAck'))).toBe(false); + await expect.poll(() => page.evaluate(() => + window.__ccuPostedMessages.some((message) => message.command === 'dashboardDataPatchAck' && message.revision === 1 && message.ok), + )).toBe(true); + expect(await page.evaluate((calls) => window.__ccuSetStateCalls - calls, before.calls)).toBeLessThanOrEqual(1); +}); + +test('continued Codex scrolling coalesces updates but cannot starve the latest patch', async ({ page }) => { + await openCodex(page, { height: 420 }); + await page.evaluate(() => { + const panel = document.getElementById('provider-panel'); + const filler = document.createElement('div'); + filler.style.height = '1600px'; + panel.appendChild(filler); + scrollTo(0, 160); + window.dispatchEvent(new Event('scroll')); + window.__ccuScrollPulse = setInterval(() => window.dispatchEvent(new Event('scroll')), 35); + for (let revision = 1; revision <= 4; revision += 1) { + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'today', revision, + html: panel.innerHTML, claudeLast30HoursByDay: {}, + } })); + } + }); + try { + await expect.poll(() => page.evaluate(() => + window.__ccuPostedMessages.filter((message) => message.command === 'dashboardDataPatchAck'), + )).toEqual([{ command: 'dashboardDataPatchAck', revision: 4, ok: true }]); + } finally { + await page.evaluate(() => clearInterval(window.__ccuScrollPulse)); + } +}); + +test('a 20-update scroll storm swaps the provider panel at most twice', async ({ page }) => { + await openCodex(page, { height: 420 }); + const result = await page.evaluate(async () => { + const panel = document.getElementById('provider-panel'); + const filler = document.createElement('div'); + filler.style.height = '1600px'; + panel.appendChild(filler); + const html = panel.innerHTML; + let swaps = 0; + const observer = new MutationObserver((records) => { + swaps += records.filter((record) => record.type === 'childList').length; + }); + observer.observe(panel, { childList: true }); + scrollTo(0, 160); + for (let revision = 1; revision <= 20; revision += 1) { + window.dispatchEvent(new Event('scroll')); + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'today', revision, + html, claudeLast30HoursByDay: {}, + } })); + await new Promise((done) => setTimeout(done, 25)); + } + await new Promise((done) => setTimeout(done, 650)); + observer.disconnect(); + return { + swaps, + acks: window.__ccuPostedMessages + .filter((message) => message.command === 'dashboardDataPatchAck') + .map((message) => message.revision), + }; + }); + expect(result.swaps).toBeGreaterThan(0); + expect(result.swaps).toBeLessThanOrEqual(2); + expect(result.acks.at(-1)).toBe(20); +}); const scrollerFixtures = [ ['dashboard-tabs', 'tabs'], diff --git a/tests/ui/local-data-controls.spec.mjs b/tests/ui/local-data-controls.spec.mjs index 881a4328..f4cc7250 100644 --- a/tests/ui/local-data-controls.spec.mjs +++ b/tests/ui/local-data-controls.spec.mjs @@ -1,4 +1,4 @@ -import { test, expect, openCodex } from './support/app.mjs'; +import { test, expect, openClaude, openCodex } from './support/app.mjs'; test('Settings stays concise: repository privacy controls are not rendered in the plugin', async ({ page }) => { await openCodex(page); @@ -61,6 +61,7 @@ test('UI and sharing resets are independent and preserve unrelated localStorage' await page.evaluate(() => { localStorage.setItem('ccu.activeTab', 'settings'); localStorage.setItem('ccu.sessionRange', '30'); + localStorage.setItem('ccu.sharing.template', 'claudeShareCard'); localStorage.setItem('ccu.combinedHeatmap.title', 'Synthetic title'); localStorage.setItem('ccu.combinedHeatmap.range', '90d'); localStorage.setItem('unrelated-private-canary', 'preserve'); @@ -83,12 +84,14 @@ test('UI and sharing resets are independent and preserve unrelated localStorage' expect(await page.evaluate(() => ({ active: localStorage.getItem('ccu.activeTab'), range: localStorage.getItem('ccu.sessionRange'), + sharingTemplate: localStorage.getItem('ccu.sharing.template'), shareTitle: localStorage.getItem('ccu.combinedHeatmap.title'), state: JSON.parse(localStorage.getItem('__ccu-vscode-state') || '{}'), unrelated: localStorage.getItem('unrelated-private-canary'), }))).toEqual({ active: null, range: null, + sharingTemplate: 'claudeShareCard', shareTitle: 'Synthetic title', state: {}, unrelated: 'preserve', @@ -100,10 +103,49 @@ test('UI and sharing resets are independent and preserve unrelated localStorage' })); }); expect(await page.evaluate(() => ({ + sharingTemplate: localStorage.getItem('ccu.sharing.template'), title: localStorage.getItem('ccu.combinedHeatmap.title'), range: localStorage.getItem('ccu.combinedHeatmap.range'), unrelated: localStorage.getItem('unrelated-private-canary'), - }))).toEqual({ title: null, range: null, unrelated: 'preserve' }); + }))).toEqual({ sharingTemplate: null, title: null, range: null, unrelated: 'preserve' }); +}); + +test('sharing reset refreshes the visible master switch without browser command-coordination state', async ({ page }) => { + await openClaude(page); + await page.locator('#tab-settings').click(); + const masterSwitch = page.locator('#set_enableShareCard'); + await masterSwitch.evaluate((element) => { + element.checked = false; + element.dispatchEvent(new Event('change', { bubbles: true })); + }); + await page.evaluate(() => { + localStorage.setItem('ccu.sharing.template', 'claudeShareCard'); + sessionStorage.setItem('unrelated-session-canary', 'preserve'); + window.dispatchEvent(new MessageEvent('message', { + data: { + command: 'localDataClientAction', + action: 'reset-sharing-preferences', + requestId: 'sharing-reset-runtime-1', + }, + })); + }); + + await expect(masterSwitch).toBeChecked(); + expect(await page.evaluate(() => ({ + template: localStorage.getItem('ccu.sharing.template'), + unrelated: sessionStorage.getItem('unrelated-session-canary'), + ack: window.__ccuPostedMessages.findLast( + (message) => message.requestId === 'sharing-reset-runtime-1', + ), + }))).toEqual({ + template: null, + unrelated: 'preserve', + ack: { + command: 'localDataClientActionAck', + requestId: 'sharing-reset-runtime-1', + ok: true, + }, + }); }); test('client reset ACK is false when allowlisted browser storage cannot be deleted', async ({ page }) => { diff --git a/tests/ui/support/app.mjs b/tests/ui/support/app.mjs index 3acadf5e..5bba5fb8 100644 --- a/tests/ui/support/app.mjs +++ b/tests/ui/support/app.mjs @@ -44,11 +44,11 @@ export async function openCodex( export async function openClaude( page, - { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, projectMatrix = true, timeZone = 'Asia/Hong_Kong', width = 1280, height = 900 } = {}, + { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, projectMatrix = true, timeZone = 'Asia/Hong_Kong', claudeOnly = false, commandTemplate = '', commandAfterReset = false, width = 1280, height = 900 } = {}, ) { await page.setViewportSize({ width, height }); await page.goto( - `${uiBaseUrl}/?provider=claude&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&projectMatrix=${projectMatrix}&timeZone=${encodeURIComponent(timeZone)}`, + `${uiBaseUrl}/?provider=claude&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&projectMatrix=${projectMatrix}&timeZone=${encodeURIComponent(timeZone)}&claudeOnly=${claudeOnly}&commandTemplate=${encodeURIComponent(commandTemplate)}&commandAfterReset=${commandAfterReset}`, { waitUntil: 'load' }, ); await page.locator('.tab-content.active').waitFor(); @@ -56,11 +56,11 @@ export async function openClaude( export async function openCompare( page, - { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, width = 1280, height = 900 } = {}, + { locale = 'en', theme = 'light', fixture = 'default', autoRefresh = false, weeklyValue = true, shareStudio = true, commandTemplate = '', commandAfterReset = false, width = 1280, height = 900 } = {}, ) { await page.setViewportSize({ width, height }); await page.goto( - `${uiBaseUrl}/?provider=compare&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}`, + `${uiBaseUrl}/?provider=compare&locale=${encodeURIComponent(locale)}&theme=${theme}&fixture=${encodeURIComponent(fixture)}&autoRefresh=${autoRefresh}&weeklyValue=${weeklyValue}&shareStudio=${shareStudio}&commandTemplate=${encodeURIComponent(commandTemplate)}&commandAfterReset=${commandAfterReset}`, { waitUntil: 'load' }, ); await page.locator('#provider-panel').waitFor(); diff --git a/tests/ui/support/capture-readme.mjs b/tests/ui/support/capture-readme.mjs index e086abef..8a90e02a 100644 --- a/tests/ui/support/capture-readme.mjs +++ b/tests/ui/support/capture-readme.mjs @@ -80,7 +80,7 @@ try { } else if (view === 'weekly') { await page.locator('#tab-all').click(); const panel = page.locator('#all .daily-breakdown').filter({ - hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex Beta', + hasText: 'Weekly subscription allowance · API-equivalent estimate · Codex', }); await expect(panel.locator('.weekly-value-details')).not.toHaveAttribute('open', ''); await expect(panel.locator('.seg-cache-read')).not.toHaveCount(0); diff --git a/tests/ui/support/capture-v240.mjs b/tests/ui/support/capture-v240.mjs new file mode 100644 index 00000000..057947ed --- /dev/null +++ b/tests/ui/support/capture-v240.mjs @@ -0,0 +1,82 @@ +// Reproducible synthetic documentation captures from the production renderer. +// These are not evidence of an installed VSIX or a user's actual usage. +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { chromium, expect } from '@playwright/test'; + +const require = createRequire(import.meta.url); +const { renderHarness } = require('./render-harness.cjs'); +const fixtureNow = Date.parse('2026-07-20T12:00:00.000Z'); +const output = resolve('images/v2.4.0'); +const views = { + compare: { provider: 'compare', locale: 'en', theme: 'light', fixture: 'combined-heatmap', width: 1280 }, + claude: { provider: 'claude', locale: 'zh-CN', theme: 'dark', fixture: 'combined-heatmap', claudeOnly: true, commandTemplate: 'claudeShareCard', width: 1280 }, + codex: { provider: 'codex', locale: 'zh-CN', theme: 'dark', fixture: 'default', width: 1280 }, + narrow: { provider: 'compare', locale: 'de-DE', theme: 'dark', fixture: 'combined-heatmap', width: 360 }, +}; +const server = createServer(async (request, response) => { + const options = views[request.url?.slice(1)]; + if (!options) { response.writeHead(404); response.end(); return; } + const NativeDate = Date; + try { + globalThis.Date = class extends NativeDate { + constructor(...args) { super(...(args.length ? args : [fixtureNow])); } + static now() { return fixtureNow; } + }; + const html = await renderHarness({ ...options, autoRefresh: false, weeklyValue: true, shareStudio: true }); + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(html); + } catch { + response.writeHead(500); response.end('Fixture rendering failed'); + } finally { + globalThis.Date = NativeDate; + } +}); +await new Promise((done) => server.listen(0, '127.0.0.1', done)); +const browser = await chromium.launch(); +try { + await mkdir(output, { recursive: true }); + for (const [view, options] of Object.entries(views)) { + const context = await browser.newContext({ + viewport: { width: options.width, height: 900 }, + deviceScaleFactor: 1, + locale: options.locale, + timezoneId: 'Asia/Hong_Kong', + reducedMotion: 'reduce', + colorScheme: options.theme, + }); + const page = await context.newPage(); + await page.addInitScript(() => { + window.acquireVsCodeApi = () => ({ + getState: () => undefined, + setState: () => undefined, + postMessage: () => Promise.resolve(true), + }); + }); + await page.clock.install({ time: new Date(fixtureNow) }); + await page.goto(`http://127.0.0.1:${server.address().port}/${view}`); + const screenshot = { animations: 'disabled', caret: 'hide' }; + if (view === 'codex') { + await page.locator('#tab-month').click(); + await expect(page.locator('#month')).toContainText('未缓存用量'); + await page.screenshot({ ...screenshot, path: resolve(output, 'codex-month-zh-CN-dark.png') }); + } else { + if (view === 'claude') { + await page.locator('#tab-all').click(); + await expect(page.locator('[data-sharing-presentation="claudeShareCard"]')).toBeVisible(); + } else { + await expect(page.locator('[data-sharing-presentation="combinedHeatmap"]')).toBeVisible(); + } + await page.locator('.sharing-workspace').screenshot({ + ...screenshot, + path: resolve(output, `${view}-sharing-${options.locale}-${options.theme}.png`), + }); + } + await context.close(); + } +} finally { + await browser.close(); + await new Promise((done) => server.close(done)); +} diff --git a/tests/ui/support/measure-scroll-refresh.mjs b/tests/ui/support/measure-scroll-refresh.mjs new file mode 100644 index 00000000..50e7b9d8 --- /dev/null +++ b/tests/ui/support/measure-scroll-refresh.mjs @@ -0,0 +1,88 @@ +// Local diagnostic, not a pass/fail test or an installed-VSIX measurement. +// Usage: node tests/ui/support/measure-scroll-refresh.mjs BASE_ROOT CANDIDATE_ROOT +import { createServer } from 'node:http'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { chromium } from '@playwright/test'; + +const roots = process.argv.slice(2).map((root) => resolve(root)); +if (roots.length !== 2) throw new Error('Pass baseline and candidate checkout roots'); +const fixtureNow = Date.parse('2026-07-20T12:00:00.000Z'); +const browser = await chromium.launch(); + +async function measure(root) { + const require = createRequire(resolve(root, 'package.json')); + const { renderHarness } = require(resolve(root, 'tests/ui/support/render-harness.cjs')); + const NativeDate = Date; + let html; + try { + globalThis.Date = class extends NativeDate { + constructor(...args) { super(...(args.length ? args : [fixtureNow])); } + static now() { return fixtureNow; } + }; + html = await renderHarness({ provider: 'codex', locale: 'en', theme: 'dark', fixture: 'default', autoRefresh: false }); + } finally { + globalThis.Date = NativeDate; + } + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + response.end(html); + }); + await new Promise((done) => server.listen(0, '127.0.0.1', done)); + try { + const trials = []; + for (let trial = 0; trial < 3; trial += 1) { + const context = await browser.newContext({ viewport: { width: 1280, height: 420 }, reducedMotion: 'reduce' }); + const page = await context.newPage(); + await page.addInitScript(() => { + window.__ccuAcks = []; + window.acquireVsCodeApi = () => ({ + getState: () => undefined, + setState: () => undefined, + postMessage: (message) => { if (message.command === 'dashboardDataPatchAck') window.__ccuAcks.push(message); }, + }); + }); + await page.clock.install({ time: new Date(fixtureNow) }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const result = await page.evaluate(async () => { + const panel = document.getElementById('provider-panel'); + if (!panel) throw new Error('Provider panel did not render'); + const filler = document.createElement('div'); + filler.style.height = '1600px'; + panel.appendChild(filler); + const patchHtml = panel.innerHTML; + let swaps = 0; + const observer = new MutationObserver((records) => { + swaps += records.filter((record) => record.type === 'childList').length; + }); + observer.observe(panel, { childList: true }); + scrollTo(0, 160); + let dispatchMs = 0; + for (let revision = 1; revision <= 20; revision += 1) { + window.dispatchEvent(new Event('scroll')); + const start = performance.now(); + window.dispatchEvent(new MessageEvent('message', { data: { + command: 'dashboardDataPatch', provider: 'codex', tab: 'today', revision, + html: patchHtml, claudeLast30HoursByDay: {}, + } })); + dispatchMs += performance.now() - start; + await new Promise((done) => setTimeout(done, 25)); + } + await new Promise((done) => setTimeout(done, 650)); + observer.disconnect(); + return { swaps, dispatchMs: Number(dispatchMs.toFixed(1)), acks: window.__ccuAcks.map((ack) => ack.revision) }; + }); + trials.push(result); + await context.close(); + } + return { root, trials }; + } finally { + await new Promise((done) => server.close(done)); + } +} + +try { + for (const root of roots) process.stdout.write(`${JSON.stringify(await measure(root))}\n`); +} finally { + await browser.close(); +} diff --git a/tests/ui/support/render-harness.cjs b/tests/ui/support/render-harness.cjs index 4cf96423..b00b8ff7 100644 --- a/tests/ui/support/render-harness.cjs +++ b/tests/ui/support/render-harness.cjs @@ -3,6 +3,23 @@ const Module = require('node:module'); const originalLoad = Module._load; +let latestHarnessPanel; + +function createHarnessPanel() { + const webview = { + html: '', + onDidReceiveMessage: () => ({ dispose: () => undefined }), + postMessage: async () => true, + }; + const panel = { + webview, + reveal: () => undefined, + onDidDispose: () => ({ dispose: () => undefined }), + }; + latestHarnessPanel = panel; + return panel; +} + const vscodeHost = { ColorThemeKind: { Light: 1, Dark: 2, HighContrast: 3, HighContrastLight: 4 }, ViewColumn: { One: 1 }, @@ -13,7 +30,7 @@ const vscodeHost = { authentication: { getSession: async () => undefined }, window: { activeColorTheme: { kind: 1 }, - createWebviewPanel: () => { throw new Error('UI harness does not create VS Code panels'); }, + createWebviewPanel: () => createHarnessPanel(), }, workspace: { workspaceFolders: undefined, @@ -318,8 +335,8 @@ function addClaudeData(provider, { fixture = 'default', enableContent = false } { date: '2026-07-20', data: claudeUsage(0.6) }, ], [ - { date: '2026-06', data: claudeUsage(5) }, - { date: '2026-07', data: claudeUsage(13) }, + { date: '2026-06-01', data: claudeUsage(5) }, + { date: '2026-07-01', data: claudeUsage(13) }, ], [ { hour: '18:00', data: claudeUsage(0.35) }, @@ -349,6 +366,11 @@ function addClaudeData(provider, { fixture = 'default', enableContent = false } '2026-07-20': [{ hour: '18:00', data: claudeUsage(0.6) }], }, claudeProjectUsageMatrix(), + [ + { date: '2026-06-01', data: claudeUsage(5) }, + { date: '2026-07-19', data: claudeUsage(0.4) }, + { date: '2026-07-20', data: claudeUsage(0.6) }, + ], ); if (completedWeeklyFixture) { provider.updateWeeklyQuotaHistory([{ @@ -431,6 +453,9 @@ exports.renderHarness = async function renderHarness({ adviceFeedback = 'none', timeZone = 'Asia/Hong_Kong', codexMonth = '', + claudeOnly = false, + commandTemplate = '', + commandAfterReset = false, } = {}) { I18n.setLanguage(locale); I18n.setTimezone(timeZone); @@ -535,9 +560,11 @@ exports.renderHarness = async function renderHarness({ }; } provider.updateProviderData( - view, + claudeOnly ? null : view, buildScopedCodexInsights(view), - { claude: true, codex: true }, + claudeOnly + ? { claude: true, codex: false, codexData: false } + : { claude: true, codex: true }, ); provider.currentProvider = selectedProvider; @@ -548,7 +575,17 @@ exports.renderHarness = async function renderHarness({ return provider.renderCodexMonthDailyDetail(codexMonth, rows); } - const html = provider.getWebviewContent(); + latestHarnessPanel = undefined; + if (commandTemplate) { + provider.showSharingWorkspace(commandTemplate); + if (commandAfterReset) { + provider.clearSharingRuntimeState(); + provider.showSharingWorkspace(commandTemplate); + } + } + const html = latestHarnessPanel + ? latestHarnessPanel.webview.html + : provider.getWebviewContent(); const fixtureHtml = persistedDetailsFixture ? html.replace('
{ const codexMonth = /^\d{4}-\d{2}$/.test(requestedCodexMonth) ? requestedCodexMonth : ''; + const claudeOnly = url.searchParams.get('claudeOnly') === 'true'; + const requestedCommandTemplate = url.searchParams.get('commandTemplate') ?? ''; + const commandTemplate = ['claudeShareCard', 'claudeHeatmap'].includes(requestedCommandTemplate) + ? requestedCommandTemplate + : ''; + const commandAfterReset = url.searchParams.get('commandAfterReset') === 'true'; const html = await renderHarness({ provider, locale, @@ -77,6 +83,9 @@ const server = createServer(async (request, response) => { adviceFeedback, timeZone, codexMonth, + claudeOnly, + commandTemplate, + commandAfterReset, }); response.writeHead(200, { 'content-type': 'text/html; charset=utf-8',