Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ DerivedData/
.idea/
.vscode/

# Secrets and personal machine state
*.pem
*.p12
*.pfx
id_rsa
id_rsa.pub
id_ed25519
id_ed25519.pub
auth.json
.credentials.json
chrome-profile/
/.token-meter/
/.grok/

# Local agent planning state, not public user docs
PLAN.md
/plan.md
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</p>

Token Meter is a local-first observability dashboard for AI coding agents. It
turns session evidence from Claude, Codex, Cursor, OpenCode, Kiro, and Pi into one
turns session evidence from Claude, Codex, Cursor, OpenCode, Kiro, Pi, Hermes, and Grok into one
view of what happened, what it cost, and where time went—so you can decide
whether to continue, intervene, compare, or investigate a run.

Expand Down Expand Up @@ -64,7 +64,7 @@ troubleshooting, see the [User guide](specs/USER_GUIDE.md).
## Coverage

**Runtimes:** Claude Code and Desktop Agent/Cowork, Codex CLI and desktop,
Cursor Agent/Composer, OpenCode, Kiro, and Pi.
Cursor Agent/Composer, OpenCode, Kiro, Pi, Hermes, and Grok Build.

| Platform | Status | Experience |
| --- | --- | --- |
Expand Down
103 changes: 103 additions & 0 deletions menubar/TokenMeterMenuBar.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Cocoa
import Carbon.HIToolbox
import Foundation
import WebKit

private let tokenMeterMenubarURL = URL(string: "http://127.0.0.1:8722/menubar")!
private let tokenMeterDashboardURL = URL(string: "http://127.0.0.1:8722/#sessions")!
private let tokenMeterWidgetURL = URL(string: "http://127.0.0.1:8722/#widget")!
private let tokenMeterBudgetSettingsURL = URL(string: "http://127.0.0.1:8722/#settings-budgets")!
private let tokenMeterUpdateSettingsURL = URL(string: "http://127.0.0.1:8722/#settings-updates")!
private let tokenMeterInstallUpdateURL = URL(string: "http://127.0.0.1:8722/updates/install")!
Expand All @@ -22,6 +24,7 @@ private let globalShortcutDefaultsKey = "TokenMeterGlobalShortcut"
private let customShortcutKeyCodeDefaultsKey = "TokenMeterCustomShortcutKeyCode"
private let customShortcutModifiersDefaultsKey = "TokenMeterCustomShortcutModifiers"
private let tokenMeterMenubarBundleIdentifier = "com.token-meter.menubar"
private let usageWidgetOpenDefaultsKey = "TokenMeterUsageWidgetOpen"
private let statusItemAutosaveName = "TokenMeterPrimaryStatusItem"
private let statusItemPreferredPositionDefaultsKey = "NSStatusItem Preferred Position \(statusItemAutosaveName)"
private let statusItemInitialPreferredPosition = 50
Expand Down Expand Up @@ -1077,6 +1080,7 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var snapshot = MeterSnapshot.disconnected("Waiting for http://127.0.0.1:8722/menubar")
private var monthlyBudget: MonthlyBudget?
private var softwareUpdate = SoftwareUpdateSnapshot.waiting
private var usageWidgetWindow: NSPanel?

func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
Expand All @@ -1095,6 +1099,9 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
registerGlobalHotKey()
rebuildMenu()
fetchState()
if usageWidgetOpenPreference {
showUsageWidget()
}
timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
self?.fetchState()
}
Expand Down Expand Up @@ -1218,6 +1225,11 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
addConnectionRow()
}

let widgetItem = NSMenuItem(title: "Usage widget", action: #selector(toggleUsageWidget), keyEquivalent: "")
widgetItem.image = menuSymbol("rectangle.trailinghalf.inset.filled", description: "Usage widget")
widgetItem.target = self
menu.addItem(widgetItem)

let limitsItem = NSMenuItem(title: "Provider limits (Beta)", action: nil, keyEquivalent: "")
limitsItem.image = menuSymbol("gauge.with.dots.needle.50percent", description: "Provider limits (Beta)")
limitsItem.submenu = makeLimitsMenu()
Expand Down Expand Up @@ -2274,6 +2286,97 @@ final class TokenMeterMenuBar: NSObject, NSApplicationDelegate, NSMenuDelegate {
try? process.run()
}

private var usageWidgetOpenPreference: Bool {
get {
if tokenMeterDefaults.object(forKey: usageWidgetOpenDefaultsKey) == nil { return true }
return tokenMeterDefaults.bool(forKey: usageWidgetOpenDefaultsKey)
}
set { tokenMeterDefaults.set(newValue, forKey: usageWidgetOpenDefaultsKey) }
}

private func showUsageWidget() {
if ProcessInfo.processInfo.environment["TOKEN_METER_MENUBAR_SMOKE"] == "1" { return }
if usageWidgetWindow == nil {
usageWidgetWindow = makeUsageWidgetPanel()
positionUsageWidget()
}
usageWidgetWindow?.makeKeyAndOrderFront(nil)
}

@objc private func toggleUsageWidget() {
if ProcessInfo.processInfo.environment["TOKEN_METER_MENUBAR_SMOKE"] == "1" { return }
if usageWidgetWindow?.isVisible == true {
usageWidgetWindow?.orderOut(nil)
usageWidgetOpenPreference = false
return
}
usageWidgetOpenPreference = true
showUsageWidget()
NSApp.activate(ignoringOtherApps: true)
}

private var usageWidgetSnapWork: DispatchWorkItem?

private func makeUsageWidgetPanel() -> NSPanel {
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 520),
styleMask: [.titled, .closable, .utilityWindow, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.title = "Token Meter"
panel.isFloatingPanel = true
panel.level = .floating
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
let webView = WKWebView(frame: panel.contentView?.bounds ?? .zero)
webView.autoresizingMask = [.width, .height]
webView.load(URLRequest(url: tokenMeterWidgetURL))
panel.contentView = webView
NotificationCenter.default.addObserver(
forName: NSWindow.didMoveNotification,
object: panel,
queue: .main
) { [weak self] _ in
self?.scheduleUsageWidgetSnap()
}
return panel
}

private func screenContaining(point: NSPoint) -> NSScreen? {
NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } ?? NSScreen.main
}

private func snapUsageWidget() {
guard let panel = usageWidgetWindow, let screen = screenContaining(point: NSPoint(x: panel.frame.midX, y: panel.frame.midY)) else { return }
var frame = panel.frame
let work = screen.visibleFrame
if frame.midX >= work.midX {
frame.origin.x = work.maxX - frame.width - 12
} else {
frame.origin.x = work.minX + 12
}
frame.origin.y = min(max(frame.origin.y, work.minY), work.maxY - frame.height)
panel.setFrame(frame, display: true)
}

private func scheduleUsageWidgetSnap() {
usageWidgetSnapWork?.cancel()
let work = DispatchWorkItem { [weak self] in self?.snapUsageWidget() }
usageWidgetSnapWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: work)
}

private func positionUsageWidget() {
guard let panel = usageWidgetWindow, let screen = NSScreen.main else { return }
var frame = panel.frame
frame.origin.x = screen.visibleFrame.maxX - frame.width - 12
frame.origin.y = screen.visibleFrame.maxY - frame.height - 72
panel.setFrame(frame, display: true)
}

@objc private func openDashboard() {
if pinnedSessionID?.isEmpty == false {
openDashboardPanel("summary")
Expand Down
Loading