Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel
current calendar month's total cost ($(calendar) icon) instead of today's
cost. Hover tooltip mirrors the today tooltip with month-to-date token and
cost breakdown.
- **Sessions: resume / copy / delete** — each session row can copy its id, copy
its project path, resume it (in the
official Claude Code extension, or a terminal for cross-project sessions), or
delete it (to the trash, after a confirm); plus a Current project / All filter.
- **Quota display options** — `quotaFiveHourOnly` (show only the 5-hour window)
and `showResetInStatusBar` (append a compact reset countdown) in the ⚙ Settings
tab. The default stays the clean `5h 6% · wk 1%`; full reset times always live
in the tooltip. To hide cost, set `statusBarMetric` to `tokens`.
- **Sturdier quota** — the last `/usage` result is cached to disk and shown
instantly on startup; on a 429 the fetch backs off instead of hammering the
endpoint.
- **Wider dashboard** (up to 1600 px) with indented sub-project rows; status-bar
setting changes apply without a full dashboard reload.
- **Weekly Opus limit in the status bar** — opt-in `showOpusWeekly` (default
off) appends `opus:NN%` after the 5h / weekly quota figures, for heavy Opus
users who want an at-a-glance weekly Opus signal. Merged from
Expand Down
3 changes: 3 additions & 0 deletions README-zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ AI 建议生成的是一份 **Markdown 文档**,用文字展示比截图更直

## 2.1 新功能

- **会话:恢复 / 拷贝 / 删除**:每行可拷贝会话 ID、**恢复**(本工程会话优先在官方 Claude Code 扩展的新 tab 打开;其他工程的会话在其所在目录开终端执行 `claude --resume <id>`,因为官方 in-tab 恢复用的是当前工作区目录、找不到跨工程会话)、或**删除**(移至回收站,需确认)。并新增「当前工程 / 全部」筛选,默认当前工程。
- **配额显示选项**:`quotaFiveHourOnly`(只显示 5 小时配额窗口)、`showResetInStatusBar`(在状态栏配额后附加重置倒计时,如 `5h:50%:2.3h | wk:30%:3.2d`),均在 ⚙ 设置标签页中。(想隐藏金额:把现有的「状态栏指标」切到 tokens 即可。)
- **详情页加宽**:详情页加宽至 1600px,窄屏下仍自适应。
- **工作流标签页**:所有多代理运行集中查看:动态工作流(ultracode)和临时子代理批次,含每次运行的成本、代理数、所用模型、**缓存命中率**("我的供应商是否适配工作流"的诊断信号),以及按任务内容标注的逐代理明细。
- **用量追踪面板**:对标官方 `/usage` 的"用量构成"视图,但支持全部模型 / 供应商和五档范围(日 / 周 / 月 / 会话 / 项目):>150k 上下文占比、8 小时以上会话占比、子代理密集占比、工作流占比,以及 Skills/子代理/插件/模型四类细分。今日标签页有精简卡片。
- **思考占比**:每会话的估算思考 token 占比(Sessions 列 + 今日卡片),过高时提示改用 `/effort`。
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ consent prompt.

## What's new in 2.1

- **Sessions: resume / copy / delete** — each row can copy the session id,
**resume** it (official Claude Code extension in-tab for this project, or a
terminal `claude --resume <id>` for other projects), or **delete** it (to the
trash, with confirm). A **Current project / All** filter defaults to the
current project.
- **Quota display options** — `quotaFiveHourOnly` (show only the 5h
window) and `showResetInStatusBar` (append the reset countdown, e.g.
`5h:50%:2.3h | wk:30%:3.2d`), both in ⚙ Settings.
- **Wider dashboard** — detail page widened to 1600 px, still fluid
on narrow screens.
- **Workflows tab** — every multi-agent run in one place: dynamic-workflow
runs (ultracode) *and* ad-hoc sub-agent batches, with per-run cost, agent
count, models used, **cache hit rate** (the "is my provider workflow-ready"
Expand Down
36 changes: 36 additions & 0 deletions src/dataLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,42 @@ export class ClaudeDataLoader {
return claudePaths.length > 0 ? claudePaths[0] : null;
}

/** Locate the .jsonl log file(s) and sub-agent dir for a session id. */
static async findSessionFiles(sessionId: string, dataDirectory?: string | null): Promise<string[]> {
if (!sessionId || /[\\/]/.test(sessionId) || sessionId === 'unknown') { return []; }
const roots = new Set<string>(this.getClaudePaths());
if (dataDirectory) { roots.add(dataDirectory); }
const fileName = sessionId + '.jsonl';
const matches: string[] = [];
const walk = async (dir: string): Promise<void> => {
let entries;
try {
entries = await fs.promises.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === sessionId) {
matches.push(full); // session's sub-agent container — remove whole
} else {
await walk(full);
}
} else if (e.isFile() && e.name === fileName) {
matches.push(full);
}
}
};
for (const root of roots) {
const projectsDir = path.join(root, CLAUDE_PROJECTS_DIR_NAME);
if (fs.existsSync(projectsDir)) {
await walk(projectsDir);
}
}
return matches;
}

static async loadUsageRecords(
dataDirectory?: string,
options?: { analyzeContent?: boolean; windowDays?: number; log?: (line: string) => void }
Expand Down
88 changes: 81 additions & 7 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,17 @@ export class ClaudeCodeUsageExtension {
dataDirectory: string | null;
usageLimits: ClaudeApiUsageResponse | null;
usageLimitsLastUpdate: Date;
usageLimitsBackoffUntil: Date;
usageLimitsFailStreak: number;
} = {
records: [],
contentAnalysis: null,
lastUpdate: new Date(0),
dataDirectory: null,
usageLimits: null,
usageLimitsLastUpdate: new Date(0)
usageLimitsLastUpdate: new Date(0),
usageLimitsBackoffUntil: new Date(0),
usageLimitsFailStreak: 0
};

private outputChannel: vscode.OutputChannel;
Expand All @@ -52,6 +56,8 @@ export class ClaudeCodeUsageExtension {
// more refresh after the current one finishes, instead of dropping the event
// (which starved updates during rapid ultracode/sub-agent writes).
private pendingRefresh: boolean = false;
// True when a coalesced refresh was a manual one, so the follow-up forces a full reload.
private pendingManual: boolean = false;
// Epoch ms of the last observed .jsonl change. Drives activity-aware
// refresh cadence: while Claude Code is actively writing we refresh faster
// (~15 s, quota cache 20 s); when idle we fall back to the user's interval.
Expand Down Expand Up @@ -84,10 +90,11 @@ export class ClaudeCodeUsageExtension {
// it tell us when the user changes a setting there so we re-apply config
// (globalState changes don't fire onDidChangeConfiguration).
this.webviewProvider.settings = this.settings;
this.webviewProvider.onSettingsChanged = () => this.onConfigurationChanged();
this.webviewProvider.onSettingsChanged = (key) => this.onSettingsChangedFromPanel(key);

this.setupCommands();
this.loadConfiguration();
this.loadPersistedQuota();
this.startAutoRefresh();
this.refreshData().then(() => this.startFileWatching());
console.log('Claude Code Usage Extension: Initialization complete');
Expand Down Expand Up @@ -351,7 +358,7 @@ export class ClaudeCodeUsageExtension {
I18n.setDecimalPlaces(config.decimalPlaces);
I18n.setCompactNumbers(config.compactNumbers);
I18n.setTimezone(config.timezone);
this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly);
this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar);

// Listen for configuration changes
vscode.workspace.onDidChangeConfiguration(e => {
Expand All @@ -366,6 +373,8 @@ export class ClaudeCodeUsageExtension {
// quota fetch + refresh so it reappears without needing a new window.
vscode.workspace.onDidChangeWorkspaceFolders(() => {
this.cache.usageLimitsLastUpdate = new Date(0);
this.cache.usageLimitsBackoffUntil = new Date(0);
this.cache.usageLimitsFailStreak = 0;
this.quotaColdRetryDone = false;
this.refreshData();
});
Expand All @@ -389,6 +398,8 @@ export class ClaudeCodeUsageExtension {
statusBarMetric: s.get<'cost' | 'monthly-cost' | 'tokens'>('statusBarMetric'),
showOpusWeekly: s.get<boolean>('showOpusWeekly'),
usageLimitTracking: s.get<boolean>('usageLimitTracking'),
quotaFiveHourOnly: s.get<boolean>('quotaFiveHourOnly'),
showResetInStatusBar: s.get<boolean>('showResetInStatusBar'),
adviceApiKey: s.get<string>('advice.apiKey'),
adviceApiUrl: s.get<string>('advice.apiUrl'),
adviceModel: s.get<string>('advice.model'),
Expand All @@ -408,13 +419,33 @@ export class ClaudeCodeUsageExtension {
};
}

// Settings whose change only affects the status bar (no dashboard reload).
private static readonly STATUS_BAR_ONLY_SETTINGS = new Set([
// usageLimitTracking is intentionally excluded: turning it on must trigger a
// /usage fetch (the full reload path), else the quota stays empty until the
// next tick.
'showCost', 'showContext', 'statusBarMetric',
'showOpusWeekly', 'quotaFiveHourOnly', 'showResetInStatusBar',
]);

/** Dashboard Settings change — status-bar-only toggles apply in place, others reload. */
private onSettingsChangedFromPanel(key?: string): void {
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.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar);
this.statusBar.updateQuota(this.cache.usageLimits ?? null);
return;
}
this.onConfigurationChanged();
}

private onConfigurationChanged(): void {
const config = this.getConfiguration();
I18n.setLanguage(config.language as any);
I18n.setDecimalPlaces(config.decimalPlaces);
I18n.setCompactNumbers(config.compactNumbers);
I18n.setTimezone(config.timezone);
this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly);
this.statusBar.setVisibility(config.showCost, config.showContext, config.usageLimitTracking, config.statusBarMetric, config.showOpusWeekly, config.quotaFiveHourOnly, config.showResetInStatusBar);

// Restart auto-refresh with new interval
this.startAutoRefresh();
Expand Down Expand Up @@ -530,10 +561,36 @@ export class ClaudeCodeUsageExtension {
}

/** Fetch real usage limits via OAuth, cached for 2 minutes. */
// Persist the last-known quota across reloads/restarts.
private static readonly QUOTA_STATE_KEY = 'ccu.usageLimits';

private loadPersistedQuota(): void {
if (!this.getConfiguration().usageLimitTracking) {
return;
}
try {
const saved = this.context.globalState.get<{ data: ClaudeApiUsageResponse; ts: number }>(
ClaudeCodeUsageExtension.QUOTA_STATE_KEY
);
if (saved && saved.data) {
this.cache.usageLimits = saved.data;
this.cache.usageLimitsLastUpdate = new Date(saved.ts || 0);
this.statusBar.updateQuota(saved.data);
}
} catch {
/* ignore corrupt persisted state */
}
}

private async maybeFetchUsageLimits(config: ExtensionConfig): Promise<ClaudeApiUsageResponse | null> {
if (!config.usageLimitTracking) {
return null;
}
const now = Date.now();
// While backing off after a 429, return the cached value without refetching.
if (now < this.cache.usageLimitsBackoffUntil.getTime()) {
return this.cache.usageLimits;
}
const age = Date.now() - this.cache.usageLimitsLastUpdate.getTime();
// Activity-aware cache: 20 s while Claude Code is actively writing (so the
// quota keeps pace during high-consumption ultracode runs), 120 s when
Expand All @@ -554,9 +611,22 @@ export class ClaudeCodeUsageExtension {
if (fetched) {
this.cache.usageLimits = fetched;
this.cache.usageLimitsLastUpdate = new Date();
this.cache.usageLimitsFailStreak = 0;
// Even on success, hold off /usage for 30s — this floor also covers the
// expired-window bypass so a just-rolled window can't trigger an immediate
// refetch.
this.cache.usageLimitsBackoffUntil = new Date(Date.now() + 30000);
// Write through to disk so the next startup/reload has it instantly.
void this.context.globalState.update(
ClaudeCodeUsageExtension.QUOTA_STATE_KEY,
{ data: fetched, ts: Date.now() }
);
return fetched;
}
// Keep showing the last known value if a refresh fails.
// Failed (usually a 429). Exponentially back off — 60s, 120s … capped at 10 min.
this.cache.usageLimitsFailStreak++;
const backoffMs = Math.min(600000, 60000 * Math.pow(2, this.cache.usageLimitsFailStreak - 1));
this.cache.usageLimitsBackoffUntil = new Date(now + backoffMs);
return this.cache.usageLimits;
}

Expand All @@ -580,6 +650,7 @@ export class ClaudeCodeUsageExtension {
// one more after the current finishes (see finally). Dropping the event
// outright starved updates during rapid ultracode / sub-agent writes.
this.pendingRefresh = true;
if (manualTrigger) { this.pendingManual = true; }
return;
}
this.isRefreshing = true;
Expand Down Expand Up @@ -633,8 +704,9 @@ export class ClaudeCodeUsageExtension {
// this avoids pointless work (and CPU spikes) while you are not running code.
const latestMtime = await ClaudeDataLoader.getLatestModifiedTime(dataDirectory);
const dirChanged = this.cache.dataDirectory !== dataDirectory;
// A manual refresh always reloads from disk (a delete doesn't bump mtimes).
const needFullRefresh =
dirChanged || this.cache.records.length === 0 || latestMtime > this.cache.lastUpdate.getTime();
manualTrigger || dirChanged || this.cache.records.length === 0 || latestMtime > this.cache.lastUpdate.getTime();

if (!needFullRefresh) {
// Idle: logs unchanged. Quota was already refreshed above. Still
Expand Down Expand Up @@ -731,7 +803,9 @@ export class ClaudeCodeUsageExtension {
// number of dropped triggers into a single follow-up.
if (this.pendingRefresh) {
this.pendingRefresh = false;
setTimeout(() => this.refreshData(), 0);
const manual = this.pendingManual;
this.pendingManual = false;
setTimeout(() => this.refreshData(manual), 0);
}
}
}
Expand Down
Loading
Loading