From 636936850972db9975f4efcbc923bccdbf1c0a45 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 7 Feb 2026 22:41:04 +0100 Subject: [PATCH 1/5] Add extra tool name --- src/toolNames.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/toolNames.json b/src/toolNames.json index 7165c72..bb64e4d 100644 --- a/src/toolNames.json +++ b/src/toolNames.json @@ -4,6 +4,8 @@ ,"mcp.io.github.git.assign_copilot_to_issue": "GitHub MCP: Assign Copilot to Issue" ,"mcp.io.github.git.create_or_update_file": "GitHub MCP: Create/Update File" ,"mcp_io_github_git_pull_request_read": "GitHub MCP: Pull Request Read" + ,"mcp_io_github_git_issue_read": "GitHub MCP: Issue Read" + ,"mcp_io_github_git_issue_write": "GitHub MCP: Issue Write" ,"manage_todo_list": "Manage TODO List" ,"copilot_readFile": "Read File" ,"copilot_applyPatch": "Apply Patch" From 5085dd7ffc161acac04f003ee25eb3de54d45abc Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 7 Feb 2026 23:02:22 +0100 Subject: [PATCH 2/5] Add repository field to session details and implement repository extraction feature --- src/extension.ts | 177 ++++++++++++++++++++++++++++++-- src/webview/diagnostics/main.ts | 47 +++++++++ 2 files changed, 218 insertions(+), 6 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index da7c837..8dd4311 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -78,6 +78,7 @@ interface SessionFileCache { firstInteraction?: string | null; // ISO timestamp of first interaction lastInteraction?: string | null; // ISO timestamp of last interaction title?: string; // Session title (customTitle from session file) + repository?: string; // Git remote origin URL for the session's workspace } // New interfaces for usage analysis @@ -170,6 +171,7 @@ interface SessionFileDetails { editorRoot?: string; // top-level editor root path (for display in diagnostics) editorName?: string; // friendly editor name (e.g., 'VS Code') title?: string; // session title (customTitle from session file) + repository?: string; // Git remote origin URL for the session's workspace } // Chat turn information for log viewer @@ -1941,6 +1943,135 @@ class CopilotTokenTracker implements vscode.Disposable { } } + /** + * Extract repository remote URL from file paths found in contentReferences. + * Looks for .git/config file in the workspace root to get the origin remote URL. + * @param contentReferences Array of content reference objects from session data + * @returns The repository remote URL if found, undefined otherwise + */ + private async extractRepositoryFromContentReferences(contentReferences: any[]): Promise { + if (!Array.isArray(contentReferences)) { + return undefined; + } + + const filePaths: string[] = []; + + // Collect all file paths from contentReferences + for (const contentRef of contentReferences) { + if (!contentRef || typeof contentRef !== 'object') { + continue; + } + + let reference = null; + const kind = contentRef.kind; + + if (kind === 'reference' && contentRef.reference) { + reference = contentRef.reference; + } else if (kind === 'inlineReference' && contentRef.inlineReference) { + reference = contentRef.inlineReference; + } + + if (reference) { + // Prefer fsPath (native format) over path (URI format) + const rawPath = reference.fsPath || reference.path; + if (typeof rawPath === 'string' && rawPath.length > 0) { + // Convert VS Code URI path format to native path on Windows + // URI paths look like "/c:/Users/..." but should be "c:/Users/..." on Windows + let normalizedPath = rawPath; + if (process.platform === 'win32' && normalizedPath.match(/^\/[a-zA-Z]:/)) { + normalizedPath = normalizedPath.substring(1); // Remove leading slash + } + filePaths.push(normalizedPath); + } + } + } + + if (filePaths.length === 0) { + return undefined; + } + + // Find the most likely workspace root by looking for common parent directories + // Try each file path and look for a .git/config file in parent directories + const checkedRoots = new Set(); + + for (const filePath of filePaths) { + // Normalize path separators to forward slashes for consistent splitting + const normalizedPath = filePath.replace(/\\/g, '/'); + const pathParts = normalizedPath.split('/').filter(p => p.length > 0); + + // Walk up the directory tree looking for .git/config + for (let i = pathParts.length - 1; i >= 1; i--) { + // Reconstruct path - on Windows, first part is drive letter (e.g., "c:") + let potentialRoot = pathParts.slice(0, i).join('/'); + + // On Windows, ensure we have a valid absolute path + if (process.platform === 'win32' && pathParts[0].match(/^[a-zA-Z]:$/)) { + // Path starts with drive letter, already valid + } else if (process.platform !== 'win32' && !potentialRoot.startsWith('/')) { + // On Unix, prepend / for absolute path + potentialRoot = '/' + potentialRoot; + } + + // Skip if we've already checked this root + if (checkedRoots.has(potentialRoot)) { + continue; + } + checkedRoots.add(potentialRoot); + + const gitConfigPath = path.join(potentialRoot, '.git', 'config'); + try { + const gitConfig = await fs.promises.readFile(gitConfigPath, 'utf8'); + const remoteUrl = this.parseGitRemoteUrl(gitConfig); + if (remoteUrl) { + return remoteUrl; + } + } catch { + // No .git/config at this level, continue up the tree + } + } + } + + return undefined; + } + + /** + * Parse the remote origin URL from a .git/config file content. + * Looks for [remote "origin"] section and extracts the url value. + * @param gitConfigContent The content of a .git/config file + * @returns The remote origin URL if found, undefined otherwise + */ + private parseGitRemoteUrl(gitConfigContent: string): string | undefined { + // Look for [remote "origin"] section and extract url + const lines = gitConfigContent.split('\n'); + let inOriginSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Check if we're entering the [remote "origin"] section + if (trimmed.match(/^\[remote\s+"origin"\]$/i)) { + inOriginSection = true; + continue; + } + + // Check if we're leaving the section (new section starts) + if (inOriginSection && trimmed.startsWith('[')) { + inOriginSection = false; + continue; + } + + // Look for url = ... in the origin section + if (inOriginSection) { + const urlMatch = trimmed.match(/^url\s*=\s*(.+)$/i); + if (urlMatch) { + return urlMatch[1].trim(); + } + } + } + + return undefined; + } + /** * Extract session metadata (title, timestamps) from a session file. * Used to populate cache with information needed for session file details. @@ -2181,7 +2312,8 @@ class CopilotTokenTracker implements vscode.Disposable { firstInteraction: cached.firstInteraction || null, lastInteraction: lastInteraction, editorSource: this.detectEditorSource(sessionFile), - title: cached.title + title: cached.title, + repository: cached.repository }; // Add editor root and name @@ -2229,7 +2361,8 @@ class CopilotTokenTracker implements vscode.Disposable { }, firstInteraction: details.firstInteraction, lastInteraction: details.lastInteraction, - title: details.title + title: details.title, + repository: details.repository }; // Update the contextReferences in usageAnalysis with the current data @@ -2250,8 +2383,14 @@ class CopilotTokenTracker implements vscode.Disposable { // Try to get details from cache first const cachedDetails = await this.getSessionFileDetailsFromCache(sessionFile, stat); if (cachedDetails) { - this._cacheHits++; - return cachedDetails; + // Invalidate cache if repository field is missing (needed for new repository extraction feature) + // Only re-parse JSONL files since they're likely to have contentReferences + if (cachedDetails.repository === undefined && sessionFile.endsWith('.jsonl')) { + // Fall through to re-parse + } else { + this._cacheHits++; + return cachedDetails; + } } this._cacheMisses++; @@ -2282,6 +2421,7 @@ class CopilotTokenTracker implements vscode.Disposable { if (isJsonlContent) { const lines = fileContent.trim().split('\n'); const timestamps: number[] = []; + const allContentReferences: any[] = []; // Collect for repository extraction for (const line of lines) { if (!line.trim()) { continue; } @@ -2327,6 +2467,10 @@ class CopilotTokenTracker implements vscode.Disposable { if (request.message?.text) { this.analyzeContextReferences(request.message.text, details.contextReferences); } + // Collect contentReferences for repository extraction + if (request.contentReferences && Array.isArray(request.contentReferences)) { + allContentReferences.push(...request.contentReferences); + } } } @@ -2354,6 +2498,11 @@ class CopilotTokenTracker implements vscode.Disposable { details.lastInteraction = stat.mtime.toISOString(); } + // Extract repository from collected contentReferences + if (allContentReferences.length > 0) { + details.repository = await this.extractRepositoryFromContentReferences(allContentReferences); + } + // Update cache with the details we just collected await this.updateCacheWithSessionDetails(sessionFile, stat, details); @@ -2368,9 +2517,12 @@ class CopilotTokenTracker implements vscode.Disposable { details.title = sessionContent.customTitle; } - if (sessionContent.requests && Array.isArray(sessionContent.requests)) { + const hasRequests = sessionContent.requests && Array.isArray(sessionContent.requests); + + if (hasRequests) { details.interactions = sessionContent.requests.length; const timestamps: number[] = []; + const allContentReferences: any[] = []; // Collect for repository extraction for (const request of sessionContent.requests) { // Extract timestamps from requests @@ -2391,6 +2543,11 @@ class CopilotTokenTracker implements vscode.Disposable { } } + // Collect contentReferences for repository extraction + if (request.contentReferences && Array.isArray(request.contentReferences)) { + allContentReferences.push(...request.contentReferences); + } + // Check variableData for @workspace, @terminal, @vscode references if (request.variableData) { const varDataStr = JSON.stringify(request.variableData).toLowerCase(); @@ -2413,6 +2570,11 @@ class CopilotTokenTracker implements vscode.Disposable { // Fallback to file modification time if no timestamps in content details.lastInteraction = stat.mtime.toISOString(); } + + // Extract repository from collected contentReferences + if (allContentReferences.length > 0) { + details.repository = await this.extractRepositoryFromContentReferences(allContentReferences); + } } // Update cache with the details we just collected @@ -4371,7 +4533,7 @@ class CopilotTokenTracker implements vscode.Disposable { try { const details = await this.getSessionFileDetails(file); - // Only include sessions with activity (lastInteraction or file modified time) within the last 14 days + // Only include sessions with activity (lastInteraction or file modified time) within the last x days const lastActivity = details.lastInteraction ? new Date(details.lastInteraction) : new Date(details.modified); @@ -4389,6 +4551,9 @@ class CopilotTokenTracker implements vscode.Disposable { if (panel === this.diagnosticsPanel) { this.diagnosticsCachedFiles = detailedSessionFiles; } + // Log summary stats + const withRepo = detailedSessionFiles.filter(s => s.repository).length; + this.log(`📊 Sending ${detailedSessionFiles.length} sessions to diagnostics (${withRepo} with repository info)`); await panel.webview.postMessage({ command: 'sessionFilesLoaded', detailedSessionFiles diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts index 5233f5a..00857d2 100644 --- a/src/webview/diagnostics/main.ts +++ b/src/webview/diagnostics/main.ts @@ -36,6 +36,7 @@ type SessionFileDetails = { editorRoot?: string; editorName?: string; title?: string; + repository?: string; }; type CacheInfo = { @@ -174,6 +175,50 @@ function getFileName(filePath: string): string { return parts[parts.length - 1]; } +/** + * Extract a friendly display name from a repository URL. + * Supports HTTPS, SSH, and git:// URLs. + * @param repoUrl The full repository URL + * @returns A shortened display name like "owner/repo" + */ +function getRepoDisplayName(repoUrl: string): string { + if (!repoUrl) { return ''; } + + // Remove .git suffix if present + let url = repoUrl.replace(/\.git$/, ''); + + // Handle SSH URLs like git@github.com:owner/repo + if (url.includes('@') && url.includes(':')) { + const colonIndex = url.lastIndexOf(':'); + const atIndex = url.lastIndexOf('@'); + if (colonIndex > atIndex) { + return url.substring(colonIndex + 1); + } + } + + // Handle HTTPS/git URLs - extract path after the host + try { + // Handle URLs with explicit protocol + if (url.includes('://')) { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(p => p); + if (pathParts.length >= 2) { + return `${pathParts[pathParts.length - 2]}/${pathParts[pathParts.length - 1]}`; + } + return urlObj.pathname.replace(/^\//, ''); + } + } catch { + // URL parsing failed, return as-is + } + + // Fallback: return the last part of the path + const parts = url.split('/').filter(p => p); + if (parts.length >= 2) { + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; + } + return url; +} + function getEditorIcon(editor: string): string { const lower = editor.toLowerCase(); if (lower.includes('cursor')) { return '🖱️'; } @@ -306,6 +351,7 @@ function renderSessionTable(detailedFiles: SessionFileDetails[], isLoading: bool # Editor Title + Repository Size Interactions Context Refs @@ -322,6 +368,7 @@ function renderSessionTable(detailedFiles: SessionFileDetails[], isLoading: bool ${sf.title ? `${escapeHtml(sf.title.length > 40 ? sf.title.substring(0, 40) + '...' : sf.title)}` : `(Empty session)`} + ${sf.repository ? escapeHtml(getRepoDisplayName(sf.repository)) : ''} ${formatFileSize(sf.size)} ${sanitizeNumber(sf.interactions)} ${sanitizeNumber(getTotalContextRefs(sf.contextReferences))} From ccd6c3e567692489ed92b6b7d8c619dc72aa75b2 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 7 Feb 2026 23:08:00 +0100 Subject: [PATCH 3/5] Add new tool names for GitHub Copilot features --- src/toolNames.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/toolNames.json b/src/toolNames.json index bb64e4d..2cf37cc 100644 --- a/src/toolNames.json +++ b/src/toolNames.json @@ -19,5 +19,8 @@ ,"copilot_searchCodebase": "Search Codebase" ,"get_terminal_output": "Get Terminal Output" ,"run_task": "Run Task: Investigate" - ,"await_terminal": "Await Terminal command" + ,"await_terminal": "Await Terminal command" + ,"github.copilot.editsAgent": "GitHub Copilot Edits Agent" + ,"todoList": "TODO List" + ,"terminal": "Terminal" } From 5f78cd797e55757db6c511060c0976fda5ca4a17 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 7 Feb 2026 23:08:13 +0100 Subject: [PATCH 4/5] Refactor sorting logic to remove firstInteraction column and simplify session file sorting --- src/webview/diagnostics/main.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts index 00857d2..9808f81 100644 --- a/src/webview/diagnostics/main.ts +++ b/src/webview/diagnostics/main.ts @@ -89,7 +89,7 @@ const vscode = acquireVsCodeApi(); const initialData = window.__INITIAL_DIAGNOSTICS__; // Sorting and filtering state -let currentSortColumn: 'firstInteraction' | 'lastInteraction' = 'lastInteraction'; +let currentSortColumn: 'lastInteraction' = 'lastInteraction'; let currentSortDirection: 'asc' | 'desc' = 'desc'; let currentEditorFilter: string | null = null; // null = show all @@ -231,8 +231,8 @@ function getEditorIcon(editor: string): string { function sortSessionFiles(files: SessionFileDetails[]): SessionFileDetails[] { return [...files].sort((a, b) => { - const aVal = currentSortColumn === 'firstInteraction' ? a.firstInteraction : a.lastInteraction; - const bVal = currentSortColumn === 'firstInteraction' ? b.firstInteraction : b.lastInteraction; + const aVal = a.lastInteraction; + const bVal = b.lastInteraction; // Handle null values - push them to the end if (!aVal && !bVal) { return 0; } @@ -246,7 +246,7 @@ function sortSessionFiles(files: SessionFileDetails[]): SessionFileDetails[] { }); } -function getSortIndicator(column: 'firstInteraction' | 'lastInteraction'): string { +function getSortIndicator(column: 'lastInteraction'): string { if (currentSortColumn !== column) { return ''; } return currentSortDirection === 'desc' ? ' ▼' : ' ▲'; } @@ -355,7 +355,6 @@ function renderSessionTable(detailedFiles: SessionFileDetails[], isLoading: bool Size Interactions Context Refs - First Interaction${getSortIndicator('firstInteraction')} Last Interaction${getSortIndicator('lastInteraction')} Actions @@ -372,7 +371,6 @@ function renderSessionTable(detailedFiles: SessionFileDetails[], isLoading: bool ${formatFileSize(sf.size)} ${sanitizeNumber(sf.interactions)} ${sanitizeNumber(getTotalContextRefs(sf.contextReferences))} - ${formatDate(sf.firstInteraction)} ${formatDate(sf.lastInteraction)} 📄 View @@ -1245,7 +1243,7 @@ function setupStorageLinkHandlers(): void { function setupSortHandlers(): void { document.querySelectorAll('.sortable').forEach(header => { header.addEventListener('click', () => { - const sortColumn = (header as HTMLElement).getAttribute('data-sort') as 'firstInteraction' | 'lastInteraction'; + const sortColumn = (header as HTMLElement).getAttribute('data-sort') as 'lastInteraction'; if (sortColumn) { // Toggle direction if same column, otherwise default to desc if (currentSortColumn === sortColumn) { From 096d6f7ba27fe7bcd90ba5d674a68bea79ab6a24 Mon Sep 17 00:00:00 2001 From: Rob Bos Date: Sat, 7 Feb 2026 23:17:55 +0100 Subject: [PATCH 5/5] Adding repo info to the chart --- src/extension.ts | 97 ++++++++++++++++++++++++++++++++++++++- src/webview/chart/main.ts | 52 +++++++++++++++++---- 2 files changed, 137 insertions(+), 12 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 8dd4311..51c916e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -38,6 +38,13 @@ interface EditorUsage { }; } +interface RepositoryUsage { + [repository: string]: { + tokens: number; + sessions: number; + }; +} + interface PeriodStats { tokens: number; sessions: number; @@ -66,6 +73,7 @@ interface DailyTokenStats { interactions: number; modelUsage: ModelUsage; editorUsage: EditorUsage; + repositoryUsage: RepositoryUsage; } interface SessionFileCache { @@ -310,6 +318,49 @@ class CopilotTokenTracker implements vscode.Disposable { return 'Unknown'; } + /** + * Extract a friendly display name from a repository URL. + * Supports HTTPS, SSH, and git:// URLs. + * @param repoUrl The full repository URL + * @returns A shortened display name like "owner/repo" + */ + private getRepoDisplayName(repoUrl: string): string { + if (!repoUrl || repoUrl === 'Unknown') { return 'Unknown'; } + + // Remove .git suffix if present + let url = repoUrl.replace(/\.git$/, ''); + + // Handle SSH URLs like git@github.com:owner/repo + if (url.includes('@') && url.includes(':')) { + const colonIndex = url.lastIndexOf(':'); + const atIndex = url.lastIndexOf('@'); + if (colonIndex > atIndex) { + return url.substring(colonIndex + 1); + } + } + + // Handle HTTPS/git URLs - extract path after the host + try { + if (url.includes('://')) { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/').filter(p => p); + if (pathParts.length >= 2) { + return `${pathParts[pathParts.length - 2]}/${pathParts[pathParts.length - 1]}`; + } + return urlObj.pathname.replace(/^\//, ''); + } + } catch { + // URL parsing failed, continue to fallback + } + + // Fallback: return the last part of the path + const parts = url.split('/').filter(p => p); + if (parts.length >= 2) { + return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`; + } + return url; + } + // Logging methods public log(message: string): void { const timestamp = new Date().toLocaleTimeString(); @@ -951,6 +1002,10 @@ class CopilotTokenTracker implements vscode.Disposable { const modelUsage = await this.getModelUsageFromSessionCached(sessionFile, mtime, fileSize); const editorType = this.getEditorTypeFromPath(sessionFile); + // Get repository from cache if available + const cached = this.getCachedSessionData(sessionFile); + const repository = cached?.repository || 'Unknown'; + // Get the date in YYYY-MM-DD format const dateKey = this.formatDateKey(new Date(fileStats.mtime)); @@ -962,7 +1017,8 @@ class CopilotTokenTracker implements vscode.Disposable { sessions: 0, interactions: 0, modelUsage: {}, - editorUsage: {} + editorUsage: {}, + repositoryUsage: {} }); } @@ -978,6 +1034,13 @@ class CopilotTokenTracker implements vscode.Disposable { dailyStats.editorUsage[editorType].tokens += tokens; dailyStats.editorUsage[editorType].sessions += 1; + // Merge repository usage + if (!dailyStats.repositoryUsage[repository]) { + dailyStats.repositoryUsage[repository] = { tokens: 0, sessions: 0 }; + } + dailyStats.repositoryUsage[repository].tokens += tokens; + dailyStats.repositoryUsage[repository].sessions += 1; + // Merge model usage for (const [model, usage] of Object.entries(modelUsage)) { if (!dailyStats.modelUsage[model]) { @@ -1023,7 +1086,8 @@ class CopilotTokenTracker implements vscode.Disposable { sessions: 0, interactions: 0, modelUsage: {}, - editorUsage: {} + editorUsage: {}, + repositoryUsage: {} }); } } @@ -4785,6 +4849,33 @@ class CopilotTokenTracker implements vscode.Disposable { }; }); + // Aggregate repository usage across all days + const allRepositories = new Set(); + dailyStats.forEach(d => Object.keys(d.repositoryUsage).forEach(r => allRepositories.add(r))); + + const repositoryDatasets = Array.from(allRepositories).map((repo, idx) => { + const color = modelColors[idx % modelColors.length]; + // Shorten repository URL for display (e.g., "owner/repo") + const label = this.getRepoDisplayName(repo); + return { + label, + fullRepo: repo, + data: dailyStats.map(d => d.repositoryUsage[repo]?.tokens || 0), + backgroundColor: color.bg, + borderColor: color.border, + borderWidth: 1 + }; + }); + + // Calculate repository totals for summary + const repositoryTotalsMap: Record = {}; + dailyStats.forEach(d => { + Object.entries(d.repositoryUsage).forEach(([repo, usage]) => { + const displayName = this.getRepoDisplayName(repo); + repositoryTotalsMap[displayName] = (repositoryTotalsMap[displayName] || 0) + usage.tokens; + }); + }); + // Calculate editor totals for summary cards const editorTotalsMap: Record = {}; dailyStats.forEach(d => { @@ -4803,6 +4894,8 @@ class CopilotTokenTracker implements vscode.Disposable { modelDatasets, editorDatasets, editorTotalsMap, + repositoryDatasets, + repositoryTotalsMap, dailyCount: dailyStats.length, totalTokens, avgTokensPerDay: dailyStats.length > 0 ? Math.round(totalTokens / dailyStats.length) : 0, diff --git a/src/webview/chart/main.ts b/src/webview/chart/main.ts index ba78e9f..6df9e07 100644 --- a/src/webview/chart/main.ts +++ b/src/webview/chart/main.ts @@ -9,6 +9,7 @@ type ChartConfig = import('chart.js').ChartConfiguration<'bar' | 'line', number[ type ModelDataset = { label: string; data: number[]; backgroundColor: string; borderColor: string; borderWidth: number }; type EditorDataset = ModelDataset; +type RepositoryDataset = ModelDataset & { fullRepo?: string }; type InitialChartData = { labels: string[]; @@ -16,7 +17,9 @@ type InitialChartData = { sessionsData: number[]; modelDatasets: ModelDataset[]; editorDatasets: EditorDataset[]; + repositoryDatasets: RepositoryDataset[]; editorTotalsMap: Record; + repositoryTotalsMap: Record; dailyCount: number; totalTokens: number; avgTokensPerDay: number; @@ -50,7 +53,7 @@ async function loadChartModule(): Promise { const mod = await import('chart.js/auto'); Chart = mod.default; } -let currentView: 'total' | 'model' | 'editor' = 'total'; +let currentView: 'total' | 'model' | 'editor' | 'repository' = 'total'; function renderLayout(data: InitialChartData): void { const root = document.getElementById('root'); @@ -130,7 +133,9 @@ function renderLayout(data: InitialChartData): void { modelBtn.id = 'view-model'; const editorBtn = el('button', 'toggle', 'By Editor'); editorBtn.id = 'view-editor'; - toggles.append(totalBtn, modelBtn, editorBtn); + const repoBtn = el('button', 'toggle', 'By Repository'); + repoBtn.id = 'view-repository'; + toggles.append(totalBtn, modelBtn, editorBtn, repoBtn); const canvasWrap = el('div', 'canvas-wrap'); const canvas = document.createElement('canvas'); @@ -184,6 +189,7 @@ function wireInteractions(data: InitialChartData): void { { id: 'view-total', view: 'total' as const }, { id: 'view-model', view: 'model' as const }, { id: 'view-editor', view: 'editor' as const }, + { id: 'view-repository', view: 'repository' as const }, ]; viewButtons.forEach(({ id, view }) => { @@ -204,7 +210,7 @@ async function setupChart(canvas: HTMLCanvasElement, data: InitialChartData): Pr chart = new Chart(ctx, createConfig('total', data)); } -async function switchView(view: 'total' | 'model' | 'editor', data: InitialChartData): Promise { +async function switchView(view: 'total' | 'model' | 'editor' | 'repository', data: InitialChartData): Promise { if (currentView === view) { return; } @@ -229,8 +235,8 @@ async function switchView(view: 'total' | 'model' | 'editor', data: InitialChart chart = new Chart(ctx, createConfig(view, data)); } -function setActive(view: 'total' | 'model' | 'editor'): void { - ['view-total', 'view-model', 'view-editor'].forEach(id => { +function setActive(view: 'total' | 'model' | 'editor' | 'repository'): void { + ['view-total', 'view-model', 'view-editor', 'view-repository'].forEach(id => { const btn = document.getElementById(id); if (!btn) { return; @@ -239,7 +245,7 @@ function setActive(view: 'total' | 'model' | 'editor'): void { }); } -function createConfig(view: 'total' | 'model' | 'editor', data: InitialChartData): ChartConfig { +function createConfig(view: 'total' | 'model' | 'editor' | 'repository', data: InitialChartData): ChartConfig { const baseOptions = { responsive: true, maintainAspectRatio: false, @@ -311,10 +317,23 @@ function createConfig(view: 'total' | 'model' | 'editor', data: InitialChartData }; } - const datasets = view === 'model' ? data.modelDatasets : data.editorDatasets; + const datasets = view === 'model' ? data.modelDatasets : view === 'repository' ? data.repositoryDatasets : data.editorDatasets; + + // Add sessions line as an overlay on all stacked views + const sessionsDataset = { + label: 'Sessions', + data: data.sessionsData, + backgroundColor: 'rgba(255, 99, 132, 0.6)', + borderColor: 'rgba(255, 99, 132, 1)', + borderWidth: 2, + type: 'line' as const, + yAxisID: 'y1', + stack: undefined // Don't stack the line + }; + return { type: 'bar' as const, - data: { labels: data.labels, datasets }, + data: { labels: data.labels, datasets: [...datasets, sessionsDataset] }, options: { ...baseOptions, plugins: { @@ -323,8 +342,21 @@ function createConfig(view: 'total' | 'model' | 'editor', data: InitialChartData }, scales: { ...baseOptions.scales, - y: { stacked: true, grid: { color: '#2d2d33' }, ticks: { color: '#c8c8c8', font: { size: 11 }, callback: (value: any) => Number(value).toLocaleString() } }, - x: { stacked: true, grid: { color: '#2d2d33' }, ticks: { color: '#c8c8c8', font: { size: 11 } } } + y: { + stacked: true, + grid: { color: '#2d2d33' }, + ticks: { color: '#c8c8c8', font: { size: 11 }, callback: (value: any) => Number(value).toLocaleString() }, + title: { display: true, text: 'Tokens', color: '#d0d0d0', font: { size: 12, weight: 'bold' } } + }, + x: { stacked: true, grid: { color: '#2d2d33' }, ticks: { color: '#c8c8c8', font: { size: 11 } } }, + y1: { + type: 'linear' as const, + display: true, + position: 'right' as const, + grid: { drawOnChartArea: false }, + ticks: { color: '#c8c8c8', font: { size: 11 } }, + title: { display: true, text: 'Sessions', color: '#d0d0d0', font: { size: 12, weight: 'bold' } } + } } } };