From 17076a751fa4fcf3963e7f598446a0404dcee8ae Mon Sep 17 00:00:00 2001 From: Sean Yang Date: Fri, 26 Jun 2026 11:07:43 +0800 Subject: [PATCH] feat: session resume/copy/delete + quota display options & resilience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Sessions tab actions** — copy the session id / project path, resume (official Claude Code extension for a same-project session, terminal `claude --resume` otherwise), delete (to OS trash, with a modal confirm), and a Current project / All filter. - **Quota display** — `quotaFiveHourOnly` and `showResetInStatusBar` (e.g. `5h:50%:2.3h`) settings; hide cost via the existing `statusBarMetric: tokens`. - **Quota resilience** — last `/usage` cached to disk and shown on startup; 429 exponential back-off; a 30 s floor after a success. - **Dashboard** — 1600 px width, indented sub-projects, status-bar settings apply without a full reload, and the active tab is kept in localStorage across refreshes. Compile clean; 16/16 node:test green. --- CHANGELOG.md | 12 ++ README-zh-CN.md | 3 + README.md | 10 + src/dataLoader.ts | 36 ++++ src/extension.ts | 88 ++++++++- src/i18n.ts | 102 ++++++++++ src/sessionResume.ts | 27 +++ src/settings.ts | 20 ++ src/statusBar.ts | 49 ++++- src/test/sessionResume.test.ts | 44 +++++ src/types.ts | 4 + src/webview.ts | 327 +++++++++++++++++++++++++++++++-- 12 files changed, 690 insertions(+), 32 deletions(-) create mode 100644 src/sessionResume.ts create mode 100644 src/test/sessionResume.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5718d89f..a7e25bb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel ## [2.1.0] — Unreleased ### Added +- **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` (5-hour window only) and + `showResetInStatusBar` (append the reset countdown, e.g. `5h:50%:2.3h`) in the + ⚙ Settings tab; 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 diff --git a/README-zh-CN.md b/README-zh-CN.md index 5a541f9e..29a1062e 100644 --- a/README-zh-CN.md +++ b/README-zh-CN.md @@ -62,6 +62,9 @@ AI 建议生成的是一份 **Markdown 文档**,用文字展示比截图更直 ## 2.1 新功能 +- **会话:恢复 / 拷贝 / 删除**:每行可拷贝会话 ID、**恢复**(本工程会话优先在官方 Claude Code 扩展的新 tab 打开;其他工程的会话在其所在目录开终端执行 `claude --resume `,因为官方 in-tab 恢复用的是当前工作区目录、找不到跨工程会话)、或**删除**(移至回收站,需确认)。并新增「当前工程 / 全部」筛选,默认当前工程。 +- **配额显示选项**:`quotaFiveHourOnly`(只显示 5 小时配额窗口)、`showResetInStatusBar`(在状态栏配额后附加重置倒计时,如 `5h:50%:2.3h | wk:30%:3.2d`),均在 ⚙ 设置标签页中。(想隐藏金额:把现有的「状态栏指标」切到 tokens 即可。) +- **详情页加宽**:详情页加宽至 1600px,窄屏下仍自适应。 - **工作流标签页**:所有多代理运行集中查看:动态工作流(ultracode)和临时子代理批次,含每次运行的成本、代理数、所用模型、**缓存命中率**("我的供应商是否适配工作流"的诊断信号),以及按任务内容标注的逐代理明细。 - **用量追踪面板**:对标官方 `/usage` 的"用量构成"视图,但支持全部模型 / 供应商和五档范围(日 / 周 / 月 / 会话 / 项目):>150k 上下文占比、8 小时以上会话占比、子代理密集占比、工作流占比,以及 Skills/子代理/插件/模型四类细分。今日标签页有精简卡片。 - **思考占比**:每会话的估算思考 token 占比(Sessions 列 + 今日卡片),过高时提示改用 `/effort`。 diff --git a/README.md b/README.md index da5ed983..5702365f 100644 --- a/README.md +++ b/README.md @@ -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 ` 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" diff --git a/src/dataLoader.ts b/src/dataLoader.ts index b83d23fa..4cc4be66 100644 --- a/src/dataLoader.ts +++ b/src/dataLoader.ts @@ -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 { + if (!sessionId || /[\\/]/.test(sessionId) || sessionId === 'unknown') { return []; } + const roots = new Set(this.getClaudePaths()); + if (dataDirectory) { roots.add(dataDirectory); } + const fileName = sessionId + '.jsonl'; + const matches: string[] = []; + const walk = async (dir: string): Promise => { + 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 } diff --git a/src/extension.ts b/src/extension.ts index d2e0fb1f..12cf3559 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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; @@ -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. @@ -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'); @@ -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 => { @@ -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(); }); @@ -389,6 +398,8 @@ export class ClaudeCodeUsageExtension { statusBarMetric: s.get<'cost' | 'tokens'>('statusBarMetric'), showOpusWeekly: s.get('showOpusWeekly'), usageLimitTracking: s.get('usageLimitTracking'), + quotaFiveHourOnly: s.get('quotaFiveHourOnly'), + showResetInStatusBar: s.get('showResetInStatusBar'), adviceApiKey: s.get('advice.apiKey'), adviceApiUrl: s.get('advice.apiUrl'), adviceModel: s.get('advice.model'), @@ -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(); @@ -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 { 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 @@ -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; } @@ -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; @@ -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 @@ -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); } } } diff --git a/src/i18n.ts b/src/i18n.ts index a78fc26e..76000774 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -130,6 +130,20 @@ export interface Translations { scopeMonth: string; attrTodayPointer: string; sessionTitle: string; + // Session row actions. + sessionActions: string; + copySessionId: string; + copyPath: string; + resumeSession: string; + resumeInvalid: string; + sessionFilterCurrent: string; + sessionFilterAll: string; + deleteSession: string; + deleteSessionConfirm: string; + deleteSessionDetail: string; + deleteSessionYes: string; + deleteSessionNotFound: string; + deleteSessionDone: string; getAdvice: string; adviceCardTitle: string; adviceCardDesc: string; @@ -316,6 +330,19 @@ const translations: Record = { scopeMonth: 'Month', attrTodayPointer: 'Details: Content tab', sessionTitle: 'Session', + sessionActions: 'Actions', + copySessionId: 'Copy session ID', + copyPath: 'Copy path', + resumeSession: 'Resume session', + resumeInvalid: 'Invalid session id — cannot resume.', + sessionFilterCurrent: 'Current project', + sessionFilterAll: 'All', + deleteSession: 'Delete session', + deleteSessionConfirm: 'Delete session "{name}"?', + deleteSessionDetail: 'Its conversation log moves to the trash (recoverable). The extension is otherwise read-only.', + deleteSessionYes: 'Delete', + deleteSessionNotFound: 'Session log file not found.', + deleteSessionDone: 'Deleted "{name}" (moved to trash).', getAdvice: 'Get AI Advice', adviceCardTitle: 'AI advice', adviceCardDesc: @@ -518,6 +545,19 @@ const translations: Record = { scopeMonth: "Monat", attrTodayPointer: "Details: Inhalt-Tab", sessionTitle: "Sitzung", + sessionActions: 'Aktionen', + copySessionId: 'Sitzungs-ID kopieren', + copyPath: 'Pfad kopieren', + resumeSession: 'Sitzung fortsetzen', + resumeInvalid: 'Ungültige Sitzungs-ID — Fortsetzen nicht möglich.', + sessionFilterCurrent: 'Aktuelles Projekt', + sessionFilterAll: 'Alle', + deleteSession: 'Sitzung löschen', + deleteSessionConfirm: 'Sitzung "{name}" löschen?', + deleteSessionDetail: 'Das Gesprächsprotokoll wandert in den Papierkorb (wiederherstellbar). Die Erweiterung ist ansonsten schreibgeschützt.', + deleteSessionYes: 'Löschen', + deleteSessionNotFound: 'Sitzungs-Protokolldatei nicht gefunden.', + deleteSessionDone: '"{name}" gelöscht (in den Papierkorb verschoben).', getAdvice: "KI-Rat holen", adviceCardTitle: "KI-Rat", adviceCardDesc: @@ -719,6 +759,19 @@ const translations: Record = { scopeMonth: '月', attrTodayPointer: '詳情見「內容分析」頁籤', sessionTitle: '會話', + sessionActions: '操作', + copySessionId: '複製會話 ID', + copyPath: '複製路徑', + resumeSession: '恢復會話', + resumeInvalid: '無效的會話 ID,無法恢復。', + sessionFilterCurrent: '目前專案', + sessionFilterAll: '全部', + deleteSession: '刪除會話', + deleteSessionConfirm: '刪除會話「{name}」?', + deleteSessionDetail: '對話記錄會移至垃圾桶(可復原)。此擴充功能其餘部分為唯讀。', + deleteSessionYes: '刪除', + deleteSessionNotFound: '找不到會話記錄檔。', + deleteSessionDone: '已刪除「{name}」(已移至垃圾桶)。', getAdvice: '取得 AI 建議', adviceCardTitle: 'AI 建議', adviceCardDesc: @@ -917,6 +970,19 @@ const translations: Record = { scopeMonth: '月', attrTodayPointer: '详情见「内容分析」页签', sessionTitle: '会话', + sessionActions: '操作', + copySessionId: '复制会话 ID', + copyPath: '复制路径', + resumeSession: '恢复会话', + resumeInvalid: '无效的会话 ID,无法恢复。', + sessionFilterCurrent: '当前工程', + sessionFilterAll: '全部', + deleteSession: '删除会话', + deleteSessionConfirm: '删除会话「{name}」?', + deleteSessionDetail: '对话日志将移至回收站(可恢复)。本扩展其余部分为只读。', + deleteSessionYes: '删除', + deleteSessionNotFound: '未找到会话日志文件。', + deleteSessionDone: '已删除「{name}」(已移至回收站)。', getAdvice: '获取 AI 建议', adviceCardTitle: 'AI 建议', adviceCardDesc: @@ -1119,6 +1185,19 @@ const translations: Record = { scopeMonth: '月', attrTodayPointer: '詳細はコンテンツタブへ', sessionTitle: 'セッション', + sessionActions: '操作', + copySessionId: 'セッションIDをコピー', + copyPath: 'パスをコピー', + resumeSession: 'セッションを再開', + resumeInvalid: '無効なセッションIDのため再開できません。', + sessionFilterCurrent: '現在のプロジェクト', + sessionFilterAll: 'すべて', + deleteSession: 'セッションを削除', + deleteSessionConfirm: 'セッション「{name}」を削除しますか?', + deleteSessionDetail: '会話ログはゴミ箱に移動します(復元可能)。この拡張機能は他の部分では読み取り専用です。', + deleteSessionYes: '削除', + deleteSessionNotFound: 'セッションのログファイルが見つかりません。', + deleteSessionDone: '「{name}」を削除しました(ゴミ箱に移動)。', getAdvice: 'AI アドバイスを取得', adviceCardTitle: 'AI アドバイス', adviceCardDesc: @@ -1322,6 +1401,19 @@ const translations: Record = { scopeMonth: '월', attrTodayPointer: '자세한 내용은 콘텐츠 탭에서', sessionTitle: '세션', + sessionActions: '작업', + copySessionId: '세션 ID 복사', + copyPath: '경로 복사', + resumeSession: '세션 재개', + resumeInvalid: '잘못된 세션 ID입니다 — 재개할 수 없습니다.', + sessionFilterCurrent: '현재 프로젝트', + sessionFilterAll: '전체', + deleteSession: '세션 삭제', + deleteSessionConfirm: '세션 "{name}"을(를) 삭제할까요?', + deleteSessionDetail: '대화 로그가 휴지통으로 이동합니다(복구 가능). 확장 프로그램은 그 외에는 읽기 전용입니다.', + deleteSessionYes: '삭제', + deleteSessionNotFound: '세션 로그 파일을 찾을 수 없습니다.', + deleteSessionDone: '"{name}"을(를) 삭제했습니다(휴지통으로 이동).', getAdvice: 'AI 조언 받기', adviceCardTitle: 'AI 조언', adviceCardDesc: @@ -1404,6 +1496,8 @@ const SETTINGS_I18N: Partial s.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + const c = norm(child); + const p = norm(parent); + return c === p || c.startsWith(p + '/'); +} diff --git a/src/settings.ts b/src/settings.ts index c87a242d..ce593a52 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -150,6 +150,26 @@ export const SETTINGS: SettingDef[] = [ label: 'Show weekly Opus limit', help: 'Append the weekly Opus cap (opus:NN%) after the 5h / weekly figures.', }, + { + // Show only the 5-hour quota window; drop weekly / Opus from the status bar. + key: 'quotaFiveHourOnly', + type: 'boolean', + default: false, + storage: 'state', + group: 'statusBar', + label: 'Show only the 5-hour quota', + help: 'Hide the weekly (and Opus) windows; show just the 5-hour utilisation.', + }, + { + // Append the 5h / weekly reset countdown to the status-bar quota item. + key: 'showResetInStatusBar', + type: 'boolean', + default: false, + storage: 'state', + group: 'statusBar', + label: 'Show quota reset time in status bar', + help: 'Append the reset countdown (e.g. "5h:50%:2.3h") to the status-bar quota item.', + }, { key: 'workflowQuotaWarnPercent', type: 'number', diff --git a/src/statusBar.ts b/src/statusBar.ts index a69449c1..d0b5a924 100644 --- a/src/statusBar.ts +++ b/src/statusBar.ts @@ -16,6 +16,9 @@ export class StatusBarManager { // Opt-in: append the weekly Opus limit (opus:NN%) to the quota item (PR #38, // @wheelbarrel00). private showOpusWeekly: boolean = false; + // Quota display preferences. + private quotaFiveHourOnly: boolean = false; // show only the 5h window + private showResetInBar: boolean = false; // append reset countdown to the bar constructor() { this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); @@ -50,13 +53,17 @@ export class StatusBarManager { showContext: boolean, usageLimitTracking: boolean = true, metric: 'cost' | 'tokens' = 'cost', - showOpusWeekly: boolean = false + showOpusWeekly: boolean = false, + quotaFiveHourOnly: boolean = false, + showResetInBar: boolean = false ): void { this.showCost = showCost; this.showContext = showContext; this.usageLimitTracking = usageLimitTracking; this.metric = metric; this.showOpusWeekly = showOpusWeekly; + this.quotaFiveHourOnly = quotaFiveHourOnly; + this.showResetInBar = showResetInBar; if (!showContext) { this.contextItem.hide(); } @@ -215,6 +222,11 @@ export class StatusBarManager { // hours, even after a plan upgrade or a reset). Drop any window whose reset // time has passed. Adapted from PR #24 by @nickearnshaw. const live = this.liveWindows(usageLimits); + // Collapse to the 5h window only. + if (this.quotaFiveHourOnly && live) { + live.seven_day = undefined; + live.seven_day_opus = undefined; + } const fiveHour = live?.five_hour; const weekly = live?.seven_day; const opus = live?.seven_day_opus; @@ -223,23 +235,29 @@ export class StatusBarManager { return; } + // Optionally append each window's reset countdown to the bar. + const resetSuffix = (limit: ClaudeUsageLimit): string => { + if (!this.showResetInBar) { return ''; } + const d = new Date(limit.resets_at); + return isNaN(d.getTime()) ? '' : `:${this.formatResetCompact(d)}`; + }; const parts: string[] = []; let worstPct = 0; if (fiveHour) { worstPct = Math.max(worstPct, fiveHour.utilization); - parts.push(`5h:${Math.round(fiveHour.utilization)}%`); + parts.push(`5h:${Math.round(fiveHour.utilization)}%${resetSuffix(fiveHour)}`); } if (weekly) { worstPct = Math.max(worstPct, weekly.utilization); - parts.push(`wk:${Math.round(weekly.utilization)}%`); + parts.push(`wk:${Math.round(weekly.utilization)}%${resetSuffix(weekly)}`); } // Opt-in weekly Opus cap (PR #38, @wheelbarrel00). if (this.showOpusWeekly && opus) { worstPct = Math.max(worstPct, opus.utilization); - parts.push(`opus:${Math.round(opus.utilization)}%`); + parts.push(`opus:${Math.round(opus.utilization)}%${resetSuffix(opus)}`); } - this.quotaItem.text = `$(dashboard) ${parts.join(' ')}`; + this.quotaItem.text = `$(dashboard) ${parts.join(' | ')}`; // Stay quiet until usage actually gets high. if (worstPct >= 95) { @@ -451,7 +469,7 @@ export class StatusBarManager { ); } - /** Time remaining until a reset, e.g. "2h 15m" or "3d 4h". */ + /** Time remaining until a reset, e.g. "2h 15m" or "3.2d". */ private formatCountdown(target: Date): string { const ms = target.getTime() - Date.now(); if (ms <= 0) { @@ -461,11 +479,28 @@ export class StatusBarManager { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; if (hours >= 24) { - return `${Math.floor(hours / 24)}d ${hours % 24}h`; + return `${(hours / 24).toFixed(1)}d`; } return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; } + /** Compact reset countdown for the status-bar quota item, e.g. "45m"/"2.3h"/"3.2d". */ + private formatResetCompact(target: Date): string { + const ms = target.getTime() - Date.now(); + if (ms <= 0) { + return '0m'; + } + const minutes = ms / 60000; + if (minutes < 60) { + return `${Math.round(minutes)}m`; + } + const hours = minutes / 60; + if (hours < 24) { + return `${hours.toFixed(1)}h`; + } + return `${(hours / 24).toFixed(1)}d`; + } + /** Localised weekday + time of a weekly reset, in 24-hour form * (e.g. "Wed 03:00"). hour12:false suppresses AM/PM that some locales add. */ private formatWeeklyReset(target: Date): string { diff --git a/src/test/sessionResume.test.ts b/src/test/sessionResume.test.ts new file mode 100644 index 00000000..d94cb04c --- /dev/null +++ b/src/test/sessionResume.test.ts @@ -0,0 +1,44 @@ +// Tests for the session-resume guards. + +import { test } from 'node:test'; +import * as assert from 'node:assert/strict'; + +import { isValidSessionId, isUsableCwd, buildResumeCommand, isUnderDir } from '../sessionResume'; + +test('isValidSessionId accepts UUID-shaped ids and sub-agent suffixes', () => { + assert.ok(isValidSessionId('3f2504e0-4f89-41d3-9a0c-0305e82c3301')); + assert.ok(isValidSessionId('abc123_DEF-456')); +}); + +test('isValidSessionId rejects shell-injection and junk', () => { + const bad = ['', 'id; rm -rf /', 'a b', '$(whoami)', 'a`b`', 'a/b', '../x', 42, null, undefined]; + for (const v of bad) { + assert.equal(isValidSessionId(v as unknown), false, `should reject ${String(v)}`); + } +}); + +test('isUsableCwd only accepts absolute filesystem paths', () => { + assert.ok(isUsableCwd('/Users/ozn/dev/ccp')); + assert.ok(isUsableCwd('C:\\Users\\ozn')); + assert.ok(isUsableCwd('D:/proj')); + const bad = ['', ' ', 'd--Jiaming-My-Proj', 'relative/path', null, 5]; + for (const v of bad) { + assert.equal(isUsableCwd(v as unknown), false, `should reject ${String(v)}`); + } +}); + +test('buildResumeCommand builds only for valid ids', () => { + assert.equal(buildResumeCommand('abc-123'), 'claude --resume abc-123'); + assert.equal(buildResumeCommand('bad; ls'), null); + assert.equal(buildResumeCommand(''), null); +}); + +test('isUnderDir matches same dir and nested children, separator-agnostic', () => { + assert.ok(isUnderDir('/Users/ozn/dev/ccp', '/Users/ozn/dev/ccp')); // same + assert.ok(isUnderDir('/Users/ozn/dev/ccp/src', '/Users/ozn/dev/ccp')); // nested + assert.ok(isUnderDir('/Users/ozn/dev/ccp/', '/Users/ozn/dev/ccp')); // trailing slash + assert.ok(isUnderDir('C:\\proj\\a', 'C:\\proj')); // windows + assert.equal(isUnderDir('/Users/ozn/dev/ccp2', '/Users/ozn/dev/ccp'), false); + assert.equal(isUnderDir('/other', '/Users/ozn/dev/ccp'), false); + assert.equal(isUnderDir('/x', ''), false); +}); diff --git a/src/types.ts b/src/types.ts index c3883760..0b18aaa3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -236,6 +236,10 @@ export interface ExtensionConfig { showOpusWeekly: boolean; // Fetch real 5-hour / weekly limit utilisation via Claude Code's OAuth session. usageLimitTracking: boolean; + // Show only the 5-hour quota window (hide weekly / Opus). + quotaFiveHourOnly: boolean; + // Append the reset countdown to the status-bar quota item. + showResetInStatusBar: boolean; // LLM "usage advice" feature (OpenAI-compatible endpoint, e.g. DeepSeek). adviceApiKey: string; adviceApiUrl: string; diff --git a/src/webview.ts b/src/webview.ts index d0504bf0..f3768fcf 100644 --- a/src/webview.ts +++ b/src/webview.ts @@ -3,6 +3,7 @@ import { ClaudeDataLoader } from './dataLoader'; import { I18n } from './i18n'; import { getModelRatesPerMillion } from './pricing'; import { SETTINGS, SettingsStore, SettingView } from './settings'; +import { buildResumeCommand, isUsableCwd, isValidSessionId, isUnderDir } from './sessionResume'; import { AttributionScope, BranchUsage, @@ -52,7 +53,7 @@ export class UsageWebviewProvider { // the user edits a setting in the dashboard's ⚙ Settings tab. Both are set by // extension.ts right after construction. public settings?: SettingsStore; - public onSettingsChanged?: () => void; + public onSettingsChanged?: (key?: string) => void; // Last Usage Optimizer interaction (draft + lens choices + result). Persisted // here so an auto-refresh re-render of the webview doesn't wipe a result the // user is still reading — renderOptimizerCard restores it from this. @@ -117,17 +118,84 @@ export class UsageWebviewProvider { case 'tabChanged': this.currentTab = message.tab; break; + case 'resumeSession': { + // Resume a past session via the official extension, or a terminal fallback. + const sessionId = message.sessionId; + if (!isValidSessionId(sessionId)) { + vscode.window.showWarningMessage(I18n.t.popup.resumeInvalid); + break; + } + const rawCwd = typeof message.cwd === 'string' ? message.cwd : ''; + const cwd = isUsableCwd(rawCwd) ? rawCwd : undefined; + // The official in-tab view only opens sessions from this project; others + // go via terminal. A session whose log had no cwd carries the dash- + // encoded folder name (e.g. "-Users-ozn-dev-ccp"), so match that too. + const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + const sameProject = !!ws && (isUnderDir(rawCwd, ws) || rawCwd === ws.replace(/[/\\]/g, '-')); + const official = vscode.extensions.getExtension('anthropic.claude-code'); + if (official && sameProject) { + try { + if (!official.isActive) { await official.activate(); } + await vscode.commands.executeCommand('claude-vscode.editor.open', sessionId); + break; + } catch { + // Fall back to the terminal if the command shape changed. + } + } + const term = vscode.window.createTerminal({ name: 'Claude Resume', cwd }); + term.show(); + term.sendText(buildResumeCommand(sessionId) as string); + break; + } + case 'deleteSession': { + // Delete a session's log (modal confirm, routed to OS trash). + const sessionId = message.sessionId; + if (!isValidSessionId(sessionId)) { + vscode.window.showWarningMessage(I18n.t.popup.resumeInvalid); + break; + } + const name = String(message.title || sessionId); + const yes = I18n.t.popup.deleteSessionYes; + const pick = await vscode.window.showWarningMessage( + I18n.t.popup.deleteSessionConfirm.replace('{name}', name), + { modal: true, detail: I18n.t.popup.deleteSessionDetail }, + yes + ); + if (pick !== yes) { break; } + const { ClaudeDataLoader } = await import('./dataLoader'); + const targets = await ClaudeDataLoader.findSessionFiles(sessionId, this.dataDirectory); + let deleted = 0; + for (const f of targets) { + try { + await vscode.workspace.fs.delete(vscode.Uri.file(f), { recursive: true, useTrash: true }); + deleted++; + } catch { /* skip what we can't remove */ } + } + // Always force a fresh reload so the row drops — whether files were + // removed now or it was already gone (stale row from an earlier + // delete). refresh -> refreshData(true) re-reads disk regardless of + // mtimes, so the session disappears either way. + vscode.commands.executeCommand('claudeCodeUsage.refresh'); + if (deleted > 0) { + vscode.window.showInformationMessage(I18n.t.popup.deleteSessionDone.replace('{name}', name)); + } else { + vscode.window.showWarningMessage(I18n.t.popup.deleteSessionNotFound); + } + break; + } case 'updateSetting': if (this.settings && typeof message.key === 'string') { await this.settings.set(message.key, message.value); - // Re-apply config (status bar, refresh cadence, language…) and - // re-render. globalState changes don't fire onDidChangeConfiguration. - this.onSettingsChanged?.(); + // 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. + this.onSettingsChanged?.(message.key); } break; case 'resetSetting': if (this.settings && typeof message.key === 'string') { await this.settings.reset(message.key); + // Reset re-renders fully so the control visually reverts to default. this.onSettingsChanged?.(); } break; @@ -1223,9 +1291,17 @@ export class UsageWebviewProvider { // enabled; the column collapses to "-" otherwise (estimate, see hint). const thinkingMap = this.contentAnalysis ? this.contentAnalysis.thinkingBySession : null; + // Mark sessions outside the current workspace so the list can default to "this project". + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || ''; + let currentCount = 0; + let rows = ''; this.sessionBreakdown.forEach((s) => { const d = s.data; + const foreign = workspacePath + ? !(isUnderDir(s.projectPath, workspacePath) || s.projectPath === workspacePath.replace(/[/\\]/g, '-')) + : false; + if (!foreign) { currentCount++; } // Conversation title (the name `claude --resume` shows); falls back to a // short session id so same-project rows stay distinguishable either way. const fullName = s.title || s.sessionId; @@ -1242,7 +1318,7 @@ export class UsageWebviewProvider { thinkingShare !== null && thinkingShare > 0.6 ? t.effortHint : '', ].filter((x) => x).join(' · '); rows += - '' + '' + I18n.formatNumber(d.messageCount) + '' + '' + this.escapeHtml(this.formatDuration(s.startTime, s.endTime)) + '' + + // Copy id / resume / delete actions; id re-validated host-side. + '' + + '' + + '' + + '' + + '' + ''; }); const th = (key: string, label: string): string => '' + label + ''; + // Filter the list to the current project (default) or all. + const total = this.sessionBreakdown.length; + const foreignCount = total - currentCount; + const showToggle = !!workspacePath && currentCount > 0 && foreignCount > 0; + const filterBar = showToggle + ? '
' + + '' + + '' + + '
' + : ''; + return ( '
' + '

' + t.sessionBreakdown + '

' + '

' + t.sortHint + '

' + + filterBar + + '
' + '
' + '' + '' + @@ -1294,10 +1396,12 @@ export class UsageWebviewProvider { th('thinking', t.thinkingShare) + th('messages', t.messages) + th('duration', t.duration) + + '' + '' + '' + rows + '' + '
' + t.sessionActions + '
' + '
' + + '
' + '

' + t.thinkingShare + ': ' + t.estimatedNote + '

' + '
' ); @@ -1368,9 +1472,19 @@ export class UsageWebviewProvider { /** A table cell showing the project's friendly name with its full path beneath. */ private renderProjectCell(name: string, fullPath: string): string { const safeName = this.escapeHtml(name || 'unknown'); - const safePath = this.escapeHtml(fullPath || ''); - const pathLine = safePath ? '
' + safePath + '
' : ''; - return '
' + safeName + '
' + pathLine + ''; + return '
' + safeName + '
' + this.projectPathLine(fullPath || '') + ''; + } + + /** A project directory line with a small copy-to-clipboard icon. */ + private projectPathLine(fullPath: string, indent: number = 0): string { + if (!fullPath) { return ''; } + const safe = this.escapeHtml(fullPath); + const pad = ' '.repeat(indent); + return '
' + + '' + pad + safe + '' + + '' + + '
'; } private renderProjectData(): string { @@ -1420,9 +1534,7 @@ export class UsageWebviewProvider { this.escapeHtml(group.groupName) + ' (' + group.projectCount + ')' + '' + - '
' + - this.escapeHtml(group.groupPath) + - '
' + + this.projectPathLine(group.groupPath) + '' + '' + I18n.formatNumber(group.sessionCount) + '' + usageCells(group.data) + @@ -1432,10 +1544,8 @@ export class UsageWebviewProvider { rows += '' + '' + - '
' + this.escapeHtml(child.projectName) + '
' + - '
' + - this.escapeHtml(child.projectPath) + - '
' + + '
  ' + this.escapeHtml(child.projectName) + '
' + + this.projectPathLine(child.projectPath, 2) + '' + '' + I18n.formatNumber(child.sessionCount) + '' + usageCells(child.data) + @@ -2482,7 +2592,8 @@ export class UsageWebviewProvider { } .container { - max-width: 800px; + /* Widen for big monitors; fluid below the cap. */ + max-width: 1600px; margin: 0 auto; } @@ -2827,6 +2938,58 @@ export class UsageWebviewProvider { display: inline-block; } + /* Session row action buttons. */ + .actions-cell { + white-space: nowrap; + text-align: center; + } + .session-action { + background: none; + border: 1px solid var(--vscode-panel-border); + color: var(--vscode-foreground); + border-radius: 4px; + cursor: pointer; + padding: 1px 6px; + margin: 0 1px; + font-size: 12px; + line-height: 1.4; + } + .session-action:hover { + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.2)); + } + .session-action.copied { + color: var(--vscode-charts-green, #4caf50); + border-color: var(--vscode-charts-green, #4caf50); + } + .session-delete:hover { + color: var(--vscode-errorForeground, #f44336); + border-color: var(--vscode-errorForeground, #f44336); + } + + /* Sessions "current project / all" filter. */ + .sess-filter { + display: flex; + gap: 6px; + margin: 0 0 10px; + } + .sess-filter-btn { + background: none; + border: 1px solid var(--vscode-panel-border); + color: var(--vscode-foreground); + border-radius: 4px; + cursor: pointer; + padding: 2px 10px; + font-size: 12px; + } + .sess-filter-btn.active { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-color: var(--vscode-button-background); + } + .session-list.show-current .session-foreign { + display: none; + } + /* iOS-style auto-refresh toggle. The label/switch sit next to the other buttons in the header actions row. Slider colour mirrors the status-bar success colour so on/off state reads at a glance. */ @@ -3314,8 +3477,38 @@ export class UsageWebviewProvider { .project-path { font-size: 11px; color: var(--vscode-descriptionForeground); - word-break: break-all; margin-top: 2px; + display: flex; + align-items: flex-start; + gap: 4px; + } + .project-path-text { + word-break: break-all; + } + /* Small copy-path icon, revealed on row hover. */ + .copy-path { + flex-shrink: 0; + background: none; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 0 2px; + font-size: 11px; + line-height: 1.3; + opacity: 0; + transition: opacity 0.1s; + } + .sort-row:hover .copy-path, + .project-child-row:hover .copy-path, + .copy-path.copied { + opacity: 0.7; + } + .copy-path:hover { + opacity: 1 !important; + color: var(--vscode-foreground); + } + .copy-path.copied { + color: var(--vscode-charts-green, #4caf50); } .composition-chart { @@ -3789,6 +3982,22 @@ console.log("[DEBUG] === JAVASCRIPT INITIALIZATION START ==="); const vscode = acquireVsCodeApi(); console.log("[DEBUG] VSCode API acquired"); +// Restore the active tab from localStorage before first paint, so a reload can't change it. +function restoreActiveTab() { + try { + var t = localStorage.getItem('ccu.activeTab'); + if (t && document.getElementById('tab-' + t) && document.getElementById(t)) { + showTab(t); + } + } catch (e) {} +} +function restoreUi() { restoreActiveTab(); restoreSessionFilter(); } +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', restoreUi); +} else { + restoreUi(); +} + // Locale + timezone baked in at render time so drill-down renders match the // user's UI language and configured timezone (instead of the hardcoded zh-TW // that the original used in this script body). @@ -3928,6 +4137,87 @@ function dismissQuotaWarn() { vscode.postMessage({ command: 'dismissQuotaWarn' }); } +// Session row actions. +function copySession(btn) { + if (!btn) { return; } + var id = btn.getAttribute('data-session-id') || ''; + if (!id) { return; } + var orig = btn.textContent; + var done = function() { + btn.classList.add('copied'); + btn.textContent = '✓'; + setTimeout(function() { btn.classList.remove('copied'); btn.textContent = orig; }, 1200); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(id).then(done, done); + } else { + done(); + } +} + +function resumeSession(btn) { + if (!btn) { return; } + var id = btn.getAttribute('data-session-id') || ''; + if (!id) { return; } + vscode.postMessage({ + command: 'resumeSession', + sessionId: id, + cwd: btn.getAttribute('data-cwd') || '' + }); +} + +function deleteSession(btn) { + if (!btn) { return; } + var id = btn.getAttribute('data-session-id') || ''; + if (!id) { return; } + // The host shows a modal confirm before anything is removed. + vscode.postMessage({ + command: 'deleteSession', + sessionId: id, + title: btn.getAttribute('data-title') || '' + }); +} + +function applySessionFilter(mode) { + 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); + }); +} +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) {} +} +function restoreSessionFilter() { + try { + var mode = localStorage.getItem('ccu.sessionFilter'); + if (mode && document.querySelector('.sess-filter-btn[data-mode="' + mode + '"]')) { + applySessionFilter(mode); + } + } catch (e) {} +} + +function copyPath(btn, e) { + if (e) { e.stopPropagation(); } + if (!btn) { return; } + var p = btn.getAttribute('data-path') || ''; + if (!p) { return; } + var orig = btn.textContent; + var done = function() { + btn.classList.add('copied'); + btn.textContent = '✓'; + setTimeout(function() { btn.classList.remove('copied'); btn.textContent = orig; }, 1200); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(p).then(done, done); + } else { done(); } +} + function attrSetScope(kind) { document.querySelectorAll('.attr-tab').forEach(function(b) { b.classList.toggle('active', b.getAttribute('data-kind') === kind); @@ -4036,8 +4326,9 @@ function showTab(tabName) { selectedContent.classList.add('active'); console.log("[DEBUG] Tab switched successfully to:", tabName); - // Notify extension vscode.postMessage({ command: 'tabChanged', tab: tabName }); + // Persist in localStorage too — survives the reload, restored before paint. + try { localStorage.setItem('ccu.activeTab', tabName); } catch (e) {} } else { console.error("[DEBUG] Tab or content not found:", tabName); }