From 63671f5bfedd05e1fe69e5a7259c42ca83b33da3 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 11 Apr 2026 23:03:24 +0200 Subject: [PATCH 1/5] feat(chart): persist period selection and move toggles inline with section header - Add lastChartPeriod field to extension; defaults to 'day' - Handle 'setPeriodPreference' message in showChart() to store chosen period - Pass initialPeriod in getChartHtml() so chart reopens on the last-used period - In bootstrap(), set currentPeriod from initialData.initialPeriod before render - In switchPeriod(), post setPeriodPreference message to extension - Move period toggle buttons from separate row into chart section header (right-aligned) - Period pills are smaller (11px font, 4px/9px padding) and sit alongside the heading - Saves one full row of vertical space; compact pills save screen real estate - Add .chart-section-header flex layout and .period-controls compact overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-extension/src/extension.ts | 10 +++++++- vscode-extension/src/webview/chart/main.ts | 21 +++++++++++----- vscode-extension/src/webview/chart/styles.css | 25 +++++++++++++++---- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 1e92a8a5..a741e5dc 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -241,6 +241,8 @@ class CopilotTokenTracker implements vscode.Disposable { private lastDailyStats: DailyTokenStats[] | undefined; /** Full-year daily stats (up to 365 days) for the chart Week/Month period views. */ private lastFullDailyStats: DailyTokenStats[] | undefined; + /** Last period selected by the user in the chart view; restored on next open. */ + private lastChartPeriod: 'day' | 'week' | 'month' = 'day'; private lastUsageAnalysisStats: UsageAnalysisStats | undefined; private lastDashboardData: any | undefined; private tokenEstimators: { [key: string]: number } = tokenEstimatorsData.estimators; @@ -4768,6 +4770,12 @@ class CopilotTokenTracker implements vscode.Disposable { if (message.command === 'refresh') { await this.dispatch('refresh:chart', () => this.refreshChartPanel()); } + if (message.command === 'setPeriodPreference') { + const p = message.period; + if (p === 'day' || p === 'week' || p === 'month') { + this.lastChartPeriod = p; + } + } }); // Render immediately; Week/Month buttons are shown as loading if full-year data isn't ready @@ -8160,7 +8168,7 @@ ${hashtag}`; `script-src 'nonce-${nonce}'`, ].join("; "); - const chartData = { ...this.buildChartData(dailyStats), periodsReady }; + const chartData = { ...this.buildChartData(dailyStats), periodsReady, initialPeriod: this.lastChartPeriod }; const initialData = JSON.stringify(chartData).replace(/ { } return; } + if (initialData.initialPeriod) { + currentPeriod = initialData.initialPeriod; + } renderLayout(initialData); } diff --git a/vscode-extension/src/webview/chart/styles.css b/vscode-extension/src/webview/chart/styles.css index bf4ccd54..ad3d15a3 100644 --- a/vscode-extension/src/webview/chart/styles.css +++ b/vscode-extension/src/webview/chart/styles.css @@ -103,8 +103,18 @@ body { margin-top: 2px; } -.chart-shell { - background: var(--bg-tertiary); +.chart-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.chart-section-header h3 { + margin: 0; +} + +.chart-shell { background: var(--bg-tertiary); border: 1px solid var(--border-subtle); border-radius: 10px; padding: 12px; @@ -121,9 +131,14 @@ body { } .period-controls { - margin-bottom: 4px; - padding-bottom: 8px; - border-bottom: 1px solid var(--border-subtle); + display: flex; + gap: 4px; + align-items: center; +} + +.period-controls .toggle { + font-size: 11px; + padding: 4px 9px; } .toggle { From b49785a4b3ef4e5f7f6d83c6ebb64420aa15b5e7 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 11 Apr 2026 23:07:11 +0200 Subject: [PATCH 2/5] perf(cache): raise session file cache limit from 1000 to 3000 entries With ~1100 session files, the old 1000-entry limit caused constant evictions on every analysis cycle, keeping cache hit rates around 50-60%. 3000 entries gives comfortable headroom (3x current file count). Memory cost is ~1-2 MB which is well within VS Code extension and globalState limits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-extension/src/cacheManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vscode-extension/src/cacheManager.ts b/vscode-extension/src/cacheManager.ts index b8f1daa7..13cddb47 100644 --- a/vscode-extension/src/cacheManager.ts +++ b/vscode-extension/src/cacheManager.ts @@ -60,10 +60,10 @@ export class CacheManager { } this.sessionFileCache.set(filePath, data); - // Limit cache size to prevent memory issues (keep last 1000 files) + // Limit cache size to prevent memory issues (keep last 3000 files) // Only trigger cleanup when size exceeds limit by 100 to avoid frequent operations - if (this.sessionFileCache.size > 1100) { - // Remove 100 oldest entries to bring size back to 1000 + if (this.sessionFileCache.size > 3100) { + // Remove 100 oldest entries to bring size back to 3000 // Maps maintain insertion order, so the first entries are the oldest const keysToDelete: string[] = []; let count = 0; From b48d75778c6e7beceaf35d15d32ae1265eb74b70 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 11 Apr 2026 23:09:58 +0200 Subject: [PATCH 3/5] feat(chart): group minor models into 'Other' and exclude Unknown from repo view By Model chart: - Rank all models by total tokens for the active period - Show top 5 models individually (largest usage first) - Collapse remaining models into a single 'Other models' dataset (gray) - Eliminates the wall of 20+ legend entries visible in the screenshot By Repository chart: - Exclude 'Unknown' entries from repository datasets and summary cards - 'Unknown' represents sessions with no repo context (empty window chats, global CLI sessions, etc.) which add noise to a per-repo breakdown - Same filter applied to repositoryTotalsMap used for the detail panel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-extension/src/extension.ts | 36 ++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index a741e5dc..a0e81417 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -8005,7 +8005,21 @@ ${hashtag}`; const allModels = new Set(); entries.forEach(e => Object.keys(e.modelUsage).forEach(m => allModels.add(m))); - const modelDatasets = Array.from(allModels).map((model, idx) => { + + // Rank models by total tokens across the period; keep top 5, group the rest + const modelTotals = new Map(); + for (const model of allModels) { + const total = entries.reduce((sum, e) => { + const u = e.modelUsage[model]; + return sum + (u ? u.inputTokens + u.outputTokens : 0); + }, 0); + modelTotals.set(model, total); + } + const sortedModels = Array.from(allModels).sort((a, b) => (modelTotals.get(b) || 0) - (modelTotals.get(a) || 0)); + const topModels = sortedModels.slice(0, 5); + const otherModels = sortedModels.slice(5); + + const modelDatasets = topModels.map((model, idx) => { const color = modelColors[idx % modelColors.length]; return { label: getModelDisplayName(model), @@ -8013,6 +8027,18 @@ ${hashtag}`; backgroundColor: color.bg, borderColor: color.border, borderWidth: 1, }; }); + if (otherModels.length > 0) { + modelDatasets.push({ + label: 'Other models', + data: entries.map(e => otherModels.reduce((sum, m) => { + const u = e.modelUsage[m]; + return sum + (u ? u.inputTokens + u.outputTokens : 0); + }, 0)), + backgroundColor: 'rgba(150, 150, 150, 0.5)', + borderColor: 'rgba(150, 150, 150, 0.8)', + borderWidth: 1, + }); + } const allEditors = new Set(); entries.forEach(e => Object.keys(e.editorUsage).forEach(ed => allEditors.add(ed))); @@ -8026,7 +8052,9 @@ ${hashtag}`; }); const allRepos = new Set(); - entries.forEach(e => Object.keys(e.repositoryUsage).forEach(r => allRepos.add(r))); + entries.forEach(e => Object.keys(e.repositoryUsage) + .filter(r => r !== 'Unknown') + .forEach(r => allRepos.add(r))); const repositoryDatasets = Array.from(allRepos).map((repo, idx) => { const color = modelColors[idx % modelColors.length]; return { @@ -8119,7 +8147,9 @@ ${hashtag}`; }); const repositoryTotalsMap: Record = {}; dailyBuckets.forEach(b => { - Object.entries(b.stats.repositoryUsage).forEach(([repo, usage]) => { + Object.entries(b.stats.repositoryUsage) + .filter(([repo]) => repo !== 'Unknown') + .forEach(([repo, usage]) => { const displayName = this.getRepoDisplayName(repo); repositoryTotalsMap[displayName] = (repositoryTotalsMap[displayName] || 0) + usage.tokens; }); From a593f33dd672b2ab45bc35e973490bf4f5e7fce5 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 11 Apr 2026 23:16:07 +0200 Subject: [PATCH 4/5] fix(repo-detection): extract repo from CLI tool args and handle git worktrees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs that caused CLI sessions in worktrees to show as 'Unknown' in the By Repository chart: 1. CLI JSONL path extraction was dead code: allContentReferences was defined but never populated for non-delta (Copilot CLI) JSONL files. The loop only read rename_session calls. Now also reads tool.execution_start argument values that look like file paths and pushes them into allContentReferences, which is then fed to extractRepositoryFromContentReferences as before. 2. Git worktree detection missing in workspaceHelpers.ts: extractRepositoryFromContentReferences only checked for .git/config (standard git repo). Worktrees have .git as a FILE containing 'gitdir: /.git/worktrees/'. This file was unreadable as a directory, silently caught, and the walk continued upward past the repo root without finding the remote URL. Now also reads .git as a file, follows the gitdir pointer, resolves the main .git dir (2 levels up from the worktree-specific dir), and reads the remote URL from its config. Bump CACHE_VERSION 37 → 38 to invalidate stale 'no repo' cache entries so all existing CLI sessions are re-scanned on next reload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-extension/src/extension.ts | 12 +++++++++++- vscode-extension/src/workspaceHelpers.ts | 25 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index a0e81417..fc3f6249 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -168,7 +168,7 @@ type RepoPrStatsResult = { class CopilotTokenTracker implements vscode.Disposable { // Cache version - increment this when making changes that require cache invalidation - private static readonly CACHE_VERSION = 37; // Add thinking effort (reasoning effort) tracking + private static readonly CACHE_VERSION = 38; // Fix repo detection for CLI worktree sessions // Maximum length for displaying workspace IDs in diagnostics/customization matrix private static readonly WORKSPACE_ID_DISPLAY_LENGTH = 8; @@ -3475,6 +3475,16 @@ class CopilotTokenTracker implements vscode.Disposable { if (event.type === 'tool.execution_start' && event.data?.toolName === 'rename_session') { if (event.data?.arguments?.title) { details.title = event.data.arguments.title; } } + + // Collect file paths from tool arguments for repository detection + if (event.type === 'tool.execution_start' && event.data?.arguments) { + const args = event.data.arguments as Record; + for (const val of Object.values(args)) { + if (typeof val === 'string' && val.length > 3 && (val.includes('/') || val.includes('\\'))) { + allContentReferences.push({ kind: 'reference', reference: { fsPath: val } }); + } + } + } } catch { // Skip malformed lines } diff --git a/vscode-extension/src/workspaceHelpers.ts b/vscode-extension/src/workspaceHelpers.ts index c4f7fb9a..8341ae48 100644 --- a/vscode-extension/src/workspaceHelpers.ts +++ b/vscode-extension/src/workspaceHelpers.ts @@ -586,6 +586,31 @@ export async function extractRepositoryFromContentReferences(contentReferences: } catch { // No .git/config at this level, continue up the tree } + + // Also check if .git is a file (git worktree) — contains "gitdir: " + const gitFilePath = path.join(potentialRoot, '.git'); + try { + const gitFileContent = await fs.promises.readFile(gitFilePath, 'utf8'); + const match = gitFileContent.match(/^gitdir:\s*(.+)$/m); + if (match) { + const gitdirPath = match[1].trim(); + const basePath = potentialRoot.replace(/\//g, path.sep); + const resolvedGitdir = path.isAbsolute(gitdirPath) + ? gitdirPath + : path.resolve(basePath, gitdirPath); + // Standard worktree: gitdir =
/.git/worktrees/ + // Main .git dir is 2 levels up; its config holds the remote URL + const mainGitDir = path.resolve(resolvedGitdir, '..', '..'); + const mainConfigPath = path.join(mainGitDir, 'config'); + const gitConfig = await fs.promises.readFile(mainConfigPath, 'utf8'); + const remoteUrl = parseGitRemoteUrl(gitConfig); + if (remoteUrl) { + return remoteUrl; + } + } + } catch { + // Not a worktree or can't read gitdir, continue + } } } From 0ea263b257eba26298f92ce61673b467fd3d93f2 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 11 Apr 2026 23:20:03 +0200 Subject: [PATCH 5/5] feat: open Usage Analysis panel immediately, load stats in background Previously showUsageAnalysis() awaited calculateUsageAnalysisStats() before creating the panel, causing a ~20s blank delay when the cache was cold (e.g. while the chart was computing its 365-day background load). Apply the same two-phase pattern used by the chart view: - Create the webview panel immediately - If lastUsageAnalysisStats is cached, render it instantly - Otherwise show a loading spinner; fire calculateUsageAnalysisStats() in the background and push 'updateStats' to the webview when it completes - getUsageAnalysisHtml() now accepts UsageAnalysisStats | null - bootstrap() shows a loading message instead of 'No data available.' when null, since the updateStats message handler already calls renderLayout() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vscode-extension/src/extension.ts | 40 ++++++++++++++++------ vscode-extension/src/webview/usage/main.ts | 3 +- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index fc3f6249..39eb400a 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -4819,10 +4819,7 @@ class CopilotTokenTracker implements vscode.Disposable { this.analysisPanel = undefined; } - // Get usage analysis stats (use cached version for fast loading) - const analysisStats = await this.calculateUsageAnalysisStats(true); - - // Create webview panel + // Create webview panel immediately so the user sees something right away this.analysisPanel = vscode.window.createWebviewPanel( 'copilotUsageAnalysis', 'AI Usage Analysis', @@ -4877,8 +4874,31 @@ class CopilotTokenTracker implements vscode.Disposable { } }); - // Set the HTML content - this.analysisPanel.webview.html = this.getUsageAnalysisHtml(this.analysisPanel.webview, analysisStats); + // Set HTML immediately — use cached stats if available, else show loading spinner + this.analysisPanel.webview.html = this.getUsageAnalysisHtml(this.analysisPanel.webview, this.lastUsageAnalysisStats ?? null); + + // If no cached stats, compute in the background and push via updateStats + if (!this.lastUsageAnalysisStats) { + this.calculateUsageAnalysisStats(true).then(analysisStats => { + if (!this.analysisPanel) { return; } + void this.analysisPanel.webview.postMessage({ + command: 'updateStats', + data: { + today: analysisStats.today, + last30Days: analysisStats.last30Days, + month: analysisStats.month, + locale: analysisStats.locale, + customizationMatrix: analysisStats.customizationMatrix || null, + missedPotential: analysisStats.missedPotential || [], + lastUpdated: analysisStats.lastUpdated.toISOString(), + backendConfigured: this.isBackendConfigured(), + currentWorkspacePaths: vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath) ?? [], + }, + }); + }).catch(err => { + this.error(`Failed to load usage analysis stats: ${err}`); + }); + } // Handle panel disposal this.analysisPanel.onDidDispose(() => { @@ -8231,7 +8251,7 @@ ${hashtag}`; private getUsageAnalysisHtml( webview: vscode.Webview, - stats: UsageAnalysisStats, + stats: UsageAnalysisStats | null, ): string { const nonce = this.getNonce(); const scriptUri = webview.asWebviewUri( @@ -8258,7 +8278,7 @@ ${hashtag}`; ); this.log(`[Locale Detection] Intl default: ${intlLocale}`); - const detectedLocale = stats.locale || localeFromEnv || intlLocale; + const detectedLocale = (stats?.locale) || localeFromEnv || intlLocale; this.log(`[Usage Analysis] Extension detected locale: ${detectedLocale}`); this.log( `[Usage Analysis] Test format 1234567.89: ${new Intl.NumberFormat(detectedLocale).format(1234567.89)}`, @@ -8268,7 +8288,7 @@ ${hashtag}`; .getConfiguration('copilotTokenTracker') .get('suppressedUnknownTools', []); - const initialData = JSON.stringify({ + const initialData = stats ? JSON.stringify({ today: stats.today, last30Days: stats.last30Days, month: stats.month, @@ -8279,7 +8299,7 @@ ${hashtag}`; backendConfigured: this.isBackendConfigured(), currentWorkspacePaths: vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath) ?? [], suppressedUnknownTools, - }).replace(/ diff --git a/vscode-extension/src/webview/usage/main.ts b/vscode-extension/src/webview/usage/main.ts index 918ba093..9dae01f5 100644 --- a/vscode-extension/src/webview/usage/main.ts +++ b/vscode-extension/src/webview/usage/main.ts @@ -1997,8 +1997,9 @@ async function bootstrap(): Promise { if (!initialData) { const root = document.getElementById('root'); if (root) { - root.textContent = 'No data available.'; + root.innerHTML = '
⏳ Loading usage analysis…
'; } + // Stats will arrive via the updateStats message; the module-level listener will call renderLayout then. return; } console.log('[Usage Analysis] Browser default locale:', Intl.DateTimeFormat().resolvedOptions().locale);