diff --git a/CHANGELOG.md b/CHANGELOG.md index 8179d6fd..00cd6c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel ## [2.2.1] — Unreleased +### Added +- **Sessions: all sessions + filters (#73)** — the Sessions tab can show all + sessions and adds persisted time-range, project, and model filters. Thanks + [@Carl723000](https://github.com/Carl723000). +- **Reset countdown formats (#75, closes #74)** — quota reset countdowns can use + decimal, whole-unit, or local clock/date formats. Thanks + [@projectronic](https://github.com/projectronic). +- **Indonesian timezone presets (#77)** — the timezone picker now includes WIB, + WITA, and WIT presets. Thanks [@projectronic](https://github.com/projectronic). + ### Changed - **Codex maintenance handoff** — `AGENTS.md` is now the canonical repository policy with a Simplified-Chinese review copy. Claude Code and OpenAI Codex @@ -46,11 +56,6 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel ## [2.2.0] — 2026-07-07 ### Added -- **`resetCountdownFormat`** (`decimal` / `units` / `clock`, default `decimal`) — - the reset countdown appended by `showResetInStatusBar` can now read whole - hour/day units (`4h 48m` / `1d 14h`) or your computer's local clock time / - date (`18:20` / `2026-07-22`) instead of only the compact decimal form - (`4.8h` / `1.6d`). (#74) - **`tokenDecimalPlaces`** (default 1, 0–2) — decimals for the *compact* token display (`1.2M` / `345.6K`); full integer counts are unaffected. - **Cache-hit-rate column** in the All-time (monthly) and This-month (daily) diff --git a/README-zh-CN.md b/README-zh-CN.md index e5b3c0a6..e0d45533 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -60,6 +60,20 @@ AI 建议生成的是一份 **Markdown 文档**,用文字展示比截图更直 --- +## 2.2 新功能 + +- **用量分享卡**(可选,`enableShareCard`):一张可配置的单页 SVG 用量卡——自选时间范围 × 范围(总体 / 工程 / 会话)× 展示哪些指标,以及主题(**Claude 经典橙** / **奶油** / **极光暗色** / **自动**),可选带上 GitHub 头像与名称。自包含、可复现;提示词、路径、ID 一律不出本机。中文语言下使用 万/亿 单位。 +- **只读会话查看器**(会话标签,**默认开启**):每行的「查看」按钮以只读方式重开某个历史会话的提示词与 Markdown 渲染的回答,帮你回忆内容,而**不必**像「恢复」那样把它重新塞回模型上下文。思考与工具调用收进折叠区,默认展示最近若干轮。 +- **Token 热力图**(可选,`showHeatmap`):All 标签顶部的 GitHub 风格年度 token 热力图,并可**导出 / 发布到你的 GitHub 主页**(自包含 SVG,一键复制 Markdown 嵌入代码)。 +- **实验性洞察**(可选,`showInsights`,Content 标签):基于本地日志的启发式估算(均标注为估算)——**缓存损耗账单**(模型切换 / 空闲导致重写缓存的花费)、**各模型缓存保温时长**、**大单轮**、**你的活跃时段**、以及**技能 ROI**(每美元产出的输出 token)。 +- **最贵的 10 条消息**(可选,`showCostliestMessages`):按成本排序单轮对话,区分**缓存未命中**与长回答,附缓存命中率与距上一轮的间隔。 +- **会话「活跃」列**:每个会话的估算实际操作时长(空闲间隔按 1.5 小时封顶),比原始的首尾时间跨度更有意义。 +- **缓存命中率列**:All-time(按月)与本月(按日)表格及其下钻均新增该列,逐行可见缓存效率。 +- **实时刷新延迟**(`fileWatchSeconds`:关 / 1 / 2 / 5 / 10 / 20 / 30 秒)取代原来的开 / 关切换;只重读**本地**日志。 +- **时区感知分桶**:Today / 日 / 月 / 小时 的统计都以你配置的 IANA 时区为准(下拉含完整 UTC 偏移),彼此之间以及与 Anthropic 控制台保持一致。 +- **升级后「新功能」提示**:每次首次运行新的 major.minor 版本时给一次可关闭的提示,让可选功能不被埋没。 +- **修复**:Sonnet 5 正确上报 1M 上下文窗口(#50);日志带 TTL 拆分时,1 小时缓存写入按 2× 基础输入计价(#62);多窗口下后台窗口在获得焦点时刷新、不再显示过期数据(#55);时区设置改为校验过的下拉框,非法值不再拖垮仪表板(#51);德语(de-DE)与巴西葡语(pt-BR)在各处均可选。 + ## 2.1 新功能 - **会话:恢复 / 拷贝 / 删除**:每行可拷贝会话 ID、**恢复**(本工程会话优先在官方 Claude Code 扩展的新 tab 打开;其他工程的会话在其所在目录开终端执行 `claude --resume `,因为官方 in-tab 恢复用的是当前工作区目录、找不到跨工程会话)、或**删除**(移至回收站,需确认)。并新增「当前工程 / 全部」筛选,默认当前工程。 diff --git a/README.md b/README.md index 640be32a..e8bf3f21 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,47 @@ consent prompt. --- +## What's new in 2.2 + +- **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. + 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" + button re-opens a past session's prompts and Markdown-rendered answers so you + can jog your memory **without** loading it back into the model's context + (unlike resume). Thinking and tool traffic sit behind toggles; opens on the + last rounds. +- **Token heatmap** (opt-in, `showHeatmap`) — a GitHub-style yearly token heatmap + on the All tab, plus **Export / Publish to your GitHub profile** as a + self-contained SVG with a one-click Markdown embed. +- **Experimental insights** (opt-in, `showInsights`, Content tab) — labelled + estimates from your local logs: a **cache-churn bill** ($ spent re-writing + cache after model switches / idle gaps), **cache warmth by model**, **big + one-shot turns**, **your active hours**, and **skill ROI** (output tokens + returned per $). +- **Top-10 costliest messages** (opt-in, `showCostliestMessages`) — ranks single + turns by cost, splitting a **cache miss** from a long answer, with the + cache-hit rate and the time since the last turn. +- **Sessions "Active" column** — estimated hands-on time per session (idle gaps + capped at 1.5 h), far more meaningful than the raw first-to-last span. +- **Cache-hit-rate column** in the All-time (monthly) and This-month (daily) + tables and their drill-downs, so cache efficiency is visible per row. +- **Live-refresh delay** (`fileWatchSeconds`: Off / 1 / 2 / 5 / 10 / 20 / 30 s) + replaces the on/off live-watch toggle; it only re-reads your **local** logs. +- **Timezone-aware bucketing** — Today / day / month / hour totals all key off + your configured IANA zone (full UTC-offset dropdown), so they agree with each + other and the Anthropic console. +- **"What's new" prompt after upgrades** — a single dismissible nudge the first + time you run a new major.minor version, so opt-in features stay discoverable. +- **Fixes** — Sonnet 5 reports a 1M context window (#50); 1-hour cache writes are + priced at 2× base input when the log carries the split (#62); background + windows refresh on focus instead of going stale (#55); the Timezone setting is + a validated dropdown so a bad value can't crash the dashboard (#51); German + (de-DE) and Brazilian Portuguese (pt-BR) are selectable everywhere. + ## What's new in 2.1 - **Sessions: resume / copy / delete** — each row can copy the session id, diff --git a/src/dataLoader.ts b/src/dataLoader.ts index 4428815e..23698d7e 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -2076,9 +2076,11 @@ export class ClaudeDataLoader { * Group records by their source session (.jsonl file) and aggregate usage per session. * Returns sessions with billable usage, sorted by most recent activity first. * @param records All loaded usage records - * @param limit Maximum number of sessions to return (default 50) + * @param limit Maximum number of sessions to return. Default 1000 — a DOM-safety + * cap, not a "recent" window: the Sessions tab shows all of a normal user's + * sessions and narrows them with client-side time / project / model filters. */ - static getSessionBreakdown(records: ClaudeUsageRecord[], limit: number = 50): SessionUsage[] { + static getSessionBreakdown(records: ClaudeUsageRecord[], limit: number = 1000): SessionUsage[] { const recordsBySession: Record = {}; for (const record of records) { diff --git a/src/i18n.ts b/src/i18n.ts index 6d0084d6..db8bc872 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -145,6 +145,10 @@ export interface Translations { resumeInvalid: string; sessionFilterCurrent: string; sessionFilterAll: string; + sessionRangeToday: string; + sessionRange7d: string; + sessionRange30d: string; + sessionModelAll: string; deleteSession: string; deleteSessionConfirm: string; deleteSessionDetail: string; @@ -351,6 +355,10 @@ const translations: Record = { resumeInvalid: 'Invalid session id — cannot resume.', sessionFilterCurrent: 'Current project', sessionFilterAll: 'All', + sessionRangeToday: 'Today', + sessionRange7d: '7 days', + sessionRange30d: '30 days', + sessionModelAll: 'All models', deleteSession: 'Delete session', deleteSessionConfirm: 'Delete session "{name}"?', deleteSessionDetail: 'Its conversation log moves to the trash (recoverable). The extension is otherwise read-only.', @@ -573,6 +581,10 @@ const translations: Record = { resumeInvalid: 'Ungültige Sitzungs-ID — Fortsetzen nicht möglich.', sessionFilterCurrent: 'Aktuelles Projekt', sessionFilterAll: 'Alle', + sessionRangeToday: 'Heute', + sessionRange7d: '7 Tage', + sessionRange30d: '30 Tage', + sessionModelAll: 'Alle Modelle', deleteSession: 'Sitzung löschen', deleteSessionConfirm: 'Sitzung "{name}" löschen?', deleteSessionDetail: 'Das Gesprächsprotokoll wandert in den Papierkorb (wiederherstellbar). Die Erweiterung ist ansonsten schreibgeschützt.', @@ -794,6 +806,10 @@ const translations: Record = { resumeInvalid: '無效的會話 ID,無法恢復。', sessionFilterCurrent: '目前專案', sessionFilterAll: '全部', + sessionRangeToday: '今天', + sessionRange7d: '7 天', + sessionRange30d: '30 天', + sessionModelAll: '全部模型', deleteSession: '刪除會話', deleteSessionConfirm: '刪除會話「{name}」?', deleteSessionDetail: '對話記錄會移至垃圾桶(可復原)。此擴充功能其餘部分為唯讀。', @@ -1012,6 +1028,10 @@ const translations: Record = { resumeInvalid: '无效的会话 ID,无法恢复。', sessionFilterCurrent: '当前工程', sessionFilterAll: '全部', + sessionRangeToday: '今天', + sessionRange7d: '7 天', + sessionRange30d: '30 天', + sessionModelAll: '全部模型', deleteSession: '删除会话', deleteSessionConfirm: '删除会话「{name}」?', deleteSessionDetail: '对话日志将移至回收站(可恢复)。本扩展其余部分为只读。', @@ -1234,6 +1254,10 @@ const translations: Record = { resumeInvalid: '無効なセッションIDのため再開できません。', sessionFilterCurrent: '現在のプロジェクト', sessionFilterAll: 'すべて', + sessionRangeToday: '今日', + sessionRange7d: '7日間', + sessionRange30d: '30日間', + sessionModelAll: 'すべてのモデル', deleteSession: 'セッションを削除', deleteSessionConfirm: 'セッション「{name}」を削除しますか?', deleteSessionDetail: '会話ログはゴミ箱に移動します(復元可能)。この拡張機能は他の部分では読み取り専用です。', @@ -1457,6 +1481,10 @@ const translations: Record = { resumeInvalid: '잘못된 세션 ID입니다 — 재개할 수 없습니다.', sessionFilterCurrent: '현재 프로젝트', sessionFilterAll: '전체', + sessionRangeToday: '오늘', + sessionRange7d: '7일', + sessionRange30d: '30일', + sessionModelAll: '전체 모델', deleteSession: '세션 삭제', deleteSessionConfirm: '세션 "{name}"을(를) 삭제할까요?', deleteSessionDetail: '대화 로그가 휴지통으로 이동합니다(복구 가능). 확장 프로그램은 그 외에는 읽기 전용입니다.', @@ -1680,6 +1708,10 @@ const translations: Record = { resumeInvalid: 'ID de sessão inválido — não é possível retomar.', sessionFilterCurrent: 'Projeto atual', sessionFilterAll: 'Todos', + sessionRangeToday: 'Hoje', + sessionRange7d: '7 dias', + sessionRange30d: '30 dias', + sessionModelAll: 'Todos os modelos', deleteSession: 'Excluir sessão', deleteSessionConfirm: 'Excluir a sessão "{name}"?', deleteSessionDetail: 'O log da conversa vai para a lixeira (recuperável). A extensão é somente leitura no restante.', diff --git a/src/webview.ts b/src/webview.ts index 9d605530..2f7e9900 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -1710,6 +1710,8 @@ export class UsageWebviewProvider { // first→last span. Computed once for the whole table. const activeMap = ClaudeDataLoader.activeDurationBySession(this.allRecords); let rows = ''; + // Distinct short model names across all sessions → the model filter dropdown. + const modelSet = new Set(); this.sessionBreakdown.forEach((s) => { const d = s.data; const foreign = workspacePath @@ -1746,9 +1748,13 @@ export class UsageWebviewProvider { : thinkingHidden ? t.thinkingHiddenShort : '-'; + // Distinct short model names for this session → the model filter matches on these. + const rowModels = [...new Set(Object.keys(d.modelBreakdown || {}).map((m) => this.shortModelName(m)))]; + rowModels.forEach((m) => modelSet.add(m)); rows += ' '' + label + ''; - // Filter the list to the current project (default) or all. + // Filter bar above the table: time range · project · model. All three narrow the + // list client-side; the list now defaults to ALL sessions and ALL projects. const total = this.sessionBreakdown.length; const foreignCount = total - currentCount; const showToggle = !!workspacePath && currentCount > 0 && foreignCount > 0; - const filterBar = showToggle - ? '
' + - '' + - '' + - '
' - : ''; + const modelOptions = [...modelSet] + .sort() + .map((m) => '') + .join(''); + const filterBar = + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + (showToggle + ? '
' + + '' + + '' + + '
' + : '') + + (modelOptions + ? '' + : '') + + '
'; return ( '
' + '

' + t.sessionBreakdown + '

' + '

' + t.sortHint + '

' + filterBar + - '
' + + '
' + '
' + '' + '' + @@ -3782,11 +3806,17 @@ export class UsageWebviewProvider { border-color: var(--vscode-errorForeground, #f44336); } - /* Sessions "current project / all" filter. */ + /* Sessions filter bar: time range · project · model. */ + .sess-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin: 0 0 10px; + } .sess-filter { display: flex; gap: 6px; - margin: 0 0 10px; } .sess-filter-btn { background: none; @@ -3802,8 +3832,13 @@ export class UsageWebviewProvider { color: var(--vscode-button-foreground); border-color: var(--vscode-button-background); } - .session-list.show-current .session-foreign { - display: none; + .sess-model-select { + background: var(--vscode-dropdown-background); + color: var(--vscode-dropdown-foreground); + border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border)); + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; } /* iOS-style auto-refresh toggle. The label/switch sit next to the other @@ -5380,28 +5415,81 @@ function viewConversation(btn) { }); } -function applySessionFilter(mode) { +function ccuSessState() { + var pb = document.querySelector('.sess-filter[data-group="project"] .sess-filter-btn.active'); + var rb = document.querySelector('.sess-filter[data-group="range"] .sess-filter-btn.active'); + var ms = document.querySelector('.sess-model-select'); + return { + project: pb ? pb.getAttribute('data-mode') : 'all', + range: rb ? rb.getAttribute('data-range') : 'all', + model: ms ? ms.value : 'all' + }; +} +function applySessionFilters() { var list = document.getElementById('sessionList'); if (!list) { return; } - list.classList.toggle('show-current', mode === 'current'); - document.querySelectorAll('.sess-filter-btn').forEach(function(b) { - b.classList.toggle('active', b.getAttribute('data-mode') === mode); + var st = ccuSessState(); + var now = Date.now(); + var threshold = st.range === 'today' ? new Date().setHours(0, 0, 0, 0) + : st.range === '7' ? now - 7 * 86400000 + : st.range === '30' ? now - 30 * 86400000 + : 0; + list.querySelectorAll('tr.sort-row').forEach(function(tr) { + var okProject = st.project === 'all' || !tr.classList.contains('session-foreign'); + var okTime = st.range === 'all' || Number(tr.getAttribute('data-sort-time')) >= threshold; + var okModel = st.model === 'all' || (tr.getAttribute('data-models') || '').split('|').indexOf(st.model) !== -1; + tr.style.display = (okProject && okTime && okModel) ? '' : 'none'; }); } +// One handler per filter group; all persist and re-apply the combined filter. +function ccuSetActive(btn) { + var group = btn.parentNode; + if (!group) { return; } + group.querySelectorAll('.sess-filter-btn').forEach(function(b) { b.classList.remove('active'); }); + btn.classList.add('active'); +} function sessionFilter(btn) { if (!btn) { return; } - var mode = btn.getAttribute('data-mode'); - applySessionFilter(mode); - // Persist so the full-document reload (e.g. after a delete) keeps the filter. - try { localStorage.setItem('ccu.sessionFilter', mode); } catch (e) {} + ccuSetActive(btn); + try { localStorage.setItem('ccu.sessionFilter', btn.getAttribute('data-mode')); } catch (e) {} + applySessionFilters(); +} +function sessionRange(btn) { + if (!btn) { return; } + ccuSetActive(btn); + try { localStorage.setItem('ccu.sessionRange', btn.getAttribute('data-range')); } catch (e) {} + applySessionFilters(); +} +function sessionModel(sel) { + if (!sel) { return; } + try { localStorage.setItem('ccu.sessionModel', sel.value); } catch (e) {} + applySessionFilters(); } function restoreSessionFilter() { + // Restore each dimension from localStorage (survives the post-delete reload), + // then apply once. Defaults (nothing stored) = All range, All projects, All models. try { var mode = localStorage.getItem('ccu.sessionFilter'); - if (mode && document.querySelector('.sess-filter-btn[data-mode="' + mode + '"]')) { - applySessionFilter(mode); + if (mode) { + var pb = document.querySelector('.sess-filter[data-group="project"] .sess-filter-btn[data-mode="' + mode + '"]'); + if (pb) { ccuSetActive(pb); } + } + var range = localStorage.getItem('ccu.sessionRange'); + if (range) { + var rb = document.querySelector('.sess-filter[data-group="range"] .sess-filter-btn[data-range="' + range + '"]'); + if (rb) { ccuSetActive(rb); } + } + var model = localStorage.getItem('ccu.sessionModel'); + if (model) { + var ms = document.querySelector('.sess-model-select'); + if (ms) { + for (var i = 0; i < ms.options.length; i++) { + if (ms.options[i].value === model) { ms.value = model; break; } + } + } } } catch (e) {} + applySessionFilters(); } function copyPath(btn, e) {