Skip to content

Commit 9e1a79f

Browse files
Copilotrajbos
andauthored
feat(vs-extension): Load all views with a single CLI call instead of one per view (#500)
* Initial plan * feat: run all CLI calls in one grouped call for Visual Studio Extension Agent-Logs-Url: https://github.com/rajbos/github-copilot-token-usage/sessions/7676b466-5f83-4dbb-be78-e93f82957433 Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> * remove accidentally committed obj/ files from tracking Agent-Logs-Url: https://github.com/rajbos/github-copilot-token-usage/sessions/7676b466-5f83-4dbb-be78-e93f82957433 Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: rajbos <6085745+rajbos@users.noreply.github.com> Co-authored-by: Rob Bos <rajbos@users.noreply.github.com>
1 parent 93a682f commit 9e1a79f

File tree

11 files changed

+398
-18558
lines changed

11 files changed

+398
-18558
lines changed

cli/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { fluencyCommand } from './commands/fluency';
1212
import { diagnosticsCommand } from './commands/diagnostics';
1313
import { chartCommand } from './commands/chart';
1414
import { usageAnalysisCommand } from './commands/usage-analysis';
15+
import { allCommand } from './commands/all';
1516
import { loadCache, saveCache, disableCache } from './helpers';
1617

1718
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -44,5 +45,6 @@ program.addCommand(fluencyCommand);
4445
program.addCommand(diagnosticsCommand);
4546
program.addCommand(chartCommand);
4647
program.addCommand(usageAnalysisCommand);
48+
program.addCommand(allCommand);
4749

4850
program.parse();

cli/src/commands/all.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* `all` command - Output all view data in a single JSON response.
3+
* Used by the Visual Studio extension to load every view in one CLI call
4+
* instead of spawning four separate processes.
5+
*/
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
import { Command } from 'commander';
9+
import {
10+
discoverSessionFiles,
11+
calculateDetailedStats,
12+
calculateDailyStats,
13+
buildChartPayload,
14+
calculateUsageAnalysisStats,
15+
} from '../helpers';
16+
import { calculateMaturityScores } from '../../../vscode-extension/src/maturityScoring';
17+
import type { WorkspaceCustomizationMatrix } from '../../../vscode-extension/src/types';
18+
19+
/**
20+
* Builds a WorkspaceCustomizationMatrix by deriving workspace folder paths from
21+
* VS Code-style session file paths (workspaceStorage/<hash>/chatSessions/<file>),
22+
* then checking each workspace for .github/copilot-instructions.md or agents.md.
23+
*
24+
* Non-VS Code session files (Crush, OpenCode, Copilot CLI, Visual Studio) are skipped.
25+
*/
26+
async function buildCustomizationMatrix(sessionFiles: string[]): Promise<WorkspaceCustomizationMatrix | undefined> {
27+
const workspacePaths = new Set<string>();
28+
29+
for (const sessionFile of sessionFiles) {
30+
// Expected structure: .../workspaceStorage/<hash>/chatSessions/<file>
31+
const chatSessionsDir = path.dirname(sessionFile);
32+
if (path.basename(chatSessionsDir) !== 'chatSessions') { continue; }
33+
const hashDir = path.dirname(chatSessionsDir);
34+
const workspaceJsonPath = path.join(hashDir, 'workspace.json');
35+
36+
try {
37+
if (!fs.existsSync(workspaceJsonPath)) { continue; }
38+
const content = JSON.parse(await fs.promises.readFile(workspaceJsonPath, 'utf-8'));
39+
const folderUri: string | undefined = content.folder;
40+
if (!folderUri || !folderUri.startsWith('file://')) { continue; }
41+
42+
// Convert file URI to a local path, handling Windows drive letters
43+
let folderPath = decodeURIComponent(folderUri.replace(/^file:\/\//, ''));
44+
// On Windows, file:///C:/... becomes /C:/... — strip the leading slash
45+
if (/^\/[A-Za-z]:/.test(folderPath)) { folderPath = folderPath.slice(1); }
46+
workspacePaths.add(folderPath);
47+
} catch {
48+
// Skip unreadable workspace.json files
49+
}
50+
}
51+
52+
if (workspacePaths.size === 0) { return undefined; }
53+
54+
let workspacesWithIssues = 0;
55+
for (const wsPath of workspacePaths) {
56+
try {
57+
const hasInstructions = fs.existsSync(path.join(wsPath, '.github', 'copilot-instructions.md'));
58+
const hasAgentsMd = fs.existsSync(path.join(wsPath, 'agents.md'));
59+
if (!hasInstructions && !hasAgentsMd) { workspacesWithIssues++; }
60+
} catch {
61+
workspacesWithIssues++; // Count inaccessible workspaces as lacking customization
62+
}
63+
}
64+
65+
return {
66+
customizationTypes: [],
67+
workspaces: [],
68+
totalWorkspaces: workspacePaths.size,
69+
workspacesWithIssues,
70+
};
71+
}
72+
73+
export const allCommand = new Command('all')
74+
.description('Output all view data in a single JSON response (for Visual Studio extension)')
75+
.option('--json', 'Output raw JSON (required)')
76+
.action(async (options) => {
77+
if (!options.json) {
78+
process.stderr.write('Use --json flag for all data output\n');
79+
return;
80+
}
81+
82+
const now = new Date();
83+
const files = await discoverSessionFiles();
84+
85+
if (files.length === 0) {
86+
const empty = {
87+
details: {
88+
today: {}, month: {}, lastMonth: {}, last30Days: {},
89+
lastUpdated: now.toISOString(), backendConfigured: false,
90+
},
91+
chart: {
92+
labels: [], tokensData: [], sessionsData: [], modelDatasets: [],
93+
editorDatasets: [], editorTotalsMap: {}, repositoryDatasets: [],
94+
repositoryTotalsMap: {}, dailyCount: 0, totalTokens: 0,
95+
avgTokensPerDay: 0, totalSessions: 0,
96+
lastUpdated: now.toISOString(), backendConfigured: false,
97+
},
98+
usage: {
99+
today: {}, last30Days: {}, month: {},
100+
locale: Intl.DateTimeFormat().resolvedOptions().locale,
101+
lastUpdated: now.toISOString(), backendConfigured: false,
102+
},
103+
fluency: {},
104+
};
105+
process.stdout.write(JSON.stringify(empty));
106+
return;
107+
}
108+
109+
// Run the three independent stat computations in parallel.
110+
// The in-memory CLI session cache means each file is only parsed once even
111+
// though all three functions iterate the same session file list.
112+
const [detailedStats, { labels, days }, usageStats] = await Promise.all([
113+
calculateDetailedStats(files),
114+
calculateDailyStats(files),
115+
calculateUsageAnalysisStats(files),
116+
]);
117+
118+
// Build chart payload from daily stats
119+
const chartPayload = buildChartPayload(labels, days);
120+
121+
// Build details payload (mirrors the `usage --json` output)
122+
const detailsPayload = {
123+
today: detailedStats.today,
124+
month: detailedStats.month,
125+
lastMonth: detailedStats.lastMonth,
126+
last30Days: detailedStats.last30Days,
127+
lastUpdated: detailedStats.lastUpdated.toISOString(),
128+
backendConfigured: false,
129+
};
130+
131+
// Build usage-analysis payload (mirrors the `usage-analysis --json` output)
132+
const usagePayload = {
133+
...usageStats,
134+
locale: Intl.DateTimeFormat().resolvedOptions().locale,
135+
lastUpdated: now.toISOString(),
136+
backendConfigured: false,
137+
};
138+
139+
// Build fluency/maturity payload (mirrors the `fluency --json` output)
140+
const customizationMatrix = await buildCustomizationMatrix(files);
141+
const scores = await calculateMaturityScores(
142+
customizationMatrix,
143+
async () => usageStats,
144+
false
145+
);
146+
const fluencyPayload = {
147+
overallStage: scores.overallStage,
148+
overallLabel: scores.overallLabel,
149+
categories: scores.categories,
150+
period: scores.period,
151+
lastUpdated: scores.lastUpdated,
152+
backendConfigured: false,
153+
};
154+
155+
const payload = {
156+
details: detailsPayload,
157+
chart: chartPayload,
158+
usage: usagePayload,
159+
fluency: fluencyPayload,
160+
};
161+
162+
process.stdout.write(JSON.stringify(payload));
163+
});

visualstudio-extension/src/CopilotTokenTracker.Tests/CliBridgeTests.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using CopilotTokenTracker.Data;
22
using Microsoft.VisualStudio.TestTools.UnitTesting;
3+
using System.Threading.Tasks;
34

45
namespace CopilotTokenTracker.Tests
56
{
@@ -26,5 +27,14 @@ public void IsAvailable_ReturnsBoolean()
2627
var result = CliBridge.IsAvailable();
2728
Assert.IsTrue(result == true || result == false);
2829
}
30+
31+
[TestMethod]
32+
public void GetAllDataAsync_ReturnsTask()
33+
{
34+
// GetAllDataAsync() returns a Task regardless of whether the CLI exe is present.
35+
// Verify the method is callable and returns a non-null Task without throwing.
36+
var task = CliBridge.GetAllDataAsync();
37+
Assert.IsNotNull(task);
38+
}
2939
}
3040
}

0 commit comments

Comments
 (0)