From 0496dc7a16d4836fec34a3f7ce63212a18398730 Mon Sep 17 00:00:00 2001 From: Chandler Fang Date: Sat, 15 Aug 2026 21:36:48 -0700 Subject: [PATCH] feat(desktop): always-on widget with machine-wide global face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay no longer lives only inside a bound Claude Desktop session. When no exact session binding exists, the widget can now show a "global" face with machine-wide totals across Claude Code, Codex, and Cline. - overlay-bridge: new global-snapshot command returning identity plus usage-history totals (today, lifetime, streak), memoized for 60s - runtime UI: data-mode=global face — TODAY label, identity header, lifetime/streak from meterStats, idle gauge; settings identity rendering shared between session and global faces - native overlay: menu-bar StatusBarController with show/always-visible toggles persisted to visibility.json, LaunchAgent bootout on quit so KeepAlive stops resurrecting the app, and Codex window following via codexBundleID Tests: 145/145 pass (node --test). Co-Authored-By: Claude Fable 5 --- .../native/TokenMeterClaudeOverlay.swift | 403 +++++++++++++++--- .../claude-desktop/src/overlay-bridge.mjs | 55 +++ runtime/token-meter-ui.js | 89 ++-- test/claude-overlay-bridge.test.mjs | 13 +- test/runtime-ui-layout.test.mjs | 76 ++++ 5 files changed, 550 insertions(+), 86 deletions(-) diff --git a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift index 84cefef..6a42a4b 100644 --- a/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift +++ b/integrations/claude-desktop/native/TokenMeterClaudeOverlay.swift @@ -6,7 +6,9 @@ import Security import WebKit private let claudeBundleID = "com.anthropic.claudefordesktop" +private let codexBundleID = "com.openai.codex" private let defaultClaudeAppPath = "/Applications/Claude.app" +private let launchAgentLabel = "com.sergiochan.token-meter.claude-desktop" private let fileManager = FileManager.default private func accessibilityTrusted() -> Bool { @@ -69,7 +71,7 @@ private func absoluteURL(_ value: String, option: String) throws -> URL { // The freshly bootstrapped agent instance takes over; callers keep running // only for this session (KeepAlive restarts us on quit or next login). private func selfInstallLaunchAgent(rootPath: String, nodePath: String, statePath: String) throws { - let label = "com.sergiochan.token-meter.claude-desktop" + let label = launchAgentLabel let executable = Bundle.main.executablePath ?? CommandLine.arguments[0] let logDir = fileManager.homeDirectoryForCurrentUser .appendingPathComponent("Library/Logs/Token Meter/Claude Desktop").path @@ -209,6 +211,140 @@ final class OverlayPanel: NSPanel { override var canBecomeMain: Bool { false } } +// Unregisters the LaunchAgent (so KeepAlive stops resurrecting us) ahead of a +// deliberate quit. Shared by the settings power button and the menu-bar item. +private func bootOutOverlayLaunchAgent() { + let bootout = Process() + bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl") + bootout.arguments = ["bootout", "gui/\(getuid())/\(launchAgentLabel)"] + try? bootout.run() + bootout.waitUntilExit() +} + +// User-facing visibility switches, shared by the status-bar menu and the +// overlay tick loop. Persisted so relaunches keep the user's choice. +private final class WidgetPreferences { + private let url: URL + var widgetVisible: Bool { didSet { save() } } + var alwaysVisible: Bool { didSet { save() } } + + init(stateDirectoryURL: URL) { + url = stateDirectoryURL.appendingPathComponent("visibility.json") + widgetVisible = true + alwaysVisible = true + guard let data = try? Data(contentsOf: url), + let value = try? JSONSerialization.jsonObject(with: data) as? [String: Bool] else { + return + } + widgetVisible = value["widgetVisible"] ?? true + alwaysVisible = value["alwaysVisible"] ?? true + } + + private func save() { + let value = ["widgetVisible": widgetVisible, "alwaysVisible": alwaysVisible] + guard let data = try? JSONSerialization.data(withJSONObject: value) else { return } + try? data.write(to: url, options: .atomic) + } +} + +// Menu-bar presence: the app is a Dock-less LaunchAgent accessory, so the +// status item is the one place the user can always find the widget — show or +// hide it, keep it on the desktop, open the dashboard, or quit. +private final class StatusBarController: NSObject, NSMenuDelegate { + private let preferences: WidgetPreferences + private let openDashboardHandler: () -> Void + private let quitHandler: () -> Void + private let statusItem: NSStatusItem + private let showItem: NSMenuItem + private let alwaysItem: NSMenuItem + private let dashboardItem: NSMenuItem + private let accessibilityItem: NSMenuItem + + init( + preferences: WidgetPreferences, + openDashboard: @escaping () -> Void, + quit: @escaping () -> Void + ) { + self.preferences = preferences + openDashboardHandler = openDashboard + quitHandler = quit + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + showItem = NSMenuItem(title: "Show Widget", action: nil, keyEquivalent: "") + alwaysItem = NSMenuItem( + title: "Always Show on Desktop", action: nil, keyEquivalent: "" + ) + dashboardItem = NSMenuItem(title: "Open Dashboard…", action: nil, keyEquivalent: "") + accessibilityItem = NSMenuItem( + title: "Grant Accessibility Access…", action: nil, keyEquivalent: "" + ) + super.init() + if let button = statusItem.button { + if let image = NSImage( + systemSymbolName: "gauge.medium", accessibilityDescription: "Token Widget" + ) ?? NSImage(systemSymbolName: "gauge", accessibilityDescription: "Token Widget") { + image.isTemplate = true + button.image = image + } else { + button.title = "TW" + } + button.toolTip = "Token Widget" + } + let menu = NSMenu() + menu.autoenablesItems = false + menu.delegate = self + let quitItem = NSMenuItem( + title: "Quit Token Widget", action: #selector(quitAction), keyEquivalent: "q" + ) + let actions: [(NSMenuItem, Selector)] = [ + (showItem, #selector(toggleShow)), + (alwaysItem, #selector(toggleAlways)), + (dashboardItem, #selector(openDashboardAction)), + (accessibilityItem, #selector(openAccessibilitySettings)), + (quitItem, #selector(quitAction)), + ] + for (item, action) in actions { + item.target = self + item.action = action + } + accessibilityItem.toolTip = + "The widget needs Accessibility access to find Claude and Codex windows." + alwaysItem.toolTip = + "Keep the widget on the desktop when Claude or Codex is not in front." + menu.addItem(showItem) + menu.addItem(alwaysItem) + menu.addItem(.separator()) + menu.addItem(dashboardItem) + menu.addItem(accessibilityItem) + menu.addItem(.separator()) + menu.addItem(quitItem) + statusItem.menu = menu + } + + func menuNeedsUpdate(_ menu: NSMenu) { + showItem.state = preferences.widgetVisible ? .on : .off + alwaysItem.state = preferences.alwaysVisible ? .on : .off + alwaysItem.isEnabled = preferences.widgetVisible + let trusted = accessibilityTrusted() + accessibilityItem.isHidden = trusted + dashboardItem.isEnabled = trusted + } + + @objc private func toggleShow() { preferences.widgetVisible.toggle() } + @objc private func toggleAlways() { preferences.alwaysVisible.toggle() } + @objc private func openDashboardAction() { openDashboardHandler() } + @objc private func openAccessibilitySettings() { + guard let url = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + ) else { return } + NSWorkspace.shared.open(url) + } + @objc private func quitAction() { quitHandler() } + + deinit { + NSStatusBar.system.removeStatusItem(statusItem) + } +} + private struct SnapshotBridgeError: Error, CustomStringConvertible { let description: String } @@ -460,6 +596,7 @@ private final class SnapshotBridge { private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMessageHandler { private let configuration: AppConfiguration private let health: RuntimeHealth + private let preferences: WidgetPreferences private let snapshotBridge: SnapshotBridge private let contextWindowResolver: ClaudeContextWindowResolver private let expandedPanelSize = CGSize(width: 320, height: 250) @@ -476,6 +613,11 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes private var visibleContextWindowTokens: Int? private var defaultPanelOrigin = CGPoint.zero private var userOffset = CGPoint.zero + private var desktopOffset = CGPoint.zero + private var desktopPositioned = false + private var showingGlobalFace = false + private var globalSnapshotInFlight = false + private var lastGlobalSnapshotAt = Date.distantPast private var dragTimer: Timer? private var dragStartMouse = CGPoint.zero private var dragStartPanel = CGPoint.zero @@ -484,9 +626,14 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes private var lastHostPosition: CGPoint? private var lastHostSize: CGSize? - init(configuration: AppConfiguration, health: RuntimeHealth) { + init( + configuration: AppConfiguration, + health: RuntimeHealth, + preferences: WidgetPreferences + ) { self.configuration = configuration self.health = health + self.preferences = preferences snapshotBridge = SnapshotBridge(configuration: configuration) contextWindowResolver = ClaudeContextWindowResolver( modelCatalogURL: configuration.modelCatalogURL @@ -585,36 +732,62 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } private func tick() { - guard let claude = NSRunningApplication.runningApplications( - withBundleIdentifier: claudeBundleID - ).first else { - panel.orderOut(nil) - currentSessionID = nil - health.update(sessionBound: false) + guard preferences.widgetVisible else { + hidePanel() return } - - let appElement = AXUIElementCreateApplication(claude.processIdentifier) - guard claude.isActive, - let window = axElement(appElement, kAXFocusedWindowAttribute), - let position = axPoint(window, kAXPositionAttribute), - let size = axSize(window, kAXSizeAttribute) else { - panel.orderOut(nil) - health.update(sessionBound: false) + // A frontmost Claude window with a Claude Code session gets the bound + // session face; a frontmost Claude (no session) or Codex window gets + // the machine-wide face pinned to that window; with neither in front, + // the machine-wide face parks on the desktop when the user wants it. + if let window = frontmostHostWindow(bundleID: claudeBundleID), + let position = axPoint(window, kAXPositionAttribute), + let size = axSize(window, kAXSizeAttribute) { + if let surface = resolveClaudeCodeSurface(in: window) { + showSessionFace(surface: surface, hostPosition: position, hostSize: size) + } else { + showGlobalFace(hostPosition: position, hostSize: size) + } return } - - guard let surface = resolveClaudeCodeSurface(in: window) else { - panel.orderOut(nil) - currentSessionID = nil - visibleContextWindowTokens = nil - health.update(bridgeHealthy: false, sessionBound: false) - publishUnbound() + if let window = frontmostHostWindow(bundleID: codexBundleID), + let position = axPoint(window, kAXPositionAttribute), + let size = axSize(window, kAXSizeAttribute) { + showGlobalFace(hostPosition: position, hostSize: size) return } + if preferences.alwaysVisible { + showGlobalFace(hostPosition: nil, hostSize: nil) + return + } + hidePanel() + } + + private func frontmostHostWindow(bundleID: String) -> AXUIElement? { + guard let app = NSRunningApplication.runningApplications( + withBundleIdentifier: bundleID + ).first(where: { $0.isActive }) else { return nil } + let appElement = AXUIElementCreateApplication(app.processIdentifier) + return axElement(appElement, kAXFocusedWindowAttribute) + } + + private func hidePanel() { + panel.orderOut(nil) + currentSessionID = nil + showingGlobalFace = false + health.update(sessionBound: false) + } + + private func showSessionFace( + surface: ClaudeCodeSurface, hostPosition: CGPoint, hostSize: CGSize + ) { let identifier = surface.sessionID - positionPanel(hostPosition: position, hostSize: size) + if showingGlobalFace { + showingGlobalFace = false + publishUnbound() + } + positionPanel(hostPosition: hostPosition, hostSize: hostSize) panel.orderFrontRegardless() if identifier != currentSessionID { @@ -638,7 +811,50 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // Shared face for Codex windows and the bare desktop: the bridge's + // machine-wide usage totals instead of a bound Claude session. + private func showGlobalFace(hostPosition: CGPoint?, hostSize: CGSize?) { + if currentSessionID != nil || !showingGlobalFace { + currentSessionID = nil + visibleContextWindowTokens = nil + contextScanCadence.reset() + lastGlobalSnapshotAt = .distantPast + health.update(sessionBound: false) + } + showingGlobalFace = true + if let hostPosition, let hostSize { + positionPanel(hostPosition: hostPosition, hostSize: hostSize) + } else { + positionPanelOnDesktop() + } + panel.orderFrontRegardless() + if Date().timeIntervalSince(lastGlobalSnapshotAt) >= 5 { + fetchGlobalSnapshot() + } + } + + private func fetchGlobalSnapshot() { + guard !globalSnapshotInFlight, pageReady else { return } + globalSnapshotInFlight = true + lastGlobalSnapshotAt = Date() + snapshotBridge.command(["command": "global-snapshot"]) { [weak self] result in + guard let self else { return } + self.globalSnapshotInFlight = false + guard self.showingGlobalFace else { return } + guard case .success(let snapshot) = result, + let data = try? JSONSerialization.data(withJSONObject: snapshot) else { + self.health.update(bridgeHealthy: false) + self.publishUnbound() + return + } + self.health.update(bridgeHealthy: true) + let json = String(decoding: data, as: UTF8.self) + self.webView.evaluateJavaScript("window.__tokenMeter?.update(\(json))") + } + } + private func positionPanel(hostPosition: CGPoint, hostSize: CGSize) { + desktopPositioned = false lastHostPosition = hostPosition lastHostSize = hostSize let hostCenter = CGPoint( @@ -675,6 +891,35 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // With no host window to follow, the widget parks in the bottom-right + // corner of the primary screen (screens.first is deterministic; .main + // follows other apps' key windows across displays). Its drag offset is + // remembered separately from the window-following offset. + private func positionPanelOnDesktop() { + desktopPositioned = true + lastHostPosition = nil + lastHostSize = nil + guard let screen = NSScreen.screens.first else { return } + let frame = screen.visibleFrame + defaultPanelOrigin = CGPoint( + x: frame.maxX - currentPanelSize.width - 24, + y: frame.minY + 24 + ) + guard !dragging else { return } + var origin = CGPoint( + x: defaultPanelOrigin.x + desktopOffset.x, + y: defaultPanelOrigin.y + desktopOffset.y + ) + // A stale drag offset can point at a display that is no longer + // attached; fall back to the default corner instead of parking the + // widget somewhere invisible. + let target = CGRect(origin: origin, size: currentPanelSize) + if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(target) }) { + origin = defaultPanelOrigin + } + panel.setFrameOrigin(origin) + } + private func fetchSnapshot(for identifier: String, contextWindowTokens: Int?) { guard !snapshotInFlight else { return } snapshotInFlight = true @@ -779,6 +1024,8 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes resizePanel() if let position = lastHostPosition, let size = lastHostSize { positionPanel(hostPosition: position, hostSize: size) + } else if desktopPositioned { + positionPanelOnDesktop() } saveCollapsedState() } @@ -829,22 +1076,7 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes private func handleAction(type: String, body: [String: Any]) { switch type { case "open-dashboard": - // The dashboard is served by the bridge's loopback server so the - // page can read the profile and claim a handle. Only a loopback - // URL returned by our own bridge is ever opened. The optional - // view ("share" / "withdraw") selects the consent wizard page. - var command: [String: Any] = ["command": "dashboard-url"] - if let view = body["view"] as? String, view == "share" || view == "withdraw" { - command["view"] = view - } - snapshotBridge.command(command) { result in - guard case .success(let payload) = result, - let urlString = payload["url"] as? String, - let url = URL(string: urlString), - url.scheme == "http", - url.host == "127.0.0.1" else { return } - DispatchQueue.main.async { NSWorkspace.shared.open(url) } - } + openDashboard(view: body["view"] as? String) case "open-leaderboard": snapshotBridge.command(["command": "leaderboard-url"]) { result in guard case .success(let payload) = result, @@ -863,14 +1095,7 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) case "quit-widget": - let bootout = Process() - bootout.executableURL = URL(fileURLWithPath: "/bin/launchctl") - bootout.arguments = ["bootout", "gui/\(getuid())/com.sergiochan.token-meter.claude-desktop"] - try? bootout.run() - bootout.waitUntilExit() - // Same reason as the updater: the bridge outlives a bare exit. - snapshotBridge.stop() - exit(0) + quitWidget() case "set-sharing": let enabled = body["enabled"] as? Bool ?? false snapshotBridge.command(["command": "set-sharing", "enabled": enabled]) { _ in } @@ -896,6 +1121,35 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes } } + // The dashboard is served by the bridge's loopback server so the page can + // read the profile and claim a handle. Only a loopback URL returned by our + // own bridge is ever opened. The optional view ("share" / "withdraw") + // selects the consent wizard page. Reached from the overlay's settings + // panel and from the status-bar menu. + func openDashboard(view: String? = nil) { + var command: [String: Any] = ["command": "dashboard-url"] + if let view, view == "share" || view == "withdraw" { + command["view"] = view + } + snapshotBridge.command(command) { result in + guard case .success(let payload) = result, + let urlString = payload["url"] as? String, + let url = URL(string: urlString), + url.scheme == "http", + url.host == "127.0.0.1" else { return } + DispatchQueue.main.async { NSWorkspace.shared.open(url) } + } + } + + // Deliberate, user-initiated quit: unregister the LaunchAgent so KeepAlive + // does not resurrect the widget, then take the bridge down — it outlives a + // bare exit otherwise. + func quitWidget() -> Never { + bootOutOverlayLaunchAgent() + snapshotBridge.stop() + exit(0) + } + // Pushes install progress to the banner. States come from string literals // in this file only, so they are safe to interpolate into the page. private func postUpdateState(_ state: String) { @@ -1104,29 +1358,44 @@ private final class MeterController: NSObject, WKNavigationDelegate, WKScriptMes dragging = false dragTimer?.invalidate() dragTimer = nil - userOffset = CGPoint( + let offset = CGPoint( x: panel.frame.origin.x - defaultPanelOrigin.x, y: panel.frame.origin.y - defaultPanelOrigin.y ) - saveOffset() + if desktopPositioned { + desktopOffset = offset + saveOffset(offset, to: desktopOffsetURL) + } else { + userOffset = offset + saveOffset(offset, to: offsetURL) + } } private var offsetURL: URL { configuration.stateDirectoryURL.appendingPathComponent("position.json") } + private var desktopOffsetURL: URL { + configuration.stateDirectoryURL.appendingPathComponent("position-desktop.json") + } + private func loadOffset() { - guard let data = try? Data(contentsOf: offsetURL), + userOffset = readOffset(from: offsetURL) + desktopOffset = readOffset(from: desktopOffsetURL) + } + + private func readOffset(from url: URL) -> CGPoint { + guard let data = try? Data(contentsOf: url), let value = try? JSONSerialization.jsonObject(with: data) as? [String: Double] else { - return + return .zero } - userOffset = CGPoint(x: value["x"] ?? 0, y: value["y"] ?? 0) + return CGPoint(x: value["x"] ?? 0, y: value["y"] ?? 0) } - private func saveOffset() { - let value = ["x": userOffset.x, "y": userOffset.y] + private func saveOffset(_ offset: CGPoint, to url: URL) { + let value = ["x": offset.x, "y": offset.y] guard let data = try? JSONSerialization.data(withJSONObject: value) else { return } - try? data.write(to: offsetURL, options: .atomic) + try? data.write(to: url, options: .atomic) } } @@ -1134,14 +1403,30 @@ private final class CompanionRuntime { private let configuration: AppConfiguration private var permissionTimer: Timer? private let health: RuntimeHealth + private let preferences: WidgetPreferences + private var statusBar: StatusBarController? private var meterController: MeterController? init(configuration: AppConfiguration) throws { self.configuration = configuration health = try RuntimeHealth(stateDirectoryURL: configuration.stateDirectoryURL) + preferences = WidgetPreferences(stateDirectoryURL: configuration.stateDirectoryURL) } func start() { + // The status item exists even while Accessibility is missing, so the + // user can always find the widget, grant access, or quit. + statusBar = StatusBarController( + preferences: preferences, + openDashboard: { [weak self] in self?.meterController?.openDashboard() }, + quit: { [weak self] in + if let controller = self?.meterController { + controller.quitWidget() + } + bootOutOverlayLaunchAgent() + exit(0) + } + ) reconcileAccessibility(logWaiting: true) permissionTimer = Timer.scheduledTimer( withTimeInterval: 2, @@ -1169,7 +1454,11 @@ private final class CompanionRuntime { return } guard meterController == nil else { return } - let controller = MeterController(configuration: configuration, health: health) + let controller = MeterController( + configuration: configuration, + health: health, + preferences: preferences + ) meterController = controller controller.start() } diff --git a/integrations/claude-desktop/src/overlay-bridge.mjs b/integrations/claude-desktop/src/overlay-bridge.mjs index 44003c5..79f705e 100644 --- a/integrations/claude-desktop/src/overlay-bridge.mjs +++ b/integrations/claude-desktop/src/overlay-bridge.mjs @@ -115,6 +115,54 @@ if (options.help) { } const cachedUsageHistory = new UsageHistory(); + +// Aggregate face for the always-on desktop widget and non-Claude hosts: no +// session binding, just machine-wide totals from the usage-history cache. +// Memoized because the native layer polls it on a steady cadence. +let globalSnapshotMemo = null; +function globalSnapshot() { + const nowMs = Date.now(); + if (globalSnapshotMemo && nowMs - globalSnapshotMemo.atMs < 60_000) { + return globalSnapshotMemo.value; + } + let identity = null; + try { + identity = loadOrCreateIdentity(); + } catch { + identity = null; + } + let meterStats = null; + let todayTokens = null; + try { + const collected = cachedUsageHistory.collectCached(); + meterStats = { + lifetimeTokens: collected.stats.lifetimeTokens, + currentStreakDays: collected.stats.currentStreakDays, + }; + const today = new Date(nowMs); + const todayKey = [ + today.getFullYear(), + String(today.getMonth() + 1).padStart(2, "0"), + String(today.getDate()).padStart(2, "0"), + ].join("-"); + todayTokens = collected.days.find((day) => day.date === todayKey)?.total ?? 0; + } catch { + meterStats = null; + todayTokens = null; + } + const value = { + status: "global", + binding: { exact: false }, + meterId: identity?.meterId ?? null, + meterHandle: identity?.handle ?? null, + sharingEnabled: identity?.sharing?.enabled ?? false, + handlePrompted: identity?.handlePromptedAtMs != null, + meterStats, + todayTokens, + }; + globalSnapshotMemo = { atMs: nowMs, value }; + return value; +} const runtime = new ClaudeSnapshotRuntime({ sessionsDirectory: options.sessionsDirectory ?? @@ -183,6 +231,13 @@ for await (const line of input) { if (identity.sharing.enabled) void syncCommunity("consent"); continue; } + if (request?.command === "global-snapshot") { + const snapshot = { ...globalSnapshot() }; + snapshot.appVersion = installedVersion; + if (updateInfo) snapshot.updateInfo = { version: updateInfo.version }; + await writeLine({ requestId, snapshot }); + continue; + } if (request?.command === "update-info") { await writeLine( updateInfo diff --git a/runtime/token-meter-ui.js b/runtime/token-meter-ui.js index 15e7720..17f795c 100644 --- a/runtime/token-meter-ui.js +++ b/runtime/token-meter-ui.js @@ -63,7 +63,7 @@
- 24H TOTAL + 24H TOTAL CURRENT STREAK
@@ -147,6 +147,7 @@ gauge: card.querySelector(".gauge"), sessionId: card.querySelector(".session-id"), sessionTotal: card.querySelector(".session-total"), + dayLabel: card.querySelector(".day-label"), dayTotal: card.querySelector(".day-total"), streak: card.querySelector(".streak"), lifetime: card.querySelector(".lifetime"), @@ -533,26 +534,79 @@ !wanted || !elements.warning.hidden || !elements.updateBanner.hidden; }; + // Settings-panel identity, version line, and privacy toggle are shared by + // the session face and the machine-wide global face. + const renderSettingsIdentity = (snapshot) => { + if (snapshot?.meterHandle) { + elements.settingsIdentityLink.hidden = false; + elements.settingsIdentityLink.textContent = `@${snapshot.meterHandle}`; + elements.settingsAnon.hidden = true; + } else { + elements.settingsIdentityLink.hidden = true; + elements.settingsAnon.hidden = false; + } + elements.settingsClaim.hidden = + Boolean(snapshot?.meterHandle) || !snapshot?.meterId; + versionLabel = snapshot?.appVersion + ? `Token Widget v${snapshot.appVersion}` + + (snapshot.updateInfo?.version ? ` · v${snapshot.updateInfo.version} available` : "") + : ""; + const tipText = elements.settingsTip.textContent; + if (!tipText || tipText.startsWith("Token Widget v")) { + elements.settingsTip.textContent = versionLabel; + } + if (Date.now() - sharingToggledAtMs > 3000) { + setPrivacyUI(Boolean(snapshot?.sharingEnabled)); + } + }; + const update = (snapshot) => { ensureMounted(); const bound = snapshot?.status === "bound" && snapshot?.binding?.exact; + const global = !bound && snapshot?.status === "global"; card.dataset.bound = String(bound); - elements.unbound.hidden = bound; + card.dataset.mode = bound ? "session" : global ? "global" : "unbound"; + elements.unbound.hidden = bound || global; + elements.dayLabel.textContent = global ? "TODAY" : "24H TOTAL"; if (!bound) { elements.warning.hidden = true; renderUpdateBanner(snapshot); renderHandlePrompt(snapshot); - elements.sessionId.textContent = "UNBOUND"; + if (global) { + // Desktop / non-Claude host face: identity plus machine-wide totals + // from the usage history; no live session, so the gauge idles. + renderSettingsIdentity(snapshot); + const identityLabel = snapshot.meterHandle + ? `@${snapshot.meterHandle}` + : snapshot.meterId; + elements.sessionId.textContent = identityLabel ?? "ALL AGENTS"; + elements.sessionId.title = + "Machine-wide usage across Claude Code, Codex, and Cline" + + (nativeActions() ? " · Click to open your dashboard" : ""); + elements.dayTotal.textContent = + snapshot.todayTokens == null ? "—" : format(snapshot.todayTokens); + const stats = snapshot.meterStats; + elements.streak.textContent = + stats?.currentStreakDays == null + ? "—" + : `${stats.currentStreakDays} day${stats.currentStreakDays === 1 ? "" : "s"}`; + elements.lifetime.textContent = + stats?.lifetimeTokens == null ? "—" : format(stats.lifetimeTokens); + elements.rate.textContent = "Idle"; + } else { + elements.sessionId.textContent = "UNBOUND"; + elements.sessionId.title = ""; + elements.dayTotal.textContent = "—"; + elements.streak.textContent = "—"; + elements.lifetime.textContent = "—"; + elements.rate.textContent = "Awaiting session"; + } elements.sessionTotal.textContent = "—"; - elements.dayTotal.textContent = "—"; - elements.streak.textContent = "—"; - elements.lifetime.textContent = "—"; elements.turnTotal.textContent = "—"; elements.contextTotal.textContent = "—"; elements.contextExtra.textContent = ""; elements.compactionCount.textContent = "—"; elements.accountHour.textContent = "—"; - elements.rate.textContent = "Awaiting session"; elements.baseline.textContent = "—"; elements.agentCount.textContent = ""; elements.usageDelta.textContent = ""; @@ -588,26 +642,7 @@ ? `Meter ${snapshot.meterId} · Session ${snapshot.sessionId}` : `Session ${snapshot.sessionId}`) + (nativeActions() ? " · Click to open your dashboard" : ""); - if (snapshot.meterHandle) { - elements.settingsIdentityLink.hidden = false; - elements.settingsIdentityLink.textContent = `@${snapshot.meterHandle}`; - elements.settingsAnon.hidden = true; - } else { - elements.settingsIdentityLink.hidden = true; - elements.settingsAnon.hidden = false; - } - elements.settingsClaim.hidden = Boolean(snapshot.meterHandle) || !snapshot.meterId; - versionLabel = snapshot.appVersion - ? `Token Widget v${snapshot.appVersion}` + - (snapshot.updateInfo?.version ? ` · v${snapshot.updateInfo.version} available` : "") - : ""; - const tipText = elements.settingsTip.textContent; - if (!tipText || tipText.startsWith("Token Widget v")) { - elements.settingsTip.textContent = versionLabel; - } - if (Date.now() - sharingToggledAtMs > 3000) { - setPrivacyUI(Boolean(snapshot.sharingEnabled)); - } + renderSettingsIdentity(snapshot); const delta = Math.max(0, snapshot.session.totalTokens - lastSessionTotal); lastSessionTotal = snapshot.session.totalTokens; if (delta > 0 && !sessionChanged) { diff --git a/test/claude-overlay-bridge.test.mjs b/test/claude-overlay-bridge.test.mjs index 37f066a..d4bd1bc 100644 --- a/test/claude-overlay-bridge.test.mjs +++ b/test/claude-overlay-bridge.test.mjs @@ -71,7 +71,8 @@ test("Claude overlay bridge serves multiple snapshots in one process", async (co child.stderr.on("data", (chunk) => (stderr += chunk)); child.stdin.end( `${JSON.stringify({ requestId: 1, desktopSessionId })}\n` + - `${JSON.stringify({ requestId: 2, desktopSessionId: "invalid" })}\n`, + `${JSON.stringify({ requestId: 2, desktopSessionId: "invalid" })}\n` + + `${JSON.stringify({ requestId: 3, command: "global-snapshot" })}\n`, ); const exitCode = await new Promise((resolve, reject) => { child.once("error", reject); @@ -80,12 +81,20 @@ test("Claude overlay bridge serves multiple snapshots in one process", async (co assert.equal(exitCode, 0, stderr); const responses = stdout.trim().split("\n").map(JSON.parse); - assert.equal(responses.length, 2); + assert.equal(responses.length, 3); assert.equal(responses[0].requestId, 1); assert.equal(responses[0].snapshot.status, "bound"); assert.equal(responses[0].snapshot.session.totalTokens, 10); assert.equal(responses[1].requestId, 2); assert.equal(responses[1].snapshot.status, "unbound"); + // The machine-wide face for the desktop widget and non-Claude hosts: no + // session binding, identity plus usage-history totals only. + assert.equal(responses[2].requestId, 3); + assert.equal(responses[2].snapshot.status, "global"); + assert.equal(responses[2].snapshot.binding.exact, false); + assert.equal(typeof responses[2].snapshot.meterId, "string"); + assert.equal(typeof responses[2].snapshot.todayTokens, "number"); + assert.equal(typeof responses[2].snapshot.meterStats.lifetimeTokens, "number"); }); test("Claude overlay bridge asks the registry for a signed browser pairing URL", async () => { diff --git a/test/runtime-ui-layout.test.mjs b/test/runtime-ui-layout.test.mjs index cfec926..355f784 100644 --- a/test/runtime-ui-layout.test.mjs +++ b/test/runtime-ui-layout.test.mjs @@ -478,3 +478,79 @@ test("opening settings closes the stats view and shows the installed version", a "Token Widget v9.9.9 · v9.9.10 available", ); }); + +test("the global face renders machine-wide totals without a session", async () => { + const source = ( + await readFile(new URL("../runtime/token-meter-ui.js", import.meta.url), "utf8") + ).replace("__TOKEN_METER_CSS_JSON__", JSON.stringify("")); + const created = []; + const documentElement = new FakeElement("html"); + documentElement.isConnected = true; + const window = { + innerWidth: 1_200, + innerHeight: 800, + addEventListener() {}, + localStorage: { getItem() { return null; }, setItem() {} }, + webkit: { + messageHandlers: { + tokenMeterAction: { postMessage() {} }, + }, + }, + }; + const context = vm.createContext({ + document: { + createElement(tagName) { + const element = new FakeElement(tagName); + created.push(element); + return element; + }, + documentElement, + }, + window, + MutationObserver: class { + observe() {} + disconnect() {} + }, + performance: { now: () => 0 }, + requestAnimationFrame() {}, + clearTimeout() {}, + setTimeout() {}, + }); + + vm.runInContext(source, context); + const card = created.find((element) => element.tagName === "section"); + const sessionId = card.querySelector(".session-id"); + const dayLabel = card.querySelector(".day-label"); + const dayTotal = card.querySelector(".day-total"); + const lifetime = card.querySelector(".lifetime"); + const streak = card.querySelector(".streak"); + const rate = card.querySelector(".rate"); + const unbound = card.querySelector(".unbound"); + + window.__tokenMeter.update({ + status: "global", + binding: { exact: false }, + meterId: "TM-TEST-0000-0000", + meterHandle: "chandler", + sharingEnabled: false, + todayTokens: 500, + meterStats: { lifetimeTokens: 2_000_000, currentStreakDays: 3 }, + appVersion: "9.9.9", + }); + + assert.equal(card.dataset.mode, "global"); + assert.equal(unbound.hidden, true); + assert.equal(sessionId.textContent, "@chandler"); + assert.equal(dayLabel.textContent, "TODAY"); + assert.equal(dayTotal.textContent, "500"); + assert.equal(lifetime.textContent, "2.000M"); + assert.equal(streak.textContent, "3 days"); + assert.equal(rate.textContent, "Idle"); + + // Losing the bridge falls back to the honest unknown-session face. + window.__tokenMeter.update({ status: "unbound", binding: { exact: false } }); + assert.equal(card.dataset.mode, "unbound"); + assert.equal(unbound.hidden, false); + assert.equal(dayLabel.textContent, "24H TOTAL"); + assert.equal(sessionId.textContent, "UNBOUND"); +});