From 985bd8e7d8f0356dc97b79af70ae97774f09f2b7 Mon Sep 17 00:00:00 2001 From: CodyKoInABox Date: Sun, 2 Aug 2026 03:26:22 -0300 Subject: [PATCH 1/8] switch time to 24h format --- src/extension.ts | 19 ++++++++++++++++++- src/media/view.js | 8 +++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 511e595..61a4626 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -386,7 +386,24 @@ function fmtSinceClock(iso: string): string { if (Number.isNaN(d.getTime())) { return 'reset'; } - return d.toLocaleString(); + const clock = { + hour: '2-digit' as const, + minute: '2-digit' as const, + hour12: false as const, + }; + const now = new Date(); + const sameDay = + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate(); + if (sameDay) { + return d.toLocaleTimeString(undefined, clock); + } + return d.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + ...clock, + }); } function updateStatusBar( diff --git a/src/media/view.js b/src/media/view.js index c44ca80..1ab93cd 100644 --- a/src/media/view.js +++ b/src/media/view.js @@ -48,6 +48,7 @@ year: 'numeric', hour: '2-digit', minute: '2-digit', + hour12: false, }); } @@ -139,6 +140,7 @@ const clock = { hour: '2-digit', minute: '2-digit', + hour12: false, }; if (kind === 'hour') { @@ -156,11 +158,7 @@ return { text: 'rolling 1h' }; } - if (kind === 'session') { - return { text: `since ${d.toLocaleTimeString(undefined, clock)}` }; - } - - if (kind === 'custom') { + if (kind === 'session' || kind === 'custom') { const now = new Date(); const sameDay = d.getFullYear() === now.getFullYear() && From 0be2c7032ffd378aed5e8afb8b7d4ab7806cefc3 Mon Sep 17 00:00:00 2001 From: CodyKoInABox Date: Sun, 2 Aug 2026 03:36:50 -0300 Subject: [PATCH 2/8] last hour usage now persists through IDE sessions --- README.md | 2 +- src/extension.ts | 17 +++++++++-- src/usageWindows.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++ src/usageWindows.ts | 48 +++++++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 618190a..776787a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Live **Cursor Models** and **Other Models** percentages in a dockable sidebar an - **Sidebar panel** — CM / OM usage at a glance in the activity bar - **Status bar chip** — `CM n% · OM n%` always visible while you work, or your **Usage so far** deltas instead - **Billing cycle** — progress through the period, end time, and a simple projection -- **Session deltas** — last-hour and IDE-session usage while the extension is sampling +- **Session deltas** — last-hour usage persists across reloads (while sampling in the prior hour); IDE-session usage is since this Cursor window opened - **Usage so far** — custom resettable window that persists across reloads; track any period you care about. Auto-resets when a new billing cycle starts - **Smart refresh** — updates on focus and AI activity; pauses when Cursor is unfocused diff --git a/src/extension.ts b/src/extension.ts index 61a4626..7cf039e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,9 @@ import { CursorApiError, fetchUsageSnapshot } from './api'; import { UsageViewProvider } from './usageViewProvider'; import type { UsageSnapshot } from './types'; import { + parseUsageSamples, parseUsageSoFarState, + USAGE_SAMPLES_KEY, USAGE_SO_FAR_BASELINE_KEY, UsageWindowTracker, } from './usageWindows'; @@ -64,11 +66,22 @@ export function activate(context: vscode.ExtensionContext): void { windowTracker.loadCustomBaseline(storedBaseline); } - const persistCustomBaselineIfNeeded = async (): Promise => { + const storedSamples = parseUsageSamples( + context.globalState.get(USAGE_SAMPLES_KEY) + ); + if (storedSamples.length) { + windowTracker.loadSamples(storedSamples); + } + + const persistWindowsIfNeeded = async (): Promise => { const toSave = windowTracker.takeCustomBaselineIfNeedsPersist(); if (toSave) { await context.globalState.update(USAGE_SO_FAR_BASELINE_KEY, toSave); } + const samples = windowTracker.takeSamplesIfNeedsPersist(); + if (samples) { + await context.globalState.update(USAGE_SAMPLES_KEY, samples); + } }; const applySnapshot = async ( @@ -76,7 +89,7 @@ export function activate(context: vscode.ExtensionContext): void { opts?: { force?: boolean } ): Promise => { lastSnapshot = snapshot; - await persistCustomBaselineIfNeeded(); + await persistWindowsIfNeeded(); return provider.showUsage(snapshot, opts); }; diff --git a/src/usageWindows.test.ts b/src/usageWindows.test.ts index 2d3755e..1c53619 100644 --- a/src/usageWindows.test.ts +++ b/src/usageWindows.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { UsageSnapshot } from './types'; import { isUsageSample, + parseUsageSamples, parseUsageSoFarState, UsageWindowTracker, } from './usageWindows'; @@ -53,6 +54,14 @@ describe('persisted usage state parsing', () => { expect(parseUsageSoFarState(sample)).toEqual({ baseline: sample }); expect(parseUsageSoFarState({ baseline: {} })).toBeUndefined(); }); + + it('parses a sample ring and skips garbage', () => { + expect(parseUsageSamples(null)).toEqual([]); + expect(parseUsageSamples({})).toEqual([]); + expect( + parseUsageSamples([sample, { at: 'bad' }, null, sample]) + ).toEqual([sample, sample]); + }); }); describe('UsageWindowTracker', () => { @@ -119,9 +128,57 @@ describe('UsageWindowTracker', () => { expect(tracker.takeCustomBaselineIfNeedsPersist()).toBeUndefined(); }); + it('round-trips the sample ring so last-hour survives a restart', () => { + const a = new UsageWindowTracker(); + a.record(snapshot(10, 20), BASE_TIME); + a.record(snapshot(12, 24), BASE_TIME + 30 * MINUTE_MS); + a.record(snapshot(15, 29), BASE_TIME + 90 * MINUTE_MS); + + const persisted = a.takeSamplesIfNeedsPersist(); + expect(persisted).toBeDefined(); + expect(a.takeSamplesIfNeedsPersist()).toBeUndefined(); + + const b = new UsageWindowTracker(); + b.loadSamples(persisted!, BASE_TIME + 90 * MINUTE_MS); + expect(b.lastHour(BASE_TIME + 90 * MINUTE_MS)).toMatchObject({ + autoPercentDelta: 3, + apiPercentDelta: 5, + since: new Date(BASE_TIME + 30 * MINUTE_MS).toISOString(), + partial: false, + outdated: false, + }); + }); + + it('does not seed session from loaded samples', () => { + const a = new UsageWindowTracker(); + a.record(snapshot(10, 20), BASE_TIME); + a.record(snapshot(14, 28), BASE_TIME + 45 * MINUTE_MS); + const persisted = a.takeSamplesIfNeedsPersist()!; + + const b = new UsageWindowTracker(); + b.loadSamples(persisted, BASE_TIME + 45 * MINUTE_MS); + const reopenAt = BASE_TIME + 50 * MINUTE_MS; + b.record(snapshot(15, 30), reopenAt); + + expect(b.session()).toMatchObject({ + autoPercentDelta: 0, + apiPercentDelta: 0, + since: new Date(reopenAt).toISOString(), + partial: false, + }); + expect(b.lastHour(reopenAt)).toMatchObject({ + autoPercentDelta: 5, + apiPercentDelta: 10, + since: new Date(BASE_TIME).toISOString(), + partial: true, + }); + }); + it('resets all windows when the billing cycle changes', () => { const tracker = new UsageWindowTracker(); tracker.record(snapshot(90, 80), BASE_TIME); + expect(tracker.takeSamplesIfNeedsPersist()?.length).toBe(1); + tracker.record( snapshot(2, 3, { billingCycleStart: '2026-08-01', @@ -142,6 +199,13 @@ describe('UsageWindowTracker', () => { expect(tracker.takeCustomBaselineIfNeedsPersist()?.cycleKey).toBe( 'start:2026-08-01' ); + expect(tracker.takeSamplesIfNeedsPersist()).toEqual([ + { + at: BASE_TIME + MINUTE_MS, + autoPercentUsed: 2, + apiPercentUsed: 3, + }, + ]); }); it('also detects rollover when cumulative usage drops significantly', () => { diff --git a/src/usageWindows.ts b/src/usageWindows.ts index 8d989c0..a85cb9c 100644 --- a/src/usageWindows.ts +++ b/src/usageWindows.ts @@ -15,6 +15,9 @@ export interface UsageSoFarState { /** globalState key for the user-resettable "Usage so far" baseline. */ export const USAGE_SO_FAR_BASELINE_KEY = 'cursorPlanUsage.usageSoFarBaseline'; +/** globalState key for the pruned last-hour sample ring. */ +export const USAGE_SAMPLES_KEY = 'cursorPlanUsage.usageSamples'; + const HOUR_MS = 60 * 60 * 1000; /** Keep slightly more than 1h so we always have a pre-window baseline. */ const RETAIN_MS = HOUR_MS + 15 * 60 * 1000; @@ -93,8 +96,26 @@ export function parseUsageSoFarState( return undefined; } +/** Reads a persisted sample ring; skips malformed entries. */ +export function parseUsageSamples(value: unknown): UsageSample[] { + if (!Array.isArray(value)) { + return []; + } + const out: UsageSample[] = []; + for (const item of value) { + if (isUsageSample(item)) { + out.push({ + at: item.at, + autoPercentUsed: item.autoPercentUsed, + apiPercentUsed: item.apiPercentUsed, + }); + } + } + return out; +} + /** - * In-memory ring of period-usage samples for last-hour / IDE-session deltas. + * Sample ring for last-hour (persisted) and IDE-session (in-memory) deltas. * Absolute spend/% come from GetCurrentPeriodUsage; windows are local diffs. */ export class UsageWindowTracker { @@ -104,6 +125,8 @@ export class UsageWindowTracker { private cycleKey?: string; /** True when customBaseline was seeded/rolled over and not yet persisted. */ private customBaselineNeedsPersist = false; + /** True when the sample ring changed and is not yet persisted. */ + private samplesNeedPersist = false; loadCustomBaseline(state: UsageSoFarState): void { this.customBaseline = { ...state.baseline }; @@ -111,6 +134,16 @@ export class UsageWindowTracker { this.customBaselineNeedsPersist = false; } + /** + * Restore the last-hour sample ring. Does not seed sessionBaseline — that + * stays IDE-local and is set on the next live record(). + */ + loadSamples(samples: UsageSample[], at = Date.now()): void { + this.samples = samples.map((s) => ({ ...s })); + this.prune(at); + this.samplesNeedPersist = false; + } + /** * If the custom baseline was auto-seeded and not yet written, return it and * clear the dirty flag. Callers should persist to globalState. @@ -127,6 +160,18 @@ export class UsageWindowTracker { return state; } + /** + * If the sample ring changed and is not yet written, return a copy and clear + * the dirty flag. Callers should persist to globalState. + */ + takeSamplesIfNeedsPersist(): UsageSample[] | undefined { + if (!this.samplesNeedPersist) { + return undefined; + } + this.samplesNeedPersist = false; + return this.samples.map((s) => ({ ...s })); + } + /** * Reset "Usage so far" to the latest sample. Returns the new state, or * undefined if there are no samples yet. @@ -164,6 +209,7 @@ export class UsageWindowTracker { } this.samples.push(sample); this.prune(at); + this.samplesNeedPersist = true; } attachWindows(snapshot: UsageSnapshot, at = Date.now()): UsageSnapshot { From 1b4710de1cb272e7aec3f5b525175f3279b9fff9 Mon Sep 17 00:00:00 2001 From: CodyKoInABox Date: Sun, 2 Aug 2026 03:56:34 -0300 Subject: [PATCH 3/8] track usage since last commit --- CHANGELOG.md | 7 ++ README.md | 7 +- package.json | 8 +- src/extension.ts | 106 ++++++++++++++++++-- src/git.ts | 202 +++++++++++++++++++++++++++++++++++++++ src/media/view.js | 41 ++++++++ src/types.ts | 8 ++ src/usageViewProvider.ts | 6 +- src/usageWindows.test.ts | 98 +++++++++++++++++++ src/usageWindows.ts | 136 ++++++++++++++++++++++++++ 10 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 src/git.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b875a43..355c410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.0 + +### Features + +- Added **Since last commit** usage window: plan % burned while the working tree is dirty, re-anchored on every HEAD move (automatic; no manual reset) +- Optional status bar mode `sinceLastCommit` (`CM +n% · OM +n%`) + ## 0.2.0 ### Features diff --git a/README.md b/README.md index 776787a..e4b3b95 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ Live **Cursor Models** and **Other Models** percentages in a dockable sidebar an ## Features - **Sidebar panel** — CM / OM usage at a glance in the activity bar -- **Status bar chip** — `CM n% · OM n%` always visible while you work, or your **Usage so far** deltas instead +- **Status bar chip** — `CM n% · OM n%` always visible while you work, or **Usage so far** / **Since last commit** deltas instead - **Billing cycle** — progress through the period, end time, and a simple projection - **Session deltas** — last-hour usage persists across reloads (while sampling in the prior hour); IDE-session usage is since this Cursor window opened - **Usage so far** — custom resettable window that persists across reloads; track any period you care about. Auto-resets when a new billing cycle starts +- **Since last commit** — plan usage while your working tree is dirty; resets on each commit (HEAD move). Hidden when the tree is clean - **Smart refresh** — updates on focus and AI activity; pauses when Cursor is unfocused @@ -66,7 +67,7 @@ After install, open the **Plan Usage** icon in the activity bar, or run **Plan U | --- | --- | --- | | `cursorPlanUsage.pollIntervalSeconds` | `0` | Idle poll interval (seconds) while focused. `0` = adaptive (30s burst for 2 min after AI activity, then 3 min). Polling pauses while unfocused. | | `cursorPlanUsage.refreshOnAiActivity` | `true` | Refresh when Cursor’s local AI tracking DB updates | -| `cursorPlanUsage.statusBarMode` | `absolute` | `absolute` shows cycle totals (`CM 45% · OM 20%`); `usageSoFar` shows deltas since your last Reset (`CM +4% · OM +2%`) | +| `cursorPlanUsage.statusBarMode` | `absolute` | `absolute` shows cycle totals (`CM 45% · OM 20%`); `usageSoFar` shows deltas since your last Reset (`CM +4% · OM +2%`); `sinceLastCommit` shows deltas since HEAD while dirty (`CM +1% · OM +0%`) | | `cursorPlanUsage.apiBaseUrl` | `https://api2.cursor.sh` | Dashboard API base URL | There is no `sessionToken` setting — use the Set / Clear Session Token commands instead. @@ -77,7 +78,7 @@ There is no `sessionToken` setting — use the Set / Clear Session Token command | --- | --- | | **No token found** | Sign in to Cursor, or run **Plan Usage: Set Session Token** with a valid session token / JWT. | | **401 / unauthorized** | Re-sign into Cursor, or set a fresh token via **Plan Usage: Set Session Token**. | -| **Remote-SSH / WSL** | The extension runs in the **local** Cursor UI and uses the local session DB. If usage looks wrong in a remote window, use a local window or set a session token override. | +| **Remote-SSH / WSL** | The extension runs in the **local** Cursor UI and uses the local session DB. If usage looks wrong in a remote window, use a local window or set a session token override. **Since last commit** needs the built-in git API and stays hidden when it is unavailable in remote UI hosts. | ## Notes diff --git a/package.json b/package.json index 78ce1dd..7166d53 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "cursor-plan-usage", "displayName": "Cursor Plan Usage", "description": "Dockable sidebar showing Cursor Models and Other Models plan usage percentages.", - "version": "0.2.0", + "version": "0.3.0", "publisher": "CodyKoInABox", "license": "MIT", "icon": "resources/icon.png", @@ -111,11 +111,13 @@ "default": "absolute", "enum": [ "absolute", - "usageSoFar" + "usageSoFar", + "sinceLastCommit" ], "enumDescriptions": [ "Total usage in the current billing cycle (CM 45% · OM 20%).", - "Usage since you last pressed Reset in the sidebar (CM +4% · OM +2%)." + "Usage since you last pressed Reset in the sidebar (CM +4% · OM +2%).", + "Usage since last commit while the working tree is dirty (CM +1% · OM +0%)." ], "description": "What the status bar chip shows." }, diff --git a/src/extension.ts b/src/extension.ts index 7cf039e..7cd82de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,11 +10,14 @@ import { setSessionTokenSecret, } from './auth'; import { CursorApiError, fetchUsageSnapshot } from './api'; +import { type GitAnchor, watchGitAnchor } from './git'; import { UsageViewProvider } from './usageViewProvider'; import type { UsageSnapshot } from './types'; import { + parseAnchoredBaseline, parseUsageSamples, parseUsageSoFarState, + SINCE_LAST_COMMIT_BASELINE_KEY, USAGE_SAMPLES_KEY, USAGE_SO_FAR_BASELINE_KEY, UsageWindowTracker, @@ -31,8 +34,8 @@ const BURST_WINDOW_MS = 2 * 60 * 1000; const BURST_POLL_MS = 30_000; const IDLE_POLL_MS = 3 * 60 * 1000; -type RefreshReason = 'manual' | 'poll' | 'focus' | 'activity' | 'config'; -type StatusBarMode = 'absolute' | 'usageSoFar'; +type RefreshReason = 'manual' | 'poll' | 'focus' | 'activity' | 'config' | 'git'; +type StatusBarMode = 'absolute' | 'usageSoFar' | 'sinceLastCommit'; let pollTimer: ReturnType | undefined; let debounceTimer: ReturnType | undefined; @@ -44,8 +47,24 @@ let lastActivityAt = 0; let lastStatusKey = ''; let windowFocused = true; let lastSnapshot: UsageSnapshot | undefined; +let lastGitAnchor: GitAnchor | undefined; const windowTracker = new UsageWindowTracker(); +function stampGit(snapshot: UsageSnapshot): UsageSnapshot { + if (!lastGitAnchor || !snapshot.sinceLastCommit) { + const { git: _drop, ...rest } = snapshot; + return rest; + } + return { + ...snapshot, + git: { + repoName: lastGitAnchor.repoName, + branch: lastGitAnchor.branch, + dirtyFiles: lastGitAnchor.dirtyFiles, + }, + }; +} + export function activate(context: vscode.ExtensionContext): void { const provider = new UsageViewProvider(context.extensionUri); const statusBar = vscode.window.createStatusBarItem( @@ -73,6 +92,13 @@ export function activate(context: vscode.ExtensionContext): void { windowTracker.loadSamples(storedSamples); } + const storedAnchor = parseAnchoredBaseline( + context.workspaceState.get(SINCE_LAST_COMMIT_BASELINE_KEY) + ); + if (storedAnchor) { + windowTracker.loadAnchor(storedAnchor); + } + const persistWindowsIfNeeded = async (): Promise => { const toSave = windowTracker.takeCustomBaselineIfNeedsPersist(); if (toSave) { @@ -82,15 +108,23 @@ export function activate(context: vscode.ExtensionContext): void { if (samples) { await context.globalState.update(USAGE_SAMPLES_KEY, samples); } + const anchor = windowTracker.takeAnchorIfNeedsPersist(); + if (anchor !== undefined) { + await context.workspaceState.update( + SINCE_LAST_COMMIT_BASELINE_KEY, + anchor === null ? undefined : anchor + ); + } }; const applySnapshot = async ( snapshot: UsageSnapshot, opts?: { force?: boolean } ): Promise => { - lastSnapshot = snapshot; + const stamped = stampGit(snapshot); + lastSnapshot = stamped; await persistWindowsIfNeeded(); - return provider.showUsage(snapshot, opts); + return provider.showUsage(stamped, opts); }; const runRefresh = async (opts: { @@ -136,7 +170,7 @@ export function activate(context: vscode.ExtensionContext): void { lastSuccessAt = Date.now(); const changed = await applySnapshot(snapshot, { force: !opts.silent }); if (changed || !opts.silent) { - updateStatusBar(statusBar, snapshot); + updateStatusBar(statusBar, lastSnapshot ?? snapshot); } } catch (err) { if (err instanceof CursorApiError && err.status === 401) { @@ -146,7 +180,7 @@ export function activate(context: vscode.ExtensionContext): void { const snapshot = windowTracker.attachWindows(raw); lastSuccessAt = Date.now(); await applySnapshot(snapshot, { force: true }); - updateStatusBar(statusBar, snapshot); + updateStatusBar(statusBar, lastSnapshot ?? snapshot); return; } throw err; @@ -178,7 +212,7 @@ export function activate(context: vscode.ExtensionContext): void { } await context.globalState.update(USAGE_SO_FAR_BASELINE_KEY, state); if (lastSnapshot) { - const updated = windowTracker.overlayWindows(lastSnapshot); + const updated = stampGit(windowTracker.overlayWindows(lastSnapshot)); lastSnapshot = updated; provider.showUsage(updated, { force: true }); updateStatusBar(statusBar, updated); @@ -191,7 +225,11 @@ export function activate(context: vscode.ExtensionContext): void { reason: RefreshReason, opts?: { silent?: boolean; force?: boolean } ): void => { - const force = opts?.force === true || reason === 'manual' || reason === 'config'; + const force = + opts?.force === true || + reason === 'manual' || + reason === 'config' || + reason === 'git'; const silent = opts?.silent ?? reason !== 'manual'; void runRefresh({ silent, force }); }; @@ -264,6 +302,31 @@ export function activate(context: vscode.ExtensionContext): void { const activityWatcher = watchAiTrackingDb(onAiActivity); + const gitWatcher = watchGitAnchor((anchor) => { + const prevKey = lastGitAnchor?.key; + lastGitAnchor = anchor; + const next = windowTracker.setAnchor(anchor?.key); + if (next === undefined && prevKey === (anchor?.key ?? undefined)) { + // Metadata-only change (e.g. dirty file count) — restamp UI if we have data. + if (lastSnapshot) { + const updated = stampGit(windowTracker.overlayWindows(lastSnapshot)); + lastSnapshot = updated; + provider.showUsage(updated, { force: true }); + updateStatusBar(statusBar, updated); + } + return; + } + if (next === null) { + void context.workspaceState.update( + SINCE_LAST_COMMIT_BASELINE_KEY, + undefined + ); + } else if (next) { + void context.workspaceState.update(SINCE_LAST_COMMIT_BASELINE_KEY, next); + } + requestRefresh('git', { silent: true, force: true }); + }); + context.subscriptions.push( vscode.window.registerWebviewViewProvider(UsageViewProvider.viewId, provider, { webviewOptions: { retainContextWhenHidden: true }, @@ -332,7 +395,8 @@ export function activate(context: vscode.ExtensionContext): void { } }, }, - activityWatcher + activityWatcher, + gitWatcher ); void (async () => { @@ -429,6 +493,30 @@ function updateStatusBar( const cm = Math.round(snapshot.autoPercentUsed); const om = Math.round(snapshot.apiPercentUsed); const soFar = snapshot.usageSoFar; + const sinceCommit = snapshot.sinceLastCommit; + + if (mode === 'sinceLastCommit' && sinceCommit) { + const key = [ + 'sinceCommit', + snapshot.planName, + sinceCommit.autoPercentDelta, + sinceCommit.apiPercentDelta, + sinceCommit.since, + ].join('|'); + if (key === lastStatusKey) { + return; + } + lastStatusKey = key; + item.text = `$(git-commit) CM +${sinceCommit.autoPercentDelta}% · OM +${sinceCommit.apiPercentDelta}%`; + item.tooltip = [ + `Cursor Plan Usage — ${snapshot.planName}`, + `Since last commit (since ${fmtSinceClock(sinceCommit.since)})`, + `Cursor Models +${sinceCommit.autoPercentDelta}%`, + `Other Models +${sinceCommit.apiPercentDelta}%`, + `Cycle total — CM ${cm}% · OM ${om}%`, + ].join('\n'); + return; + } if (mode === 'usageSoFar' && soFar) { const key = [ diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..286944c --- /dev/null +++ b/src/git.ts @@ -0,0 +1,202 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +/** Minimal subset of the built-in vscode.git extension API. */ +interface GitExtension { + getAPI(version: 1): GitAPI; +} + +interface GitAPI { + repositories: Repository[]; + onDidOpenRepository: vscode.Event; + onDidCloseRepository: vscode.Event; +} + +interface Repository { + rootUri: vscode.Uri; + state: RepositoryState; +} + +interface RepositoryState { + HEAD: { name?: string; commit?: string } | undefined; + workingTreeChanges: readonly unknown[]; + indexChanges: readonly unknown[]; + mergeChanges: readonly unknown[]; + onDidChange: vscode.Event; +} + +export interface GitAnchor { + /** `{repoRoot}@{headCommit}` — Option A re-anchor key. */ + key: string; + repoName: string; + branch?: string; + dirtyFiles: number; +} + +const DEBOUNCE_MS = 500; + +function getGitApi(): GitAPI | undefined { + const ext = vscode.extensions.getExtension('vscode.git'); + if (!ext) { + return undefined; + } + try { + if (!ext.isActive) { + // Fire-and-forget activate; callers will re-emit once repos open. + void ext.activate(); + return undefined; + } + return ext.exports.getAPI(1); + } catch { + return undefined; + } +} + +function repoContainsUri(repo: Repository, uri: vscode.Uri): boolean { + const root = repo.rootUri.fsPath.replace(/\\/g, '/').toLowerCase(); + const file = uri.fsPath.replace(/\\/g, '/').toLowerCase(); + return file === root || file.startsWith(root.endsWith('/') ? root : `${root}/`); +} + +function pickRepo(api: GitAPI): Repository | undefined { + const repos = api.repositories; + if (!repos.length) { + return undefined; + } + const active = vscode.window.activeTextEditor?.document.uri; + if (active && active.scheme === 'file') { + const match = repos.find((r) => repoContainsUri(r, active)); + if (match) { + return match; + } + } + return repos[0]; +} + +function computeAnchor(api: GitAPI | undefined): GitAnchor | undefined { + if (!api) { + return undefined; + } + const repo = pickRepo(api); + if (!repo) { + return undefined; + } + const s = repo.state; + const dirtyFiles = + s.workingTreeChanges.length + + s.indexChanges.length + + s.mergeChanges.length; + if (dirtyFiles === 0) { + return undefined; + } + const head = s.HEAD?.commit ?? 'none'; + const root = repo.rootUri.fsPath; + const repoName = path.basename(root) || 'repo'; + return { + key: `${root}@${head}`, + repoName, + branch: s.HEAD?.name, + dirtyFiles, + }; +} + +/** + * Watch the active git repo and emit an Option-A anchor while the tree is dirty. + * Emits undefined when clean, no repo, or vscode.git is unavailable (e.g. Remote UI). + */ +export function watchGitAnchor( + onChange: (anchor: GitAnchor | undefined) => void +): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + let timer: ReturnType | undefined; + let lastSig = ''; + let repoListeners: vscode.Disposable[] = []; + + const emit = (): void => { + const anchor = computeAnchor(getGitApi()); + const sig = anchor + ? `${anchor.key}|${anchor.dirtyFiles}|${anchor.branch ?? ''}|${anchor.repoName}` + : ''; + if (sig === lastSig) { + return; + } + lastSig = sig; + onChange(anchor); + }; + + const schedule = (): void => { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = undefined; + emit(); + }, DEBOUNCE_MS); + }; + + const bindRepos = (): void => { + for (const d of repoListeners) { + d.dispose(); + } + repoListeners = []; + const api = getGitApi(); + if (!api) { + return; + } + for (const repo of api.repositories) { + repoListeners.push(repo.state.onDidChange(schedule)); + } + }; + + const api = getGitApi(); + if (api) { + disposables.push(api.onDidOpenRepository(() => { + bindRepos(); + schedule(); + })); + disposables.push(api.onDidCloseRepository(() => { + bindRepos(); + schedule(); + })); + bindRepos(); + } + + disposables.push( + vscode.window.onDidChangeActiveTextEditor(() => { + bindRepos(); + schedule(); + }), + vscode.extensions.onDidChange(() => { + bindRepos(); + schedule(); + }) + ); + + // Initial emit (may be undefined until git activates). + schedule(); + + // Retry once git finishes activating. + const ext = vscode.extensions.getExtension('vscode.git'); + if (ext && !ext.isActive) { + void ext.activate().then(() => { + bindRepos(); + schedule(); + }); + } + + return { + dispose: () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + for (const d of repoListeners) { + d.dispose(); + } + repoListeners = []; + for (const d of disposables) { + d.dispose(); + } + }, + }; +} diff --git a/src/media/view.js b/src/media/view.js index 1ab93cd..393a215 100644 --- a/src/media/view.js +++ b/src/media/view.js @@ -223,6 +223,42 @@ ); } + function sinceLastCommitSubtitle(w, git) { + const tip = + 'Plan usage since your working tree went dirty at this HEAD. Not attributed to specific files.'; + if (git && (git.branch || git.dirtyFiles)) { + const parts = []; + if (git.branch) parts.push(String(git.branch)); + if (git.dirtyFiles) { + const n = git.dirtyFiles; + parts.push(`${n} file${n === 1 ? '' : 's'}`); + } + return { text: parts.join(' · '), tip }; + } + const since = sinceInfo(w, 'custom'); + return since ? { text: since.text, tip } : { text: '', tip }; + } + + function renderSinceLastCommit(w, git) { + if (!w) return ''; + const sub = sinceLastCommitSubtitle(w, git); + return ( + `
` + + `
` + + `
` + + `

Since last commit

` + + `
` + + (sub.text + ? `

${esc(sub.text)}

` + : '') + + `
` + + `CM ${esc(fmtDeltaPct(w.autoPercentDelta))}` + + `OM ${esc(fmtDeltaPct(w.apiPercentDelta))}` + + `
` + + `
` + ); + } + function renderLoading() { app.innerHTML = '
Loading plan usage…
'; } @@ -266,6 +302,10 @@ : ''; const usageSoFar = renderUsageSoFar(data.usageSoFar); + const sinceLastCommit = renderSinceLastCommit( + data.sinceLastCommit, + data.git + ); const windows = data.lastHour || data.session @@ -302,6 +342,7 @@ cyclePct ) + usageSoFar + + sinceLastCommit + windows + `
` + `