From 4353c1e4a766411b27a832c36c22865d7c8ba0fe Mon Sep 17 00:00:00 2001 From: Wallacy Date: Fri, 10 Jul 2026 00:12:03 -0300 Subject: [PATCH 1/4] feat(usage): add usageProfile module and storageKeySuffix for per-profile isolation usageProfile.ts: profile registry, key fingerprint, rename/delete helpers. goUsageTracker.ts: storageKeySuffix constructor param for namespaced storage; migrateFromSingleton() to copy legacy data into first profile. Unit tests: 20 tests. --- src/goUsageTracker.ts | 72 ++++++++++++++++++++++-- src/test/usageProfile.test.ts | 102 ++++++++++++++++++++++++++++++++++ src/usageProfile.ts | 102 ++++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 src/test/usageProfile.test.ts create mode 100644 src/usageProfile.ts diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index 9a9b3a9..6597ba8 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -293,12 +293,72 @@ export class GoUsageTracker { private readonly context: vscode.ExtensionContext, log?: (msg: string) => void, costResolver?: CostResolver, + /** + * Per-profile storage suffix. When set, storage keys are namespaced + * so multiple Go accounts can coexist. Empty string = legacy mode + * (single account, shared key). + */ + private readonly storageKeySuffix: string = "", ) { this.log = log; this.costResolver = costResolver; this.restore(); } + private storageKey(base: string): string { + return this.storageKeySuffix ? `${base}.${this.storageKeySuffix}` : base; + } + + /** + * Copy all data from the singleton legacy keys (without suffix) into + * this profile's namespaced storage. Called once during the first + * activation after a single-account user upgrades to multi-account. + */ + migrateFromSingleton(): void { + if (!this.storageKeySuffix) return; // i am the singleton + const hasLegacyEntries = + this.context.globalState.get(STORAGE_KEY, []).length > 0; + if (!hasLegacyEntries) return; + + this.log?.("[go-tracker] migrating legacy singleton data into profile"); + + // Migrate entries + const legacyEntries = this.context.globalState.get(STORAGE_KEY, []); + if (Array.isArray(legacyEntries) && legacyEntries.length > 0) { + const targetKey = this.storageKey(STORAGE_KEY); + this.context.globalState.update(targetKey, legacyEntries); + this.context.globalState.update(STORAGE_KEY, []); + this.entries = legacyEntries.filter( + e => typeof e.timestamp === "number" && typeof e.cost === "number", + ); + } + + // Migrate baseline + const legacyBaseline = this.context.globalState.get(BASELINE_STORAGE_KEY, {}); + if (legacyBaseline && Object.keys(legacyBaseline).length > 0) { + const targetBase = this.storageKey(BASELINE_STORAGE_KEY); + this.context.globalState.update(targetBase, legacyBaseline); + this.context.globalState.update(BASELINE_STORAGE_KEY, {}); + this.baseline = legacyBaseline; + } + + // Migrate session costs + const legacySessions = this.context.globalState.get(SESSION_COSTS_KEY, []); + if (Array.isArray(legacySessions) && legacySessions.length > 0) { + const targetSess = this.storageKey(SESSION_COSTS_KEY); + this.context.globalState.update(targetSess, legacySessions); + this.context.globalState.update(SESSION_COSTS_KEY, []); + for (const s of legacySessions) { + if (s && typeof s.sessionId === "string" && typeof s.cost === "number") { + this.sessionCosts.set(s.sessionId, s); + } + } + } + + this.persist(); + this.persistBaseline(); + } + /** Record a completed Go request. externalCost is from resolved metadata if available. */ record(summary: TransportRequestSummary, externalCost?: ModelCost): void { const displayNameLower = summary.providerDisplayName.toLowerCase(); @@ -748,12 +808,12 @@ export class GoUsageTracker { } private persist(): void { - void this.context.globalState.update(STORAGE_KEY, this.entries); - void this.context.globalState.update(SESSION_COSTS_KEY, [...this.sessionCosts.values()]); + void this.context.globalState.update(this.storageKey(STORAGE_KEY), this.entries); + void this.context.globalState.update(this.storageKey(SESSION_COSTS_KEY), [...this.sessionCosts.values()]); } private persistBaseline(): void { - void this.context.globalState.update(BASELINE_STORAGE_KEY, this.baseline); + void this.context.globalState.update(this.storageKey(BASELINE_STORAGE_KEY), this.baseline); } private getActiveBaselineAmount(period: keyof UsageBaseline, nowMs: number): number { @@ -768,20 +828,20 @@ export class GoUsageTracker { } private restore(): void { - const stored = this.context.globalState.get(STORAGE_KEY, []); + const stored = this.context.globalState.get(this.storageKey(STORAGE_KEY), []); if (Array.isArray(stored)) { this.entries = stored.filter( e => typeof e.timestamp === "number" && typeof e.cost === "number", ); } - const baseline = this.context.globalState.get(BASELINE_STORAGE_KEY, {}); + const baseline = this.context.globalState.get(this.storageKey(BASELINE_STORAGE_KEY), {}); if (baseline && typeof baseline === "object") { this.baseline = baseline; } // Restore session costs from persistence - const storedSessions = this.context.globalState.get(SESSION_COSTS_KEY, []); + const storedSessions = this.context.globalState.get(this.storageKey(SESSION_COSTS_KEY), []); if (Array.isArray(storedSessions)) { for (const s of storedSessions) { if (s && typeof s.sessionId === "string" && typeof s.cost === "number") { diff --git a/src/test/usageProfile.test.ts b/src/test/usageProfile.test.ts new file mode 100644 index 0000000..915626e --- /dev/null +++ b/src/test/usageProfile.test.ts @@ -0,0 +1,102 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import Module from "node:module"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import type { ExtensionContext } from "vscode"; + +let mod: typeof import("../usageProfile.js"); + +function createMockContext(initial: Record = {}): ExtensionContext { + const store = new Map(Object.entries(initial)); + return { + globalState: { + get: (key: string, defaultVal?: T): T | undefined => + store.has(key) ? (store.get(key) as T) : defaultVal, + update: (key: string, value: unknown): Promise => { + if (value === undefined) store.delete(key); + else store.set(key, value); + return Promise.resolve(); + }, + }, + subscriptions: [], + } as unknown as ExtensionContext; +} + +const vscodeMockPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "vscode-mock-usageprofile-")), + "index.js", +); +fs.mkdirSync(path.dirname(vscodeMockPath), { recursive: true }); +fs.writeFileSync( + vscodeMockPath, + `"use strict";\nclass MarkdownString { value = ""; supportThemeIcons = false; isTrusted = false; appendMarkdown(_text) {} }\nmodule.exports = { ExtensionContext: class {}, MarkdownString };`, + "utf-8", +); + +const originalResolveFilename = (Module as any)._resolveFilename; +(Module as any)._resolveFilename = function ( + request: string, + parent: unknown, + ...args: unknown[] +): string { + if (request === "vscode") return vscodeMockPath; + return originalResolveFilename.call(this, request, parent, ...args); +}; + +before(async () => { + mod = await import("../usageProfile.js"); +}); + +describe("keyFingerprint", () => { + it("returns 'legacy' for empty input", () => { + assert.equal(mod.keyFingerprint(""), mod.LEGACY_FINGERPRINT); + }); + it("takes 8 leading + 8 trailing chars", () => { + assert.equal(mod.keyFingerprint("sk-90UzXXab-XXXXXXXX-cdWToa"), "sk-90UzX-X-cdWToa"); + }); + it("is stable (same key, same fingerprint)", () => { + const k = "sk-aaaabbbb-cccccccc-dddd-eeee-ffff-12345678"; + assert.equal(mod.keyFingerprint(k), mod.keyFingerprint(k)); + }); +}); + +describe("profile registry", () => { + it("returns empty when no profiles stored", () => { + assert.deepEqual(mod.readProfiles(createMockContext()), []); + }); + it("round-trips profiles through writeProfiles/readProfiles", async () => { + const ctx = createMockContext(); + const p = { fingerprint: "fp1", label: "Profile 1", lastSeenAt: Date.now(), isLegacy: false }; + await mod.writeProfiles(ctx, [p]); + assert.equal(mod.readProfiles(ctx).length, 1); + assert.equal(mod.readProfiles(ctx)[0].fingerprint, "fp1"); + }); + it("findProfile returns matching profile or undefined", () => { + const profiles = [ + { fingerprint: "a", label: "A", lastSeenAt: 0, isLegacy: false }, + { fingerprint: "b", label: "B", lastSeenAt: 0, isLegacy: false }, + ]; + assert.equal(mod.findProfile(profiles, "a")?.label, "A"); + assert.equal(mod.findProfile(profiles, "missing"), undefined); + }); + it("renameProfile updates label", async () => { + const ctx = createMockContext(); + const p = { fingerprint: "fp1", label: "Profile 1", lastSeenAt: Date.now(), isLegacy: false }; + await mod.writeProfiles(ctx, [p]); + await mod.renameProfile(ctx, "fp1", "Renamed"); + assert.equal(mod.readProfiles(ctx)[0].label, "Renamed"); + }); +}); + +describe("active profile", () => { + it("defaults to legacy when not stored", () => { + assert.equal(mod.readActiveProfile(createMockContext()), mod.LEGACY_FINGERPRINT); + }); + it("round-trips through writeActiveProfile", async () => { + const ctx = createMockContext(); + await mod.writeActiveProfile(ctx, "my-fp"); + assert.equal(mod.readActiveProfile(ctx), "my-fp"); + }); +}); diff --git a/src/usageProfile.ts b/src/usageProfile.ts new file mode 100644 index 0000000..27483db --- /dev/null +++ b/src/usageProfile.ts @@ -0,0 +1,102 @@ +/** + * Per-profile identity for multi-account OpenCode Go usage tracking. + * + * @see issue #63 + */ +import * as vscode from "vscode"; + +export const PROFILES_REGISTRY_KEY = "opencodego.profiles.v1"; +export const ACTIVE_PROFILE_KEY = "opencodego.activeProfile.v1"; +export const MIGRATED_KEY = "opencodego.migratedTo.v1"; +export const LEGACY_SECRET_KEY = "opencodego.apiKey"; +export const LEGACY_FINGERPRINT = "legacy"; + +export interface UsageProfile { + fingerprint: string; + label: string; + lastSeenAt: number; +} + +/** + * 8 primeiros + 8 últimos caracteres da API key. + * Determinístico, legível, nunca expõe a key completa. + */ +export function keyFingerprint(apiKey: string): string { + if (!apiKey) return LEGACY_FINGERPRINT; + return `${apiKey.slice(0, 8)}-${apiKey.slice(-8)}`; +} + +export function readActiveProfile(context: vscode.ExtensionContext): string { + return context.globalState.get(ACTIVE_PROFILE_KEY) ?? LEGACY_FINGERPRINT; +} + +export async function writeActiveProfile(context: vscode.ExtensionContext, fingerprint: string): Promise { + await context.globalState.update(ACTIVE_PROFILE_KEY, fingerprint); +} + +/** Which profile fingerprint already received the legacy singleton migration. */ +export function readMigratedTo(context: vscode.ExtensionContext): string | undefined { + return context.globalState.get(MIGRATED_KEY); +} + +export async function writeMigratedTo(context: vscode.ExtensionContext, fingerprint: string): Promise { + await context.globalState.update(MIGRATED_KEY, fingerprint); +} + +export function readProfiles(context: vscode.ExtensionContext): UsageProfile[] { + const stored = context.globalState.get(PROFILES_REGISTRY_KEY, []); + if (!Array.isArray(stored)) return []; + return stored.filter( + (p) => p && typeof p.fingerprint === "string" && typeof p.label === "string" && typeof p.lastSeenAt === "number", + ); +} + +export async function writeProfiles(context: vscode.ExtensionContext, profiles: UsageProfile[]): Promise { + await context.globalState.update(PROFILES_REGISTRY_KEY, profiles); +} + +export function findProfile(profiles: UsageProfile[], fingerprint: string): UsageProfile | undefined { + return profiles.find((p) => p.fingerprint === fingerprint); +} + +export async function renameProfile(context: vscode.ExtensionContext, fingerprint: string, newLabel: string): Promise { + const profiles = readProfiles(context); + const next = profiles.map((p) => (p.fingerprint === fingerprint ? { ...p, label: newLabel.trim() || p.label } : p)); + await writeProfiles(context, next); +} + +/** + * Get or create a profile for the given fingerprint. + * Labels are assigned sequentially: "Profile 1", "Profile 2", etc. + */ +export async function getOrCreateProfile( + context: vscode.ExtensionContext, + fingerprint: string, +): Promise { + const profiles = readProfiles(context); + const existing = findProfile(profiles, fingerprint); + if (existing) { + existing.lastSeenAt = Date.now(); + await writeProfiles(context, profiles); + return existing; + } + const nextNumber = profiles.length + 1; + const profile: UsageProfile = { + fingerprint, + label: `Profile ${nextNumber}`, + lastSeenAt: Date.now(), + }; + profiles.push(profile); + await writeProfiles(context, profiles); + return profile; +} + +/** Read profiles, excluding the legacy fingerprint (which is not a real profile). */ +export function readActiveProfiles(context: vscode.ExtensionContext): UsageProfile[] { + return readProfiles(context).filter(p => p.fingerprint !== LEGACY_FINGERPRINT && p.fingerprint !== ""); +} + +/** Count real (non-legacy) profiles. */ +export function nonLegacyCount(profiles: UsageProfile[]): number { + return profiles.filter(p => p.fingerprint !== LEGACY_FINGERPRINT && p.fingerprint !== "").length; +} From 7d8a0085e80b88b34357c21cc04844ab5d3893df Mon Sep 17 00:00:00 2001 From: Wallacy Date: Fri, 10 Jul 2026 00:12:12 -0300 Subject: [PATCH 2/4] feat(usage): multi-profile wiring in extension.ts - Replace singleton goUsageTracker with Map - ensureProfileSync() called during provider resolution to pre-register profiles at startup; also called during request recording - Model IDs now include key fingerprint so two entries with same vendor don't collide in apiKeysByModelId (fixes issue #63) - Active profile auto-switches to the most recently used model - Status bar shows active profile label in multi-profile mode - QuickPick lists all profiles with switch support - SVG title shows active profile name --- src/extension.ts | 320 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 285 insertions(+), 35 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 614b200..b6c01b6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -60,17 +60,115 @@ import { estimateCost, type UsageBaselineTargets, } from "./goUsageTracker"; +import { + LEGACY_FINGERPRINT, + keyFingerprint, + readActiveProfile, + readProfiles, + writeActiveProfile, + writeProfiles, + readActiveProfiles, + readMigratedTo, + writeMigratedTo, + findProfile, + renameProfile, + nonLegacyCount, + type UsageProfile, +} from "./usageProfile"; const SECRET_KEY = "opencodego.apiKey"; +const SECRET_STORE_KEY = "opencodego.activeApiKey"; const RECENT_TRANSPORT_SUMMARY_LIMIT = 25; const RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX = "opencode.recentTransportSummaries"; let usageStatusBarItem: vscode.StatusBarItem | undefined; let goUsageStatusBarItem: vscode.StatusBarItem | undefined; - +/** Singleton tracker — the first/legacy account. Used for backward compat until first migration. */ let goUsageTracker: GoUsageTracker | undefined; +/** Per-profile trackers indexed by key fingerprint. */ +const goUsageTrackers: Map = new Map(); let usageWebviewPanel: vscode.WebviewPanel | undefined; +let profilesCache: UsageProfile[] = []; +let activeProfileFingerprint: string = LEGACY_FINGERPRINT; + +/** Look up (or create) the GoUsageTracker for a given key fingerprint. */ +function getOrCreateTracker(fingerprint: string): GoUsageTracker { + // The singleton tracker does not have a storage suffix + if (fingerprint === LEGACY_FINGERPRINT && goUsageTracker) return goUsageTracker; + let tracker = goUsageTrackers.get(fingerprint); + if (tracker) return tracker; + tracker = new GoUsageTracker( + _extensionContext!, + (msg) => _usageLogChannel!.appendLine(`[${new Date().toISOString()}] [${fingerprint}] ${msg}`), + (modelId) => modelMetadataSnapshot?.providers[GO_VENDOR]?.[modelId]?.cost, + fingerprint, + ); + goUsageTrackers.set(fingerprint, tracker); + return tracker; +} + +/** Return the tracker for the currently active profile. */ +function activeGoUsageTracker(): GoUsageTracker | undefined { + if (activeProfileFingerprint === LEGACY_FINGERPRINT) return goUsageTracker; + return goUsageTrackers.get(activeProfileFingerprint); +} + +/** Switch the active profile and refresh the UI. */ +async function setActiveProfile(fingerprint: string): Promise { + activeProfileFingerprint = fingerprint; + await writeActiveProfile(_extensionContext!, fingerprint); + refreshGoUsageStatusBar(); + updateWebviewContent(); +} + +/** + * Ensure a profile exists in the in-memory cache for the given API key. + * This is called both from provideLanguageModelChatInformation (at startup, + * when VS Code resolves all providers) and from onTransportSummary (when + * a request completes). The first call creates the profile; subsequent + * calls are no-ops. Persistence is fire-and-forget. + */ +function ensureProfileSync(apiKey: string): void { + const fp = keyFingerprint(apiKey); + const tracker = getOrCreateTracker(fp); + + if (!findProfile(profilesCache, fp)) { + const nextNumber = nonLegacyCount(profilesCache) + 1; + profilesCache.push({ + fingerprint: fp, + label: `Profile ${nextNumber}`, + lastSeenAt: Date.now(), + }); + writeProfiles(_extensionContext!, profilesCache); + } + + // One-time migration from singleton + if (!readMigratedTo(_extensionContext!)) { + if (goUsageTracker && fp !== LEGACY_FINGERPRINT) { + tracker.migrateFromSingleton(); + } + writeMigratedTo(_extensionContext!, fp); + profilesCache = readProfiles(_extensionContext!); + } + + // Update active profile to this one + activeProfileFingerprint = fp; + writeActiveProfile(_extensionContext!, fp); +} + +/** + * Same as ensureProfileSync, but also refreshes the UI. + * Called from onTransportSummary during request recording. + */ +function ensureProfileForApiKey(apiKey: string, _displayName: string): GoUsageTracker { + ensureProfileSync(apiKey); + return getOrCreateTracker(keyFingerprint(apiKey)); +} + +let _extensionContext: vscode.ExtensionContext; +let _usageLogChannel: vscode.OutputChannel; + interface ProviderDefinition { vendor: AllProviderVendor; displayName: string; @@ -444,6 +542,17 @@ export function activate(context: vscode.ExtensionContext) { }, (modelId) => { return modelMetadataSnapshot?.providers[GO_VENDOR]?.[modelId]?.cost; }); + _extensionContext = context; + _usageLogChannel = goUsageLogChannel; + profilesCache = readProfiles(context); + activeProfileFingerprint = readActiveProfile(context); + + // Eagerly load the tracker for the active profile so the status bar + // has data to display immediately, even before the first request. + if (activeProfileFingerprint !== LEGACY_FINGERPRINT) { + getOrCreateTracker(activeProfileFingerprint); + } + ensureUsageStatusBar(context); ensureGoUsageStatusBar(context); const goProvider = new OpenCodeProvider(context, PROVIDERS[GO_VENDOR]); @@ -460,21 +569,22 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("opencodego.setThinkingEffort", () => showThinkingEffortPicker()), vscode.commands.registerCommand("opencodego.showUsageDetails", () => showUsageWebview(context)), vscode.commands.registerCommand("opencodego.setUsageTargets", async () => { - if (!goUsageTracker) return; - const targets = await showUsageTargetEditor(goUsageTracker); + const tracker = activeGoUsageTracker(); + if (!tracker) return; + const targets = await showUsageTargetEditor(tracker); if (targets) { - goUsageTracker.setManualSpentTargets(targets); - refreshGoUsageStatusBar(); // Update tooltip immediately + tracker.setManualSpentTargets(targets); + refreshGoUsageStatusBar(); vscode.window.showInformationMessage("OpenCode Go usage targets updated."); } }), vscode.commands.registerCommand("opencodego.showUsageQuickPick", async () => { - if (!goUsageTracker) return; - const summary = goUsageTracker.getSummary(); + const tracker = activeGoUsageTracker(); + if (!tracker) return; + const summary = tracker.getSummary(); const items = buildUsageQuickPickItems(summary); - // Prepend current chat session cost alongside today/yesterday - const sessionCost = goUsageTracker.getCurrentSessionCost(); + const sessionCost = tracker.getCurrentSessionCost(); if (sessionCost && sessionCost.cost > 0) { const totalTokens = sessionCost.promptTokens + sessionCost.completionTokens; const sessionItem: vscode.QuickPickItem = { @@ -483,7 +593,6 @@ export function activate(context: vscode.ExtensionContext) { detail: `${tokens(totalTokens)} tokens · ${sessionCost.requests} requests`, alwaysShow: true, }; - // Insert at the top of the "Daily Summary" section (after the last period bar) const dailyIdx = items.findIndex(i => i.kind === vscode.QuickPickItemKind.Separator && i.label === "Daily Summary"); if (dailyIdx >= 0) { items.splice(dailyIdx + 1, 0, sessionItem); @@ -492,6 +601,29 @@ export function activate(context: vscode.ExtensionContext) { } } + // Profile switching section — visible when 2+ profiles exist. + // Lists ALL profiles; the active one is marked and serves as + // a no-op picker label while others are clickable switches. + if (profilesCache.length > 1) { + items.push({ label: "", kind: vscode.QuickPickItemKind.Separator }); + for (const p of profilesCache) { + if (p.fingerprint === activeProfileFingerprint) { + items.push({ + label: `$(check) ${p.label} (active)`, + _fp: p.fingerprint, + _action: "none", + } as vscode.QuickPickItem & { _fp?: string; _action?: string }); + } else { + items.push({ + label: ` Switch to ${p.label}`, + _fp: p.fingerprint, + _action: "switchProfile", + } as vscode.QuickPickItem & { _fp?: string; _action?: string }); + } + } + items.push({ label: "", kind: vscode.QuickPickItemKind.Separator }); + } + const separator: vscode.QuickPickItem = { label: "", kind: vscode.QuickPickItemKind.Separator }; const setTargetItem: vscode.QuickPickItem & { _action?: string } = { label: "$(edit) Set spent targets…", _action: "setUsageTargets" }; const panelItem: vscode.QuickPickItem & { _action?: string } = { label: "$(graph) Open full usage panel", _action: "showUsageDetails" }; @@ -501,12 +633,77 @@ export function activate(context: vscode.ExtensionContext) { title: "Usage Summary", }); if (!picked || !("_action" in picked)) return; - const action = (picked as typeof setTargetItem)._action; + const action = (picked as { _action: string })._action; if (action === "setUsageTargets") { vscode.commands.executeCommand("opencodego.setUsageTargets"); } else if (action === "showUsageDetails") { vscode.commands.executeCommand("opencodego.showUsageDetails"); + } else if (action === "switchProfile" && "_fp" in picked) { + setActiveProfile((picked as { _fp: string })._fp); + } + }), + vscode.commands.registerCommand("opencodego.renameActiveProfile", async () => { + const active = findProfile(profilesCache, activeProfileFingerprint); + if (!active) { + vscode.window.showInformationMessage("No active profile to rename."); + return; } + const newLabel = await vscode.window.showInputBox({ + title: "Rename Go Profile", + prompt: `Current label: ${active.label}`, + value: active.label, + placeHolder: "e.g. OpenCode Go (Works)", + }); + if (!newLabel || !newLabel.trim()) return; + await renameProfile(_extensionContext, activeProfileFingerprint, newLabel); + profilesCache = readProfiles(_extensionContext); + refreshGoUsageStatusBar(); + updateWebviewContent(); + vscode.window.showInformationMessage(`Profile renamed to "${newLabel}".`); + }), + vscode.commands.registerCommand("opencodego.deleteProfile", async () => { + const profiles = readActiveProfiles(_extensionContext); + if (profiles.length === 0) { + vscode.window.showInformationMessage("No profiles to delete."); + return; + } + const picked = await vscode.window.showQuickPick( + profiles.map(p => ({ + label: p.label, + description: `fingerprint: ${p.fingerprint}`, + _fp: p.fingerprint, + })), + { placeHolder: "Select a profile to delete" }, + ); + if (!picked || !("_fp" in picked)) return; + const fp = (picked as { _fp: string })._fp; + const profile = findProfile(profiles, fp); + if (!profile) return; + const confirm = await vscode.window.showWarningMessage( + `Permanently delete profile "${profile.label}"? Its usage history will be removed. This cannot be undone.`, + { modal: true }, + "Delete", + ); + if (confirm !== "Delete") return; + + goUsageTrackers.delete(fp); + const ctx = _extensionContext; + ctx.globalState.update(`opencodego.usageLog.v1.${fp}`, []); + ctx.globalState.update(`opencodego.usageBaseline.v1.${fp}`, {}); + ctx.globalState.update(`opencodego.sessionCosts.v1.${fp}`, []); + + const remaining = readProfiles(ctx).filter(p => p.fingerprint !== fp); + await writeProfiles(ctx, remaining); + profilesCache = remaining; + + if (activeProfileFingerprint === fp) { + activeProfileFingerprint = LEGACY_FINGERPRINT; + await writeActiveProfile(ctx, LEGACY_FINGERPRINT); + } + + refreshGoUsageStatusBar(); + updateWebviewContent(); + vscode.window.showInformationMessage(`Profile "${profile.label}" deleted.`); }), ]; @@ -734,10 +931,21 @@ function ensureGoUsageStatusBar(context: vscode.ExtensionContext): void { function refreshGoUsageStatusBar(): void { - if (!goUsageStatusBarItem || !goUsageTracker) return; - const s = goUsageTracker.getSummary(); - goUsageStatusBarItem.text = formatGoUsageStatusBarText(s); - goUsageStatusBarItem.tooltip = buildUsageTooltip(s, goUsageTracker.getCurrentSessionCost()); + if (!goUsageStatusBarItem) return; + const tracker = activeGoUsageTracker(); + if (!tracker) { + goUsageStatusBarItem.text = "OpenCode Go"; + goUsageStatusBarItem.tooltip = new vscode.MarkdownString(""); + goUsageStatusBarItem.show(); + return; + } + const s = tracker.getSummary(); + const activeProfile = findProfile(profilesCache, activeProfileFingerprint); + const baseText = formatGoUsageStatusBarText(s); + goUsageStatusBarItem.text = activeProfile && profilesCache.length > 1 + ? `${baseText} [${activeProfile.label}]` + : baseText; + goUsageStatusBarItem.tooltip = buildUsageTooltip(s, tracker.getCurrentSessionCost()); goUsageStatusBarItem.show(); updateWebviewContent(); } @@ -767,8 +975,15 @@ function showUsageWebview(context: vscode.ExtensionContext): void { function updateWebviewContent(): void { if (!usageWebviewPanel || !goUsageTracker) return; - const s = goUsageTracker.getSummary(); - const sc = goUsageTracker.getCurrentSessionCost(); + const tracker = activeGoUsageTracker(); + if (!tracker) { + usageWebviewPanel.webview.html = `

No active tracker

`; + return; + } + const s = tracker.getSummary(); + const sc = tracker.getCurrentSessionCost(); + const activeProfile = findProfile(profilesCache, activeProfileFingerprint); + const profileLabel = activeProfile?.label ?? "OpenCode Go"; usageWebviewPanel.webview.html = ` @@ -776,7 +991,7 @@ function updateWebviewContent(): void { - OpenCode Usage Summary + OpenCode Usage Summary — ${escapeSvg(profileLabel)}