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
6 changes: 3 additions & 3 deletions vscode-extension/src/cacheManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
98 changes: 83 additions & 15 deletions vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3473,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<string, unknown>;
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
}
Expand Down Expand Up @@ -4768,6 +4780,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
Expand Down Expand Up @@ -4801,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',
Expand Down Expand Up @@ -4859,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(() => {
Expand Down Expand Up @@ -7997,14 +8035,40 @@ ${hashtag}`;

const allModels = new Set<string>();
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<string, number>();
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),
data: entries.map(e => { const u = e.modelUsage[model]; return u ? u.inputTokens + u.outputTokens : 0; }),
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<string>();
entries.forEach(e => Object.keys(e.editorUsage).forEach(ed => allEditors.add(ed)));
Expand All @@ -8018,7 +8082,9 @@ ${hashtag}`;
});

const allRepos = new Set<string>();
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 {
Expand Down Expand Up @@ -8111,7 +8177,9 @@ ${hashtag}`;
});
const repositoryTotalsMap: Record<string, number> = {};
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;
});
Expand Down Expand Up @@ -8160,7 +8228,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(/</g, "\\u003c");

Expand All @@ -8183,7 +8251,7 @@ ${hashtag}`;

private getUsageAnalysisHtml(
webview: vscode.Webview,
stats: UsageAnalysisStats,
stats: UsageAnalysisStats | null,
): string {
const nonce = this.getNonce();
const scriptUri = webview.asWebviewUri(
Expand All @@ -8210,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)}`,
Expand All @@ -8220,7 +8288,7 @@ ${hashtag}`;
.getConfiguration('copilotTokenTracker')
.get<string[]>('suppressedUnknownTools', []);

const initialData = JSON.stringify({
const initialData = stats ? JSON.stringify({
today: stats.today,
last30Days: stats.last30Days,
month: stats.month,
Expand All @@ -8231,7 +8299,7 @@ ${hashtag}`;
backendConfigured: this.isBackendConfigured(),
currentWorkspacePaths: vscode.workspace.workspaceFolders?.map(f => f.uri.fsPath) ?? [],
suppressedUnknownTools,
}).replace(/</g, "\\u003c");
}).replace(/</g, "\\u003c") : 'null';

return `<!DOCTYPE html>
<html lang="en">
Expand Down
21 changes: 15 additions & 6 deletions vscode-extension/src/webview/chart/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type InitialChartData = {
backendConfigured?: boolean;
compactNumbers?: boolean;
periodsReady?: boolean;
initialPeriod?: ChartPeriod;
periods?: {
day: ChartPeriodData;
week: ChartPeriodData;
Expand Down Expand Up @@ -168,12 +169,12 @@ function renderLayout(data: InitialChartData): void {
}

const chartSection = el('div', 'section');
chartSection.append(el('h3', '', '📊 Charts'));
// Chart section header: title left, period toggles right
const chartSectionHeader = el('div', 'chart-section-header');
chartSectionHeader.append(el('h3', '', '📊 Charts'));

const chartShell = el('div', 'chart-shell');

// Period toggle row
const periodToggles = el('div', 'chart-controls period-controls');
// Period toggles (compact, inline with section heading)
const periodToggles = el('div', 'period-controls');
const periodsReady = data.periodsReady !== false;
const dayBtn = el('button', `toggle${currentPeriod === 'day' ? ' active' : ''}`, '📅 Day');
dayBtn.id = 'period-day';
Expand All @@ -190,6 +191,10 @@ function renderLayout(data: InitialChartData): void {
monthBtn.title = 'Loading historical data…';
}
periodToggles.append(dayBtn, weekBtn, monthBtn);
chartSectionHeader.append(periodToggles);
chartSection.append(chartSectionHeader);

const chartShell = el('div', 'chart-shell');

// Chart view toggle row
const toggles = el('div', 'chart-controls');
Expand All @@ -208,7 +213,7 @@ function renderLayout(data: InitialChartData): void {
canvas.id = 'token-chart';
canvasWrap.append(canvas);

chartShell.append(periodToggles, toggles, canvasWrap);
chartShell.append(toggles, canvasWrap);
chartSection.append(chartShell);

const footer = el('div', 'footer',
Expand Down Expand Up @@ -346,6 +351,7 @@ async function switchPeriod(period: ChartPeriod, data: InitialChartData): Promis
return;
}
currentPeriod = period;
vscode.postMessage({ command: 'setPeriodPreference', period });
setActivePeriod(period);
updateSummaryCards(data);
if (!chart) {
Expand Down Expand Up @@ -551,6 +557,9 @@ async function bootstrap(): Promise<void> {
}
return;
}
if (initialData.initialPeriod) {
currentPeriod = initialData.initialPeriod;
}
renderLayout(initialData);
}

Expand Down
25 changes: 20 additions & 5 deletions vscode-extension/src/webview/chart/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion vscode-extension/src/webview/usage/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1997,8 +1997,9 @@ async function bootstrap(): Promise<void> {
if (!initialData) {
const root = document.getElementById('root');
if (root) {
root.textContent = 'No data available.';
root.innerHTML = '<div style="padding: 32px; text-align: center; color: var(--vscode-foreground); opacity: 0.7; font-size: 14px;">⏳ Loading usage analysis…</div>';
}
// 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);
Expand Down
25 changes: 25 additions & 0 deletions vscode-extension/src/workspaceHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <path>"
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 = <main>/.git/worktrees/<name>
// 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
}
}
}

Expand Down
Loading