Skip to content

Commit 25bea2b

Browse files
authored
Merge pull request #516 from rajbos/copilot/feature-group-used-models
feat: limit model usage table to top 10 with collapsible "Other" group
2 parents 9385cbf + 268b804 commit 25bea2b

File tree

1 file changed

+130
-26
lines changed
  • vscode-extension/src/webview/details

1 file changed

+130
-26
lines changed

vscode-extension/src/webview/details/main.ts

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type DetailedStats = {
4242
sortSettings?: {
4343
editor?: { key?: string; dir?: string };
4444
model?: { key?: string; dir?: string };
45+
modelOtherExpanded?: boolean;
4546
};
4647
};
4748

@@ -72,6 +73,7 @@ let editorSortKey: TableSortKey = (_initSort?.editor?.key as TableSortKey) ?? 'n
7273
let editorSortDir: SortDir = (_initSort?.editor?.dir as SortDir) ?? 'asc';
7374
let modelSortKey: TableSortKey = (_initSort?.model?.key as TableSortKey) ?? 'name';
7475
let modelSortDir: SortDir = (_initSort?.model?.dir as SortDir) ?? 'asc';
76+
let modelOtherExpanded: boolean = (_initSort?.modelOtherExpanded) ?? false;
7577

7678
function calculateProjection(last30DaysValue: number): number {
7779
// Project annual value based on last 30 days average
@@ -265,7 +267,8 @@ function saveSortSettings(): void {
265267
command: 'saveSortSettings',
266268
settings: {
267269
editor: { key: editorSortKey, dir: editorSortDir },
268-
model: { key: modelSortKey, dir: modelSortDir }
270+
model: { key: modelSortKey, dir: modelSortDir },
271+
modelOtherExpanded
269272
}
270273
});
271274
}
@@ -419,7 +422,9 @@ function buildEditorUsageSection(stats: DetailedStats): HTMLElement | null {
419422
return section;
420423
}
421424

422-
function buildModelTbody(stats: DetailedStats, allModels: string[]): HTMLTableSectionElement {
425+
const TOP_N_MODELS = 5;
426+
427+
function buildModelTbody(stats: DetailedStats, topModels: string[], otherModels: string[], onToggleOther: () => void): HTMLTableSectionElement {
423428
type ModelItem = {
424429
model: string;
425430
todayTotal: number;
@@ -435,7 +440,7 @@ function buildModelTbody(stats: DetailedStats, allModels: string[]): HTMLTableSe
435440
charsPerToken: number;
436441
};
437442

438-
const items: ModelItem[] = allModels.map(model => {
443+
function toModelItem(model: string): ModelItem {
439444
const todayUsage = stats.today.modelUsage[model] || { inputTokens: 0, outputTokens: 0 };
440445
const last30DaysUsage = stats.last30Days.modelUsage[model] || { inputTokens: 0, outputTokens: 0 };
441446
const lastMonthUsage = stats.lastMonth.modelUsage[model] || { inputTokens: 0, outputTokens: 0 };
@@ -456,29 +461,33 @@ function buildModelTbody(stats: DetailedStats, allModels: string[]): HTMLTableSe
456461
projected: Math.round(calculateProjection(last30DaysTotal)),
457462
charsPerToken: getCharsPerToken(model)
458463
};
459-
});
460-
461-
items.sort((a, b) => {
462-
let cmp: number;
463-
switch (modelSortKey) {
464-
case 'name': cmp = a.model.localeCompare(b.model); break;
465-
case 'today': cmp = a.todayTotal - b.todayTotal; break;
466-
case 'last30Days': cmp = a.last30DaysTotal - b.last30DaysTotal; break;
467-
case 'lastMonth': cmp = a.lastMonthTotal - b.lastMonthTotal; break;
468-
case 'projected': cmp = a.projected - b.projected; break;
469-
default: cmp = 0;
470-
}
471-
return modelSortDir === 'asc' ? cmp : -cmp;
472-
});
464+
}
473465

474-
const tbody = document.createElement('tbody');
466+
function sortItems(items: ModelItem[]): void {
467+
items.sort((a, b) => {
468+
let cmp: number;
469+
switch (modelSortKey) {
470+
case 'name': cmp = a.model.localeCompare(b.model); break;
471+
case 'today': cmp = a.todayTotal - b.todayTotal; break;
472+
case 'last30Days': cmp = a.last30DaysTotal - b.last30DaysTotal; break;
473+
case 'lastMonth': cmp = a.lastMonthTotal - b.lastMonthTotal; break;
474+
case 'projected': cmp = a.projected - b.projected; break;
475+
default: cmp = 0;
476+
}
477+
return modelSortDir === 'asc' ? cmp : -cmp;
478+
});
479+
}
475480

476-
items.forEach(item => {
481+
function buildModelRow(item: ModelItem, isOtherChild: boolean): HTMLTableRowElement {
477482
const tr = document.createElement('tr');
483+
if (isOtherChild) {
484+
tr.style.opacity = '0.85';
485+
}
478486
const labelTd = document.createElement('td');
479487
const labelWrapper = document.createElement('span');
480488
labelWrapper.className = 'metric-label';
481-
labelWrapper.innerHTML = `${getModelDisplayName(item.model)} <span style="color:#9aa0a6;font-size:11px; font-weight:500;">(~${item.charsPerToken.toFixed(1)} chars/tk)</span>`;
489+
const indent = isOtherChild ? '<span style="display:inline-block;width:12px"></span>' : '';
490+
labelWrapper.innerHTML = `${indent}${getModelDisplayName(item.model)} <span style="color:#9aa0a6;font-size:11px; font-weight:500;">(~${item.charsPerToken.toFixed(1)} chars/tk)</span>`;
482491
labelTd.append(labelWrapper);
483492

484493
const todayTd = document.createElement('td');
@@ -504,8 +513,89 @@ function buildModelTbody(stats: DetailedStats, allModels: string[]): HTMLTableSe
504513
projTd.textContent = formatCompact(item.projected);
505514

506515
tr.append(labelTd, todayTd, last30DaysTd, lastMonthTd, projTd);
507-
tbody.append(tr);
508-
});
516+
return tr;
517+
}
518+
519+
const topItems = topModels.map(toModelItem);
520+
sortItems(topItems);
521+
522+
const tbody = document.createElement('tbody');
523+
topItems.forEach(item => tbody.append(buildModelRow(item, false)));
524+
525+
// "Other" group — only rendered when there are more than TOP_N_MODELS models
526+
if (otherModels.length > 0) {
527+
// Aggregate summed stats across all periods for the "Other" group
528+
const sumUsage = (period: 'today' | 'last30Days' | 'lastMonth') =>
529+
otherModels.reduce(
530+
(acc, m) => {
531+
const u = stats[period].modelUsage[m] || { inputTokens: 0, outputTokens: 0 };
532+
return { inputTokens: acc.inputTokens + u.inputTokens, outputTokens: acc.outputTokens + u.outputTokens };
533+
},
534+
{ inputTokens: 0, outputTokens: 0 }
535+
);
536+
const otherToday = sumUsage('today');
537+
const otherLast30 = sumUsage('last30Days');
538+
const otherLastMonth = sumUsage('lastMonth');
539+
const otherTodayTotal = otherToday.inputTokens + otherToday.outputTokens;
540+
const otherLast30Total = otherLast30.inputTokens + otherLast30.outputTokens;
541+
const otherLastMonthTotal = otherLastMonth.inputTokens + otherLastMonth.outputTokens;
542+
const otherProjected = Math.round(calculateProjection(otherLast30Total));
543+
544+
const pct = (part: number, total: number) => (total > 0 ? (part / total) * 100 : 0);
545+
546+
// "Other" summary row
547+
const otherTr = document.createElement('tr');
548+
otherTr.style.cursor = 'pointer';
549+
otherTr.style.background = 'var(--list-hover-bg)';
550+
otherTr.title = modelOtherExpanded ? 'Collapse other models' : 'Expand other models';
551+
552+
const otherLabelTd = document.createElement('td');
553+
const otherLabelWrapper = document.createElement('span');
554+
otherLabelWrapper.className = 'metric-label';
555+
const toggleIcon = modelOtherExpanded ? '▲' : '▼';
556+
otherLabelWrapper.innerHTML = `<span style="color:var(--text-secondary);font-weight:600;">📦 Other (${otherModels.length} model${otherModels.length !== 1 ? 's' : ''})</span> <span style="font-size:10px;color:var(--text-muted)">${toggleIcon}</span>`;
557+
otherLabelTd.append(otherLabelWrapper);
558+
559+
const otherTodayTd = document.createElement('td');
560+
otherTodayTd.className = 'value-right align-right';
561+
otherTodayTd.textContent = formatCompact(otherTodayTotal);
562+
if (otherTodayTotal > 0) {
563+
otherTodayTd.append(el('div', 'muted', `↑${formatPercent(pct(otherToday.inputTokens, otherTodayTotal))}${formatPercent(pct(otherToday.outputTokens, otherTodayTotal))}`));
564+
}
565+
566+
const otherLast30Td = document.createElement('td');
567+
otherLast30Td.className = 'value-right align-right';
568+
otherLast30Td.textContent = formatCompact(otherLast30Total);
569+
if (otherLast30Total > 0) {
570+
otherLast30Td.append(el('div', 'muted', `↑${formatPercent(pct(otherLast30.inputTokens, otherLast30Total))}${formatPercent(pct(otherLast30.outputTokens, otherLast30Total))}`));
571+
}
572+
573+
const otherLastMonthTd = document.createElement('td');
574+
otherLastMonthTd.className = 'value-right align-right';
575+
otherLastMonthTd.textContent = formatCompact(otherLastMonthTotal);
576+
if (otherLastMonthTotal > 0) {
577+
otherLastMonthTd.append(el('div', 'muted', `↑${formatPercent(pct(otherLastMonth.inputTokens, otherLastMonthTotal))}${formatPercent(pct(otherLastMonth.outputTokens, otherLastMonthTotal))}`));
578+
}
579+
580+
const otherProjTd = document.createElement('td');
581+
otherProjTd.className = 'value-right align-right';
582+
otherProjTd.textContent = formatCompact(otherProjected);
583+
584+
otherTr.append(otherLabelTd, otherTodayTd, otherLast30Td, otherLastMonthTd, otherProjTd);
585+
otherTr.addEventListener('click', () => {
586+
modelOtherExpanded = !modelOtherExpanded;
587+
saveSortSettings();
588+
onToggleOther();
589+
});
590+
tbody.append(otherTr);
591+
592+
// When expanded, show individual "other" model rows beneath the summary row
593+
if (modelOtherExpanded) {
594+
const otherItems = otherModels.map(toModelItem);
595+
sortItems(otherItems);
596+
otherItems.forEach(item => tbody.append(buildModelRow(item, true)));
597+
}
598+
}
509599

510600
return tbody;
511601
}
@@ -521,6 +611,15 @@ function buildModelUsageSection(stats: DetailedStats): HTMLElement | null {
521611
return null;
522612
}
523613

614+
// Determine top N models by last30Days usage; the rest go into the "Other" group
615+
const sortedByLast30Days = Array.from(allModels).sort((a, b) => {
616+
const aUsage = stats.last30Days.modelUsage[a] || { inputTokens: 0, outputTokens: 0 };
617+
const bUsage = stats.last30Days.modelUsage[b] || { inputTokens: 0, outputTokens: 0 };
618+
return (bUsage.inputTokens + bUsage.outputTokens) - (aUsage.inputTokens + aUsage.outputTokens);
619+
});
620+
const topModels = sortedByLast30Days.slice(0, TOP_N_MODELS);
621+
const otherModels = sortedByLast30Days.slice(TOP_N_MODELS);
622+
524623
const section = el('div', 'section');
525624
const heading = el('h3');
526625
heading.textContent = '🎯 Model Usage (Tokens)';
@@ -539,6 +638,13 @@ function buildModelUsageSection(stats: DetailedStats): HTMLElement | null {
539638
{ icon: '🌍', text: 'Projected Year', key: 'projected' }
540639
];
541640
const modelHeaderWraps: HTMLElement[] = [];
641+
642+
function rebuildTbody(): void {
643+
const newTbody = buildModelTbody(stats, topModels, otherModels, rebuildTbody);
644+
const oldTbody = table.querySelector('tbody');
645+
if (oldTbody) { table.replaceChild(newTbody, oldTbody); } else { table.append(newTbody); }
646+
}
647+
542648
modelColHeaders.forEach((h, idx) => {
543649
const th = document.createElement('th');
544650
th.className = idx === 0 ? '' : 'align-right';
@@ -559,16 +665,14 @@ function buildModelUsageSection(stats: DetailedStats): HTMLElement | null {
559665
modelHeaderWraps.forEach((w, i) => {
560666
w.textContent = `${modelColHeaders[i].icon} ${modelColHeaders[i].text}${getSortIndicator(modelColHeaders[i].key, modelSortKey, modelSortDir)}`;
561667
});
562-
const newTbody = buildModelTbody(stats, Array.from(allModels));
563-
const oldTbody = table.querySelector('tbody');
564-
if (oldTbody) { table.replaceChild(newTbody, oldTbody); } else { table.append(newTbody); }
668+
rebuildTbody();
565669
saveSortSettings();
566670
});
567671
headerRow.append(th);
568672
});
569673
thead.append(headerRow);
570674
table.append(thead);
571-
table.append(buildModelTbody(stats, Array.from(allModels)));
675+
rebuildTbody();
572676
section.append(table);
573677
return section;
574678
}

0 commit comments

Comments
 (0)