Skip to content

Commit 63d77d3

Browse files
committed
Toolbar works
1 parent dc4f71b commit 63d77d3

24 files changed

+836
-909
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,10 @@ visualstudio-extension/**/bin/**
2121
visualstudio-extension/visualstudio-extension/
2222
visualstudio-extension/src/CopilotTokenTracker/bin/Release/net472/MessagePack.Annotations.dll
2323
visualstudio-extension/src/CopilotTokenTracker/bin/Release/net472/System.Buffers.dll
24+
25+
# Bundled CLI exe (build artifact copied from cli/dist/)
26+
visualstudio-extension/src/CopilotTokenTracker/cli-bundle/
27+
28+
# Node.js SEA build artifacts
29+
cli/dist/sea-prep.blob
30+
cli/dist/copilot-token-tracker.exe

build.ps1

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ function Build-Cli {
6969
try {
7070
switch ($Target) {
7171
'build' { npm ci; npm run build }
72-
'package' { npm ci; npm run build:production }
72+
'package' { npm ci; npm run build:production; & pwsh -NoProfile -File bundle-exe.ps1 -SkipBuild }
7373
'test' { Write-Host " (no CLI tests yet)" }
7474
'clean' { Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue }
7575
}
@@ -79,7 +79,22 @@ function Build-Cli {
7979
}
8080

8181
# ---------------------------------------------------------------------------
82-
# Visual Studio Extension (placeholder — C#/.NET not yet scaffolded)
82+
# CLI Bundled Executable (for embedding in Visual Studio extension)
83+
# ---------------------------------------------------------------------------
84+
function Build-CliExe {
85+
Write-Step "cli: bundle-exe"
86+
Push-Location "$PSScriptRoot/cli"
87+
try {
88+
npm ci
89+
& pwsh -NoProfile -File bundle-exe.ps1
90+
if ($LASTEXITCODE -ne 0) { throw "CLI exe bundling failed" }
91+
Write-Ok "cli exe bundled."
92+
}
93+
finally { Pop-Location }
94+
}
95+
96+
# ---------------------------------------------------------------------------
97+
# Visual Studio Extension
8398
# ---------------------------------------------------------------------------
8499
function Build-VisualStudio {
85100
Write-Step "visualstudio-extension: $Target"
@@ -88,12 +103,48 @@ function Build-VisualStudio {
88103
Write-Host " (visualstudio-extension not yet scaffolded – skipping)" -ForegroundColor Yellow
89104
return
90105
}
106+
107+
# Ensure the bundled CLI exe exists (needed at runtime by the C# bridge)
108+
$cliExe = Join-Path $PSScriptRoot 'cli' 'dist' 'copilot-token-tracker.exe'
109+
if (-not (Test-Path $cliExe)) {
110+
Write-Host " Bundled CLI exe not found — building it first..." -ForegroundColor Yellow
111+
Build-CliExe
112+
}
113+
114+
# Copy the CLI exe and its runtime assets into the VS extension project
115+
$vsCliDir = Join-Path $PSScriptRoot 'visualstudio-extension' 'src' 'CopilotTokenTracker' 'cli-bundle'
116+
if (-not (Test-Path $vsCliDir)) { New-Item -ItemType Directory -Path $vsCliDir -Force | Out-Null }
117+
Copy-Item $cliExe (Join-Path $vsCliDir 'copilot-token-tracker.exe') -Force
118+
# sql.js WASM binary is loaded at runtime from the same directory as the exe
119+
$wasmSrc = Join-Path $PSScriptRoot 'cli' 'dist' 'sql-wasm.wasm'
120+
if (Test-Path $wasmSrc) {
121+
Copy-Item $wasmSrc (Join-Path $vsCliDir 'sql-wasm.wasm') -Force
122+
}
123+
Write-Host " Copied CLI exe + sql-wasm.wasm to cli-bundle/"
124+
91125
$sln = $slnFiles[0].FullName
126+
127+
# VSIX projects require Visual Studio's MSBuild (not dotnet build) because
128+
# the VSSDK build targets depend on VS-specific assemblies.
129+
$msbuild = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" `
130+
-latest -requires Microsoft.Component.MSBuild `
131+
-find 'MSBuild\**\Bin\MSBuild.exe' 2>$null | Select-Object -First 1
132+
133+
if (-not $msbuild) {
134+
# Fallback: try the well-known VS 18 (2024+) path
135+
$msbuild = "${env:ProgramFiles}\Microsoft Visual Studio\18\Enterprise\MSBuild\Current\Bin\MSBuild.exe"
136+
}
137+
138+
if (-not (Test-Path $msbuild)) {
139+
Write-Err "MSBuild not found — install the Visual Studio 'VSIX development' workload"
140+
return
141+
}
142+
92143
switch ($Target) {
93-
'build' { dotnet build $sln --configuration Release }
94-
'package' { dotnet publish $sln --configuration Release }
95-
'test' { dotnet test $sln --configuration Release }
96-
'clean' { dotnet clean $sln }
144+
'build' { & $msbuild $sln /p:Configuration=Release /t:Build /v:minimal }
145+
'package' { & $msbuild $sln /p:Configuration=Release /t:Rebuild /v:minimal }
146+
'test' { Write-Host " (no VS extension tests yet)" }
147+
'clean' { & $msbuild $sln /p:Configuration=Release /t:Clean /v:minimal }
97148
}
98149
Write-Ok "visualstudio-extension done."
99150
}

cli/bundle-exe.ps1

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
#!/usr/bin/env pwsh
2+
<#
3+
.SYNOPSIS
4+
Bundles the CLI into a single Windows executable using Node.js SEA
5+
(Single Executable Applications).
6+
7+
.DESCRIPTION
8+
Steps:
9+
1. Build cli.js via esbuild (production mode)
10+
2. Generate a SEA preparation blob from the bundle
11+
3. Copy the current node.exe and inject the blob via postject
12+
Output: cli/dist/copilot-token-tracker.exe
13+
#>
14+
param(
15+
[switch] $SkipBuild # skip the esbuild step (use existing dist/cli.js)
16+
)
17+
18+
Set-StrictMode -Version Latest
19+
$ErrorActionPreference = 'Stop'
20+
21+
$cliRoot = $PSScriptRoot
22+
$distDir = Join-Path $cliRoot 'dist'
23+
$seaBlob = Join-Path $distDir 'sea-prep.blob'
24+
$exeName = 'copilot-token-tracker.exe'
25+
$exePath = Join-Path $distDir $exeName
26+
27+
Write-Host "==> Bundling CLI as single executable" -ForegroundColor Cyan
28+
29+
# 1. Build with esbuild
30+
if (-not $SkipBuild) {
31+
Write-Host " Building cli.js (production)..."
32+
Push-Location $cliRoot
33+
try {
34+
npm run build:production
35+
if ($LASTEXITCODE -ne 0) { throw "esbuild failed" }
36+
} finally { Pop-Location }
37+
} else {
38+
Write-Host " Skipping esbuild (using existing dist/cli.js)"
39+
}
40+
41+
if (-not (Test-Path (Join-Path $distDir 'cli.js'))) {
42+
throw "dist/cli.js not found — run esbuild first"
43+
}
44+
45+
# 2. Generate the SEA preparation blob
46+
Write-Host " Generating SEA blob..."
47+
& node --experimental-sea-config (Join-Path $cliRoot 'sea-config.json')
48+
if ($LASTEXITCODE -ne 0) { throw "SEA blob generation failed" }
49+
50+
# 3. Copy node.exe and inject the blob
51+
Write-Host " Copying node.exe..."
52+
$nodeExe = (Get-Command node).Source
53+
Copy-Item $nodeExe $exePath -Force
54+
55+
# Remove the code signature so postject can write to the binary
56+
Write-Host " Removing code signature..."
57+
try {
58+
# signtool is not always available; use PowerShell to strip Authenticode
59+
Set-AuthenticodeSignature -FilePath $exePath -Certificate $null -ErrorAction SilentlyContinue 2>$null
60+
} catch {
61+
# If that didn't work, just continue — postject will handle unsigned binaries fine
62+
}
63+
64+
Write-Host " Injecting SEA blob with postject..."
65+
& npx --yes postject $exePath NODE_SEA_BLOB $seaBlob `
66+
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
67+
if ($LASTEXITCODE -ne 0) { throw "postject injection failed" }
68+
69+
# Verify
70+
$fileSize = (Get-Item $exePath).Length / 1MB
71+
Write-Host " ✓ Built: $exePath ($([math]::Round($fileSize, 1)) MB)" -ForegroundColor Green

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"scripts": {
2626
"build": "node esbuild.js",
2727
"build:production": "node esbuild.js --production",
28+
"bundle-exe": "pwsh -NoProfile -File bundle-exe.ps1",
2829
"lint": "eslint src",
2930
"check-types": "tsc --noEmit",
3031
"test": "node dist/cli.js --help && node dist/cli.js --version"

cli/sea-config.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"main": "dist/cli.js",
3+
"output": "dist/sea-prep.blob",
4+
"disableExperimentalSEAWarning": true,
5+
"useSnapshot": false,
6+
"useCodeCache": true,
7+
"assets": {
8+
"sql-wasm.wasm": "dist/sql-wasm.wasm"
9+
}
10+
}

cli/src/cli.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { usageCommand } from './commands/usage';
1010
import { environmentalCommand } from './commands/environmental';
1111
import { fluencyCommand } from './commands/fluency';
1212
import { diagnosticsCommand } from './commands/diagnostics';
13+
import { loadCache, saveCache, disableCache } from './helpers';
1314

1415
// eslint-disable-next-line @typescript-eslint/no-require-imports
1516
const packageJson = require('../package.json');
@@ -19,7 +20,20 @@ const program = new Command();
1920
program
2021
.name('copilot-token-tracker')
2122
.description('Analyze GitHub Copilot token usage from local session files')
22-
.version(packageJson.version);
23+
.version(packageJson.version)
24+
.option('--no-cache', 'Bypass the session file cache and re-parse everything');
25+
26+
// Initialise / tear-down cache around every sub-command
27+
program.hook('preAction', () => {
28+
if (program.opts().cache === false) {
29+
disableCache();
30+
} else {
31+
loadCache();
32+
}
33+
});
34+
program.hook('postAction', () => {
35+
saveCache();
36+
});
2337

2438
program.addCommand(statsCommand);
2539
program.addCommand(usageCommand);

cli/src/cliCache.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* File-based session cache for the CLI.
3+
* Stores parsed SessionData on disk so subsequent runs skip unchanged files.
4+
*
5+
* Cache file: ~/.copilot-token-tracker/cli-cache.json
6+
*/
7+
import * as fs from 'fs';
8+
import * as path from 'path';
9+
import * as os from 'os';
10+
import type { SessionData } from './helpers';
11+
12+
/** Bump this when the SessionData shape changes to force a full re-parse. */
13+
const CACHE_VERSION = 1;
14+
15+
/** Maximum number of entries to keep in the cache file. */
16+
const MAX_CACHE_ENTRIES = 2000;
17+
18+
interface CacheEntry {
19+
/** File modification time (ms since epoch) */
20+
mtime: number;
21+
/** File size in bytes */
22+
size: number;
23+
/** Parsed session data (lastModified stored as ISO string) */
24+
data: Omit<SessionData, 'lastModified'> & { lastModified: string };
25+
}
26+
27+
interface CacheFile {
28+
version: number;
29+
entries: Record<string, CacheEntry>;
30+
}
31+
32+
const CACHE_DIR = path.join(os.homedir(), '.copilot-token-tracker');
33+
const CACHE_PATH = path.join(CACHE_DIR, 'cli-cache.json');
34+
35+
let cache: Map<string, CacheEntry> = new Map();
36+
let cacheEnabled = true;
37+
let dirty = false;
38+
39+
/** Disable caching (e.g. when --no-cache is passed). */
40+
export function disableCache(): void {
41+
cacheEnabled = false;
42+
cache.clear();
43+
}
44+
45+
/** Load cache from disk. Safe to call multiple times — only loads once. */
46+
export function loadCache(): void {
47+
if (!cacheEnabled) { return; }
48+
try {
49+
if (!fs.existsSync(CACHE_PATH)) { return; }
50+
const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
51+
const parsed: CacheFile = JSON.parse(raw);
52+
if (parsed.version !== CACHE_VERSION) {
53+
// Version mismatch — discard stale cache
54+
cache.clear();
55+
return;
56+
}
57+
cache = new Map(Object.entries(parsed.entries));
58+
} catch {
59+
// Corrupt / unreadable — start fresh
60+
cache.clear();
61+
}
62+
}
63+
64+
/** Save cache to disk (only if entries were added/updated). */
65+
export function saveCache(): void {
66+
if (!cacheEnabled || !dirty) { return; }
67+
try {
68+
// Prune to MAX_CACHE_ENTRIES, keeping the most recently modified files
69+
if (cache.size > MAX_CACHE_ENTRIES) {
70+
const sorted = [...cache.entries()].sort((a, b) => b[1].mtime - a[1].mtime);
71+
cache = new Map(sorted.slice(0, MAX_CACHE_ENTRIES));
72+
}
73+
74+
fs.mkdirSync(CACHE_DIR, { recursive: true });
75+
const payload: CacheFile = {
76+
version: CACHE_VERSION,
77+
entries: Object.fromEntries(cache),
78+
};
79+
fs.writeFileSync(CACHE_PATH, JSON.stringify(payload), 'utf-8');
80+
} catch {
81+
// Best-effort — don't crash the CLI if cache write fails
82+
}
83+
}
84+
85+
/**
86+
* Look up a cached result for a session file.
87+
* Returns the SessionData if the cache entry matches the file's current mtime and size,
88+
* or null if the file needs to be re-parsed.
89+
*/
90+
export function getCached(filePath: string, mtime: number, size: number): SessionData | null {
91+
if (!cacheEnabled) { return null; }
92+
const entry = cache.get(filePath);
93+
if (!entry) { return null; }
94+
if (entry.mtime !== mtime || entry.size !== size) { return null; }
95+
// Rehydrate the Date
96+
return {
97+
...entry.data,
98+
lastModified: new Date(entry.data.lastModified),
99+
};
100+
}
101+
102+
/** Store a parsed result in the cache. */
103+
export function setCached(filePath: string, mtime: number, size: number, data: SessionData): void {
104+
if (!cacheEnabled) { return; }
105+
dirty = true;
106+
cache.set(filePath, {
107+
mtime,
108+
size,
109+
data: {
110+
...data,
111+
lastModified: data.lastModified.toISOString(),
112+
},
113+
});
114+
}
115+
116+
/** Return cache hit/miss stats for display. */
117+
export function getCacheStats(): { entries: number; enabled: boolean } {
118+
return { entries: cache.size, enabled: cacheEnabled };
119+
}

cli/src/commands/stats.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { Command } from 'commander';
55
import chalk from 'chalk';
6-
import { discoverSessionFiles, processSessionFile, getDiagnosticPaths, fmt, formatTokens } from '../helpers';
6+
import { discoverSessionFiles, processSessionFile, getDiagnosticPaths, fmt, formatTokens, getCacheStats } from '../helpers';
77

88
export const statsCommand = new Command('stats')
99
.description('Show overview of discovered session files, sessions, chat turns, and tokens')
@@ -94,6 +94,10 @@ export const statsCommand = new Command('stats')
9494
if (totalThinkingTokens > 0) {
9595
console.log(` Thinking tokens (included): ${chalk.dim(formatTokens(totalThinkingTokens))}`);
9696
}
97+
const cacheInfo = getCacheStats();
98+
if (cacheInfo.enabled && cacheInfo.entries > 0) {
99+
console.log(chalk.dim(` Cache: ${fmt(cacheInfo.entries)} entries loaded`));
100+
}
97101
console.log();
98102

99103
// Editor breakdown

0 commit comments

Comments
 (0)