From a42dc98f10a1c359d1d970e8487c2ccf7867c59d Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 28 Mar 2026 17:58:07 +0530 Subject: [PATCH 1/5] feat: add codex and claude quota menubar --- bun.lockb | Bin 132496 -> 132912 bytes package.json | 2 + packages/cli/src/cli.test.ts | 10 + packages/cli/src/cli.ts | 21 + packages/cli/src/menubar/claude-statusline.ts | 102 ++++ packages/cli/src/menubar/command.ts | 186 ++++++++ packages/cli/src/menubar/format.ts | 29 ++ packages/cli/src/menubar/install.ts | 356 ++++++++++++++ packages/cli/src/menubar/launchd.test.ts | 25 + packages/cli/src/menubar/launchd.ts | 84 ++++ packages/cli/src/menubar/paths.ts | 30 ++ packages/cli/src/menubar/state.test.ts | 91 ++++ packages/cli/src/menubar/state.ts | 329 +++++++++++++ packages/cli/src/menubar/types.ts | 84 ++++ packages/menubar/App/TokenleakUsage.swift | 434 ++++++++++++++++++ packages/menubar/package.json | 10 + packages/registry/src/index.ts | 10 +- .../src/providers/codex-rate-limits.test.ts | 93 ++++ .../src/providers/codex-rate-limits.ts | 154 +++++++ packages/registry/src/providers/index.ts | 2 + scripts/build-menubar-app.ts | 89 ++++ scripts/check-menubar-app.ts | 27 ++ scripts/package-menubar-app.ts | 47 ++ 23 files changed, 2214 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/menubar/claude-statusline.ts create mode 100644 packages/cli/src/menubar/command.ts create mode 100644 packages/cli/src/menubar/format.ts create mode 100644 packages/cli/src/menubar/install.ts create mode 100644 packages/cli/src/menubar/launchd.test.ts create mode 100644 packages/cli/src/menubar/launchd.ts create mode 100644 packages/cli/src/menubar/paths.ts create mode 100644 packages/cli/src/menubar/state.test.ts create mode 100644 packages/cli/src/menubar/state.ts create mode 100644 packages/cli/src/menubar/types.ts create mode 100644 packages/menubar/App/TokenleakUsage.swift create mode 100644 packages/menubar/package.json create mode 100644 packages/registry/src/providers/codex-rate-limits.test.ts create mode 100644 packages/registry/src/providers/codex-rate-limits.ts create mode 100644 scripts/build-menubar-app.ts create mode 100644 scripts/check-menubar-app.ts create mode 100644 scripts/package-menubar-app.ts diff --git a/bun.lockb b/bun.lockb index b57c205b9ebf37d17667f376ba98ce4fb7491178..785bab340c3fa00fbb2e3faef1aaeeb2928583e0 100755 GIT binary patch delta 19090 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 delta 18685 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 { }); test('throws a login hint when cursor is requested without auth or cache', async () => { + const emptyRoot = mkdtempSync(join(tmpdir(), 'tokenleak-cli-empty-cursor-')); + const previousEnv = process.env; + process.env = { + ...process.env, + TOKENLEAK_CURSOR_DIR: emptyRoot, + }; + let thrown: unknown; try { await run({ format: 'json', provider: 'cursor' }); } catch (error: unknown) { thrown = error; + } finally { + process.env = previousEnv; + rmSync(emptyRoot, { recursive: true, force: true }); } expect(thrown).toBeInstanceOf(TokenleakError); expect((thrown as TokenleakError).message).toBe( diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4629e94..4f92c65 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -48,6 +48,7 @@ import { shouldStartInteractiveCli, startInteractiveCli } from './interactive.js import { copyToClipboard, openFile, uploadToGist } from './sharing/index.js'; import { startTabbedDashboard } from './tabbed-dashboard.js'; import type { TabbedDashboardOptions } from './tabbed-dashboard.js'; +import { buildMenubarHelpText, runMenubarCommand } from './menubar/command.js'; export { computeDateRange }; export { renderFocusReport, colorScore, colorDuration, colorDensity, colorProvider, colorStreak }; @@ -159,12 +160,14 @@ function buildHelpText(): string { ' tokenleak focus [flags]', ' tokenleak replay [date] [flags]', ' tokenleak cursor ', + ' tokenleak menubar ', '', 'Subcommands:', ' explain Explain what drove usage on one day', ' focus Rank sessions by deep-work score', ' replay [date] Replay a day\'s session timeline (defaults to today)', ' cursor Manage Cursor auth and cache sync', + ' menubar Install and manage the macOS quota menubar app', '', 'Provider Shortcuts:', ' --claude Only include Claude Code', @@ -2140,6 +2143,24 @@ if (isDirectExecution) { handleError(error); } } + if (argv[0] === 'menubar') { + try { + if (argv[1] === '--help' || argv[1] === '-h' || argv.length === 1) { + process.stdout.write(buildMenubarHelpText()); + process.exit(0); + } + + if (argv[1] === '--version' || argv[1] === '-v') { + process.stdout.write(buildVersionText()); + process.exit(0); + } + + await runMenubarCommand(argv.slice(1), process.argv[1]!); + process.exit(0); + } catch (error: unknown) { + handleError(error); + } + } process.argv = [...process.argv.slice(0, 2), ...normalizedArgv]; if (argv.includes('--help') || argv.includes('-h')) { diff --git a/packages/cli/src/menubar/claude-statusline.ts b/packages/cli/src/menubar/claude-statusline.ts new file mode 100644 index 0000000..8fc6e9c --- /dev/null +++ b/packages/cli/src/menubar/claude-statusline.ts @@ -0,0 +1,102 @@ +import type { MenubarPaths } from './types.js'; +import { MENUBAR_SCHEMA_VERSION, type ClaudeBridgeSnapshot, type StoredQuotaWindow } from './types.js'; +import { writeClaudeBridgeSnapshot } from './state.js'; + +function parseWindow(value: unknown, fallbackMinutes: number): StoredQuotaWindow | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const usedPercent = record['used_percentage'] ?? record['usedPercent']; + const resetAt = record['resets_at'] ?? record['resetAt']; + const windowMinutes = record['window_minutes'] ?? record['windowMinutes'] ?? fallbackMinutes; + + if (typeof usedPercent !== 'number') { + return null; + } + + return { + usedPercent, + windowMinutes: typeof windowMinutes === 'number' ? windowMinutes : fallbackMinutes, + resetAt: typeof resetAt === 'string' ? resetAt : null, + }; +} + +function resolvePlanType(record: Record): string | null { + const candidates = [ + record['subscription_type'], + record['subscriptionType'], + record['plan_type'], + typeof record['account'] === 'object' && record['account'] !== null + ? (record['account'] as Record)['subscription_type'] + : null, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + + return null; +} + +async function readStdinText(): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk)); + } else { + chunks.push(Buffer.from(chunk)); + } + } + + return Buffer.concat(chunks).toString('utf8'); +} + +export async function recordClaudeStatuslineSnapshot(paths: MenubarPaths): Promise { + const input = (await readStdinText()).trim(); + if (!input) { + return false; + } + + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch { + return false; + } + + if (typeof parsed !== 'object' || parsed === null) { + return false; + } + + const root = parsed as Record; + const rateLimits = root['rate_limits'] ?? root['rateLimits']; + if (typeof rateLimits !== 'object' || rateLimits === null) { + return false; + } + + const rateLimitsRecord = rateLimits as Record; + const fiveHour = + parseWindow(rateLimitsRecord['five_hour'] ?? rateLimitsRecord['fiveHour'], 300); + const sevenDay = + parseWindow(rateLimitsRecord['seven_day'] ?? rateLimitsRecord['sevenDay'], 10080); + + if (!fiveHour && !sevenDay) { + return false; + } + + const snapshot: ClaudeBridgeSnapshot = { + schemaVersion: MENUBAR_SCHEMA_VERSION, + source: 'claude-statusline', + capturedAt: new Date().toISOString(), + planType: resolvePlanType(root), + fiveHour, + sevenDay, + }; + writeClaudeBridgeSnapshot(paths, snapshot); + return true; +} diff --git a/packages/cli/src/menubar/command.ts b/packages/cli/src/menubar/command.ts new file mode 100644 index 0000000..38fa2c7 --- /dev/null +++ b/packages/cli/src/menubar/command.ts @@ -0,0 +1,186 @@ +import { existsSync } from 'node:fs'; +import { TokenleakError } from '../errors.js'; +import { recordClaudeStatuslineSnapshot } from './claude-statusline.js'; +import { formatTimestamp } from './format.js'; +import { + installMenubar, + openDashboardInTerminal, + openMenubarApp, + printMenubarStatus, + startMenubarApp, + stopMenubarApp, + uninstallMenubar, +} from './install.js'; +import { resolveMenubarPaths } from './paths.js'; +import { + createDefaultMenubarConfig, + readMenubarConfig, + refreshMenubarSnapshot, + writeMenubarConfig, +} from './state.js'; + +interface ParsedMenubarArgs { + command: string; + homeDir?: string; + pollIntervalSeconds?: number; + once: boolean; + help: boolean; +} + +function parseArgs(argv: string[]): ParsedMenubarArgs { + const parsed: ParsedMenubarArgs = { + command: argv[0] ?? 'help', + once: false, + help: false, + }; + + let index = 1; + while (index < argv.length) { + const arg = argv[index]!; + switch (arg) { + case '--help': + case '-h': + parsed.help = true; + index += 1; + break; + case '--home': + if (!argv[index + 1]) throw new TokenleakError('--home requires a value'); + parsed.homeDir = argv[index + 1]!; + index += 2; + break; + case '--poll': + if (!argv[index + 1]) throw new TokenleakError('--poll requires a value'); + parsed.pollIntervalSeconds = Number(argv[index + 1]!); + index += 2; + break; + case '--once': + parsed.once = true; + index += 1; + break; + default: + throw new TokenleakError(`Unknown menubar flag "${arg}"`); + } + } + + return parsed; +} + +function resolveCommandConfig(parsed: ParsedMenubarArgs) { + const paths = resolveMenubarPaths(parsed.homeDir); + const config = existsSync(paths.configPath) + ? readMenubarConfig(paths) + : createDefaultMenubarConfig(); + + if (parsed.pollIntervalSeconds !== undefined) { + config.pollIntervalSeconds = Math.max(10, Math.round(parsed.pollIntervalSeconds)); + writeMenubarConfig(paths, config); + } + + return { paths, config }; +} + +export function buildMenubarHelpText(): string { + return [ + 'tokenleak menubar', + 'Install and manage the macOS quota menubar app for Codex and Claude Code.', + '', + 'Usage:', + ' tokenleak menubar install', + ' tokenleak menubar uninstall', + ' tokenleak menubar status', + ' tokenleak menubar refresh', + ' tokenleak menubar open', + ' tokenleak menubar start', + ' tokenleak menubar stop', + '', + ].join('\n'); +} + +async function runRefresh(parsed: ParsedMenubarArgs): Promise { + const { paths } = resolveCommandConfig(parsed); + const snapshot = await refreshMenubarSnapshot(paths); + process.stdout.write(`Snapshot updated: ${snapshot.title}\n`); +} + +async function runDaemon(parsed: ParsedMenubarArgs): Promise { + const { paths, config } = resolveCommandConfig(parsed); + + const tick = async () => { + const snapshot = await refreshMenubarSnapshot(paths); + process.stdout.write(`[menubar] ${formatTimestamp(snapshot.generatedAt)} ${snapshot.title}\n`); + }; + + await tick(); + if (parsed.once) { + return; + } + + const interval = setInterval(() => { + void tick(); + }, config.pollIntervalSeconds * 1000); + + await new Promise((resolve) => { + const stop = () => { + clearInterval(interval); + resolve(); + }; + + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + }); +} + +async function runClaudeStatusline(parsed: ParsedMenubarArgs): Promise { + const paths = resolveMenubarPaths(parsed.homeDir); + await recordClaudeStatuslineSnapshot(paths); +} + +export async function runMenubarCommand(argv: string[], cliEntrypoint: string): Promise { + const parsed = parseArgs(argv); + + if (parsed.help || parsed.command === 'help') { + process.stdout.write(buildMenubarHelpText()); + return; + } + + switch (parsed.command) { + case 'install': { + const paths = await installMenubar(parsed.homeDir, cliEntrypoint); + process.stdout.write(`Installed Tokenleak Usage at ${paths.installedAppPath}\n`); + return; + } + case 'uninstall': { + const paths = uninstallMenubar(parsed.homeDir); + process.stdout.write(`Removed menubar install from ${paths.appSupportDir}\n`); + return; + } + case 'status': + printMenubarStatus(resolveMenubarPaths(parsed.homeDir)); + return; + case 'refresh': + await runRefresh(parsed); + return; + case 'open': + openMenubarApp(resolveMenubarPaths(parsed.homeDir)); + return; + case 'start': + startMenubarApp(resolveMenubarPaths(parsed.homeDir)); + process.stdout.write('Started menubar app.\n'); + return; + case 'stop': + stopMenubarApp(resolveMenubarPaths(parsed.homeDir)); + process.stdout.write('Stopped menubar app.\n'); + return; + case 'daemon': + await runDaemon(parsed); + return; + case 'claude-statusline': + await runClaudeStatusline(parsed); + return; + case 'open-dashboard': + openDashboardInTerminal(resolveMenubarPaths(parsed.homeDir)); + return; + default: + throw new TokenleakError(`Unknown menubar command "${parsed.command}"`); + } +} diff --git a/packages/cli/src/menubar/format.ts b/packages/cli/src/menubar/format.ts new file mode 100644 index 0000000..764e4de --- /dev/null +++ b/packages/cli/src/menubar/format.ts @@ -0,0 +1,29 @@ +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +export function formatTimestamp(value: string | null): string { + if (!value) { + return 'never'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + return date.toLocaleString(); +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)); +} + +export function toRemainingPercent(value: number | null): number | null { + return typeof value === 'number' ? clampPercent(100 - value) : null; +} + +export function formatPercentLeft(value: number | null): string { + const remaining = toRemainingPercent(value); + return typeof remaining === 'number' ? `${Math.round(remaining)}%` : '--'; +} diff --git a/packages/cli/src/menubar/install.ts b/packages/cli/src/menubar/install.ts new file mode 100644 index 0000000..2d5f23d --- /dev/null +++ b/packages/cli/src/menubar/install.ts @@ -0,0 +1,356 @@ +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { TokenleakError } from '../errors.js'; +import { formatPercentLeft, formatTimestamp } from './format.js'; +import { + buildAppPlist, + buildCliWrapper, + buildClaudeStatuslineWrapper, + buildDashboardWrapper, + buildOriginalClaudeStatuslineCommandScript, +} from './launchd.js'; +import { MENUBAR_APP_LABEL, resolveMenubarPaths } from './paths.js'; +import { + clearMenubarState, + createDefaultMenubarConfig, + ensureMenubarDir, + readMenubarConfig, + readSnapshot, + writeExecutableScript, + writeMenubarConfig, +} from './state.js'; +import type { MenubarConfig, MenubarPaths } from './types.js'; + +interface CommandStatusLine { + type: 'command'; + command: string; +} + +const LEGACY_APP_LABEL = 'com.tokenleak.menubar.app'; +const LEGACY_SERVICE_LABEL = 'com.tokenleak.menubar.service'; +const LEGACY_APP_NAME = 'Tokenleak Menu.app'; +const LEGACY_SERVICE_WRAPPER = 'tokenleak-menubar-service'; + +function runCommand(command: string[], cwd?: string, quiet: boolean = false): void { + const proc = Bun.spawnSync(command, { + cwd, + stdout: quiet ? 'ignore' : 'inherit', + stderr: quiet ? 'ignore' : 'inherit', + }); + + if (proc.exitCode !== 0) { + throw new TokenleakError(`Command failed: ${command.join(' ')}`); + } +} + +function resolveLocalMenubarBuilderPath(): string { + return resolve(import.meta.dir, '../../../../scripts/build-menubar-app.ts'); +} + +function resolveLocalBuiltAppPath(): string { + return resolve(import.meta.dir, '../../../../packages/menubar/dist', 'Tokenleak Usage.app'); +} + +function ensureInstallDirs(paths: MenubarPaths): void { + ensureMenubarDir(paths.appSupportDir); + ensureMenubarDir(paths.logsDir); + ensureMenubarDir(paths.launchAgentsDir); + ensureMenubarDir(dirname(paths.installedAppPath)); + ensureMenubarDir(dirname(paths.claudeSettingsPath)); +} + +function buildLocalApp(): string { + const buildScript = resolveLocalMenubarBuilderPath(); + if (!existsSync(buildScript)) { + throw new TokenleakError('Local menubar app builder not found.'); + } + + runCommand([process.execPath, buildScript], resolve(import.meta.dir, '../../../../')); + const appPath = resolveLocalBuiltAppPath(); + if (!existsSync(appPath)) { + throw new TokenleakError('Menubar app build did not produce an app bundle.'); + } + return appPath; +} + +function copyAppBundle(sourceAppPath: string, targetAppPath: string): void { + rmSync(targetAppPath, { recursive: true, force: true }); + cpSync(sourceAppPath, targetAppPath, { recursive: true }); +} + +function readClaudeSettings(paths: MenubarPaths): Record { + if (!existsSync(paths.claudeSettingsPath)) { + return {}; + } + + return JSON.parse(readFileSync(paths.claudeSettingsPath, 'utf8')) as Record; +} + +function writeClaudeSettings(paths: MenubarPaths, settings: Record): void { + ensureMenubarDir(dirname(paths.claudeSettingsPath)); + writeFileSync(paths.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function managedStatusLineSetting(paths: MenubarPaths): CommandStatusLine { + return { + type: 'command', + command: paths.claudeStatuslineWrapperPath, + }; +} + +function parseCommandStatusLine(setting: unknown): CommandStatusLine | null { + if (typeof setting !== 'object' || setting === null) { + return null; + } + + const record = setting as Record; + if (record['type'] !== 'command' || typeof record['command'] !== 'string') { + return null; + } + + return { + type: 'command', + command: record['command'], + }; +} + +export function isManagedClaudeStatusLineSetting(paths: MenubarPaths, value: unknown): boolean { + const parsed = parseCommandStatusLine(value); + return parsed?.command === paths.claudeStatuslineWrapperPath; +} + +function writeInstallArtifacts( + paths: MenubarPaths, + cliEntrypoint: string, + config: MenubarConfig, +): void { + writeExecutableScript(paths.cliWrapperPath, buildCliWrapper(process.execPath, cliEntrypoint)); + writeExecutableScript(paths.dashboardWrapperPath, buildDashboardWrapper(paths)); + writeExecutableScript(paths.claudeStatuslineWrapperPath, buildClaudeStatuslineWrapper(paths)); + + const previous = parseCommandStatusLine(config.claudeStatusLineBackup); + if (previous) { + writeExecutableScript( + paths.previousClaudeStatuslineCommandPath, + buildOriginalClaudeStatuslineCommandScript(previous.command), + ); + } else { + rmSync(paths.previousClaudeStatuslineCommandPath, { force: true }); + } + + writeFileSync(paths.appPlistPath, buildAppPlist(paths)); + writeMenubarConfig(paths, config); +} + +function configureClaudeStatusLine(paths: MenubarPaths, config: MenubarConfig): MenubarConfig { + const settings = readClaudeSettings(paths); + const current = settings['statusLine']; + + if (!isManagedClaudeStatusLineSetting(paths, current)) { + config.claudeStatusLineBackup = current ?? null; + } + + settings['statusLine'] = managedStatusLineSetting(paths); + config.claudeStatusLineManaged = true; + writeClaudeSettings(paths, settings); + return config; +} + +function restoreClaudeStatusLine(paths: MenubarPaths, config: MenubarConfig): void { + const settings = readClaudeSettings(paths); + if (!isManagedClaudeStatusLineSetting(paths, settings['statusLine'])) { + return; + } + + if (config.claudeStatusLineBackup === null) { + delete settings['statusLine']; + } else { + settings['statusLine'] = config.claudeStatusLineBackup; + } + + writeClaudeSettings(paths, settings); +} + +function guiDomain(): string { + const uid = + typeof process.getuid === 'function' ? process.getuid() : Number(process.env['UID'] ?? 0); + return `gui/${uid}`; +} + +function launchctlLabelPath(label: string): string { + return `${guiDomain()}/${label}`; +} + +function bootoutIfLoaded(label: string, plistPath: string): void { + const proc = Bun.spawnSync(['/bin/launchctl', 'bootout', launchctlLabelPath(label), plistPath], { + stdout: 'ignore', + stderr: 'ignore', + }); + + if (proc.exitCode !== 0) { + Bun.spawnSync(['/bin/launchctl', 'bootout', guiDomain(), plistPath], { + stdout: 'ignore', + stderr: 'ignore', + }); + } +} + +function killMatchingProcesses(pattern: string): void { + Bun.spawnSync(['/usr/bin/pkill', '-f', pattern], { + stdout: 'ignore', + stderr: 'ignore', + }); +} + +function cleanupLegacyMenubarInstall(paths: MenubarPaths): void { + const legacyAppPlistPath = join(paths.launchAgentsDir, `${LEGACY_APP_LABEL}.plist`); + const legacyServicePlistPath = join(paths.launchAgentsDir, `${LEGACY_SERVICE_LABEL}.plist`); + const legacyAppPath = join(dirname(paths.installedAppPath), LEGACY_APP_NAME); + const legacyServiceWrapperPath = join(paths.appSupportDir, LEGACY_SERVICE_WRAPPER); + + if (existsSync(legacyAppPlistPath)) { + bootoutIfLoaded(LEGACY_APP_LABEL, legacyAppPlistPath); + unlinkSync(legacyAppPlistPath); + } + + if (existsSync(legacyServicePlistPath)) { + bootoutIfLoaded(LEGACY_SERVICE_LABEL, legacyServicePlistPath); + unlinkSync(legacyServicePlistPath); + } + + killMatchingProcesses('/Tokenleak Menu.app/Contents/MacOS/Tokenleak Menu'); + killMatchingProcesses('tokenleak-menubar-service'); + + rmSync(legacyAppPath, { recursive: true, force: true }); + rmSync(legacyServiceWrapperPath, { force: true }); +} + +export function startMenubarApp(paths: MenubarPaths): void { + if (!existsSync(paths.appPlistPath)) { + throw new TokenleakError('Menubar is not installed. Run `tokenleak menubar install` first.'); + } + + bootoutIfLoaded(MENUBAR_APP_LABEL, paths.appPlistPath); + runCommand(['/bin/launchctl', 'bootstrap', guiDomain(), paths.appPlistPath], undefined, true); + runCommand( + ['/bin/launchctl', 'kickstart', '-k', launchctlLabelPath(MENUBAR_APP_LABEL)], + undefined, + true, + ); +} + +export function stopMenubarApp(paths: MenubarPaths): void { + if (existsSync(paths.appPlistPath)) { + bootoutIfLoaded(MENUBAR_APP_LABEL, paths.appPlistPath); + } +} + +export function openMenubarApp(paths: MenubarPaths): void { + if (!existsSync(paths.installedAppPath)) { + throw new TokenleakError('Menubar is not installed. Run `tokenleak menubar install` first.'); + } + + runCommand(['/usr/bin/open', paths.installedAppPath], undefined, true); +} + +export function openDashboardInTerminal(paths: MenubarPaths): void { + if (!existsSync(paths.dashboardWrapperPath)) { + throw new TokenleakError('Dashboard wrapper missing. Reinstall the menubar.'); + } + + runCommand(['/usr/bin/open', '-a', 'Terminal', paths.dashboardWrapperPath], undefined, true); +} + +function launchctlState(label: string): 'loaded' | 'stopped' { + const proc = Bun.spawnSync(['/bin/launchctl', 'print', launchctlLabelPath(label)], { + stdout: 'ignore', + stderr: 'ignore', + }); + return proc.exitCode === 0 ? 'loaded' : 'stopped'; +} + +function printStateLine(label: string, value: string): void { + process.stdout.write(`${label}: ${value}\n`); +} + +export function printMenubarStatus(paths: MenubarPaths): void { + const config = existsSync(paths.configPath) ? readMenubarConfig(paths) : createDefaultMenubarConfig(); + const snapshot = readSnapshot(paths); + const claudeSettings = readClaudeSettings(paths); + + printStateLine('installed_app', existsSync(paths.installedAppPath) ? 'yes' : 'no'); + printStateLine( + 'app_agent', + existsSync(paths.appPlistPath) ? launchctlState(MENUBAR_APP_LABEL) : 'missing', + ); + printStateLine( + 'claude_statusline', + isManagedClaudeStatusLineSetting(paths, claudeSettings['statusLine']) ? 'managed' : 'other', + ); + printStateLine('poll_interval_seconds', String(config.pollIntervalSeconds)); + + if (!snapshot) { + printStateLine('snapshot', 'missing'); + return; + } + + printStateLine('snapshot', 'present'); + printStateLine('title', snapshot.title); + printStateLine('generated_at', formatTimestamp(snapshot.generatedAt)); + printStateLine('codex_state', snapshot.providers.codex.state); + printStateLine( + 'codex_5h_left', + formatPercentLeft(snapshot.providers.codex.windows.fiveHour.usedPercent), + ); + if (snapshot.providers.codex.message) { + printStateLine('codex_message', snapshot.providers.codex.message); + } + printStateLine('claude_state', snapshot.providers.claudeCode.state); + printStateLine( + 'claude_5h_left', + formatPercentLeft(snapshot.providers.claudeCode.windows.fiveHour.usedPercent), + ); + if (snapshot.providers.claudeCode.message) { + printStateLine('claude_message', snapshot.providers.claudeCode.message); + } +} + +export async function installMenubar( + homeDir: string | undefined, + cliEntrypoint: string, +): Promise { + const paths = resolveMenubarPaths(homeDir); + ensureInstallDirs(paths); + cleanupLegacyMenubarInstall(paths); + + let config = existsSync(paths.configPath) ? readMenubarConfig(paths) : createDefaultMenubarConfig(); + config = configureClaudeStatusLine(paths, config); + + const appSource = buildLocalApp(); + copyAppBundle(appSource, paths.installedAppPath); + writeInstallArtifacts(paths, cliEntrypoint, config); + startMenubarApp(paths); + return paths; +} + +export function uninstallMenubar(homeDir: string | undefined): MenubarPaths { + const paths = resolveMenubarPaths(homeDir); + const config = existsSync(paths.configPath) ? readMenubarConfig(paths) : createDefaultMenubarConfig(); + + stopMenubarApp(paths); + cleanupLegacyMenubarInstall(paths); + restoreClaudeStatusLine(paths, config); + if (existsSync(paths.appPlistPath)) unlinkSync(paths.appPlistPath); + rmSync(paths.installedAppPath, { recursive: true, force: true }); + clearMenubarState(paths); + rmSync(paths.appSupportDir, { recursive: true, force: true }); + return paths; +} diff --git a/packages/cli/src/menubar/launchd.test.ts b/packages/cli/src/menubar/launchd.test.ts new file mode 100644 index 0000000..c14b27b --- /dev/null +++ b/packages/cli/src/menubar/launchd.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'bun:test'; +import { buildAppPlist, buildClaudeStatuslineWrapper, buildDashboardWrapper } from './launchd'; +import { resolveMenubarPaths } from './paths'; + +describe('menubar launchd and wrapper generation', () => { + const paths = resolveMenubarPaths('/tmp/tokenleak-home'); + + it('builds an app plist for the menubar app', () => { + const plist = buildAppPlist(paths); + expect(plist).toContain('com.tokenleak.menubar'); + expect(plist).toContain('Tokenleak Usage'); + expect(plist).toContain('TOKENLEAK_MENUBAR_HOME'); + }); + + it('builds a dashboard wrapper pinned to codex and claude-code', () => { + const wrapper = buildDashboardWrapper(paths); + expect(wrapper).toContain('--provider codex,claude-code'); + }); + + it('builds a Claude statusline wrapper that records bridge snapshots', () => { + const wrapper = buildClaudeStatuslineWrapper(paths); + expect(wrapper).toContain('menubar claude-statusline'); + expect(wrapper).toContain('claude-statusline-original'); + }); +}); diff --git a/packages/cli/src/menubar/launchd.ts b/packages/cli/src/menubar/launchd.ts new file mode 100644 index 0000000..23cc7ef --- /dev/null +++ b/packages/cli/src/menubar/launchd.ts @@ -0,0 +1,84 @@ +import type { MenubarPaths } from './types.js'; +import { MENUBAR_APP_LABEL } from './paths.js'; +import { shellQuote } from './format.js'; + +function xmlEscape(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderStringArray(values: string[]): string { + return values.map((value) => ` ${xmlEscape(value)}`).join('\n'); +} + +export function buildAppPlist(paths: MenubarPaths): string { + const executablePath = `${paths.installedAppPath}/Contents/MacOS/Tokenleak Usage`; + + return ` + + + + Label + ${MENUBAR_APP_LABEL} + ProgramArguments + +${renderStringArray([executablePath])} + + RunAtLoad + + KeepAlive + + EnvironmentVariables + + TOKENLEAK_MENUBAR_HOME + ${xmlEscape(paths.homeDir)} + + WorkingDirectory + ${xmlEscape(paths.appSupportDir)} + StandardOutPath + ${xmlEscape(paths.appLogPath)} + StandardErrorPath + ${xmlEscape(paths.appLogPath)} + + +`; +} + +export function buildCliWrapper(processExecPath: string, cliEntrypoint: string): string { + return `#!/bin/zsh +exec ${shellQuote(processExecPath)} ${shellQuote(cliEntrypoint)} "$@" +`; +} + +export function buildDashboardWrapper(paths: MenubarPaths): string { + return `#!/bin/zsh +exec ${shellQuote(paths.cliWrapperPath)} --provider codex,claude-code "$@" +`; +} + +export function buildClaudeStatuslineWrapper(paths: MenubarPaths): string { + return `#!/bin/zsh +set -u +tmp_file=$(mktemp "\${TMPDIR:-/tmp}/tokenleak-claude-statusline.XXXXXX") +cleanup() { + rm -f "$tmp_file" +} +trap cleanup EXIT +cat > "$tmp_file" +${shellQuote(paths.cliWrapperPath)} menubar claude-statusline --home ${shellQuote(paths.homeDir)} < "$tmp_file" >/dev/null 2>/dev/null || true +if [ -x ${shellQuote(paths.previousClaudeStatuslineCommandPath)} ]; then + exec ${shellQuote(paths.previousClaudeStatuslineCommandPath)} < "$tmp_file" +fi +exit 0 +`; +} + +export function buildOriginalClaudeStatuslineCommandScript(command: string): string { + return `#!/bin/zsh +${command} +`; +} diff --git a/packages/cli/src/menubar/paths.ts b/packages/cli/src/menubar/paths.ts new file mode 100644 index 0000000..f4eec31 --- /dev/null +++ b/packages/cli/src/menubar/paths.ts @@ -0,0 +1,30 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import type { MenubarPaths } from './types.js'; + +export const MENUBAR_APP_LABEL = 'com.tokenleak.menubar'; + +export function resolveMenubarPaths(homeDir: string = homedir()): MenubarPaths { + const appSupportDir = join(homeDir, 'Library', 'Application Support', 'tokenleak', 'menubar'); + const logsDir = join(appSupportDir, 'logs'); + const launchAgentsDir = join(homeDir, 'Library', 'LaunchAgents'); + + return { + homeDir, + appSupportDir, + logsDir, + launchAgentsDir, + configPath: join(appSupportDir, 'config.json'), + snapshotPath: join(appSupportDir, 'snapshot.json'), + claudeSnapshotPath: join(appSupportDir, 'claude-rate-limits.json'), + cliWrapperPath: join(appSupportDir, 'tokenleak-menubar-cli'), + dashboardWrapperPath: join(appSupportDir, 'tokenleak-menubar-dashboard'), + claudeStatuslineWrapperPath: join(appSupportDir, 'tokenleak-menubar-claude-statusline'), + previousClaudeStatuslineCommandPath: join(appSupportDir, 'claude-statusline-original'), + installedAppPath: join(homeDir, 'Applications', 'Tokenleak Usage.app'), + appPlistPath: join(launchAgentsDir, `${MENUBAR_APP_LABEL}.plist`), + appLogPath: join(logsDir, 'app.log'), + daemonLogPath: join(logsDir, 'daemon.log'), + claudeSettingsPath: join(homeDir, '.claude', 'settings.json'), + }; +} diff --git a/packages/cli/src/menubar/state.test.ts b/packages/cli/src/menubar/state.test.ts new file mode 100644 index 0000000..d263ecb --- /dev/null +++ b/packages/cli/src/menubar/state.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveMenubarPaths } from './paths'; +import { + createDefaultMenubarConfig, + refreshMenubarSnapshot, + writeClaudeBridgeSnapshot, + writeMenubarConfig, +} from './state'; + +function writeSession(root: string, relativePath: string, line: Record): void { + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${JSON.stringify(line)}\n`); +} + +describe('refreshMenubarSnapshot', () => { + const tempDirs: string[] = []; + const originalCodexHome = process.env['CODEX_HOME']; + + afterEach(() => { + if (originalCodexHome === undefined) { + delete process.env['CODEX_HOME']; + } else { + process.env['CODEX_HOME'] = originalCodexHome; + } + + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('builds a compact dual-provider title from Codex and Claude snapshots', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-home-')); + const codexHome = mkdtempSync(join(tmpdir(), 'tokenleak-codex-home-')); + tempDirs.push(homeDir, codexHome); + process.env['CODEX_HOME'] = codexHome; + + writeSession(codexHome, 'sessions/2026/03/28/session.jsonl', { + timestamp: '2026-03-28T09:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 17, window_minutes: 300, resets_at: 4102444800 }, + secondary: { used_percent: 43, window_minutes: 10080, resets_at: 4103049600 }, + plan_type: 'plus', + }, + }, + }); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + writeMenubarConfig(paths, config); + writeClaudeBridgeSnapshot(paths, { + schemaVersion: 1, + source: 'claude-statusline', + capturedAt: '2026-03-28T09:02:00.000Z', + planType: 'max', + fiveHour: { usedPercent: 62, windowMinutes: 300, resetAt: '2099-12-31T12:00:00.000Z' }, + sevenDay: { usedPercent: 54, windowMinutes: 10080, resetAt: '2099-12-31T12:00:00.000Z' }, + }); + + const snapshot = await refreshMenubarSnapshot(paths); + + expect(snapshot.title).toBe('Cdx 83% | Cld 38%'); + expect(snapshot.providers.codex.state).toBe('ready'); + expect(snapshot.providers.codex.planType).toBe('plus'); + expect(snapshot.providers.claudeCode.state).toBe('ready'); + expect(snapshot.providers.claudeCode.planType).toBe('max'); + }); + + it('marks Claude as waiting when the statusline bridge is configured but has no snapshot yet', async () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-home-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + writeMenubarConfig(paths, config); + + const snapshot = await refreshMenubarSnapshot(paths); + + expect(snapshot.providers.claudeCode.state).toBe('waiting_for_first_snapshot'); + expect(snapshot.providers.claudeCode.message).toContain('trusted interactive workspace'); + expect(snapshot.title).toContain('Cld --'); + }); +}); diff --git a/packages/cli/src/menubar/state.ts b/packages/cli/src/menubar/state.ts new file mode 100644 index 0000000..f7ed29a --- /dev/null +++ b/packages/cli/src/menubar/state.ts @@ -0,0 +1,329 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname } from 'node:path'; +import { + extractCodexQuotaSnapshot, + type CodexQuotaSnapshot, + type QuotaWindowSnapshot, +} from '@tokenleak/registry'; +import type { + ClaudeBridgeSnapshot, + MenubarConfig, + MenubarPaths, + MenubarProviderSnapshot, + MenubarSnapshot, + MenubarWindowSnapshot, + StoredQuotaWindow, +} from './types.js'; +import { + CLAUDE_STATUSLINE_SETUP_MESSAGE, + DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, + MENUBAR_SCHEMA_VERSION, +} from './types.js'; +import { toRemainingPercent } from './format.js'; + +const WINDOW_STALE_GRACE_MS = 5 * 60 * 1000; + +function createEmptyWindow(label: string, windowMinutes: number): MenubarWindowSnapshot { + return { + label, + usedPercent: null, + resetAt: null, + windowMinutes, + isStale: false, + }; +} + +function toMenubarWindow( + label: string, + value: QuotaWindowSnapshot | StoredQuotaWindow | null, + nowMs: number, + fallbackMinutes: number, +): MenubarWindowSnapshot { + if (!value) { + return createEmptyWindow(label, fallbackMinutes); + } + + const resetMs = value.resetAt ? Date.parse(value.resetAt) : Number.NaN; + const isStale = Number.isFinite(resetMs) ? nowMs > resetMs + WINDOW_STALE_GRACE_MS : false; + + return { + label, + usedPercent: value.usedPercent, + resetAt: value.resetAt, + windowMinutes: value.windowMinutes, + isStale, + }; +} + +function createProviderSnapshot( + base: Omit, + fiveHour: MenubarWindowSnapshot, + sevenDay: MenubarWindowSnapshot, +): MenubarProviderSnapshot { + return { + ...base, + windows: { + fiveHour, + sevenDay, + }, + }; +} + +function buildCodexSnapshot( + snapshot: CodexQuotaSnapshot | null, + error: string | null, + nowMs: number, +): MenubarProviderSnapshot { + const fiveHour = toMenubarWindow('5h', snapshot?.fiveHour ?? null, nowMs, 300); + const sevenDay = toMenubarWindow('7d', snapshot?.sevenDay ?? null, nowMs, 10080); + + if (error) { + return createProviderSnapshot( + { + label: 'Codex', + shortLabel: 'Cdx', + source: 'codex-log', + state: 'error', + planType: null, + lastUpdatedAt: null, + message: error, + }, + fiveHour, + sevenDay, + ); + } + + if (!snapshot) { + return createProviderSnapshot( + { + label: 'Codex', + shortLabel: 'Cdx', + source: 'codex-log', + state: 'setup_required', + planType: null, + lastUpdatedAt: null, + message: 'Use Codex once to generate a local quota snapshot.', + }, + fiveHour, + sevenDay, + ); + } + + const state = fiveHour.isStale && sevenDay.isStale ? 'stale' : 'ready'; + return createProviderSnapshot( + { + label: 'Codex', + shortLabel: 'Cdx', + source: 'codex-log', + state, + planType: snapshot.planType, + lastUpdatedAt: snapshot.capturedAt, + message: state === 'stale' ? 'Codex quota data is older than the last reset.' : null, + }, + fiveHour, + sevenDay, + ); +} + +function buildClaudeSnapshot( + snapshot: ClaudeBridgeSnapshot | null, + error: string | null, + config: MenubarConfig, + nowMs: number, +): MenubarProviderSnapshot { + const fiveHour = toMenubarWindow('5h', snapshot?.fiveHour ?? null, nowMs, 300); + const sevenDay = toMenubarWindow('7d', snapshot?.sevenDay ?? null, nowMs, 10080); + + if (error) { + return createProviderSnapshot( + { + label: 'Claude Code', + shortLabel: 'Cld', + source: 'claude-statusline', + state: 'error', + planType: null, + lastUpdatedAt: null, + message: error, + }, + fiveHour, + sevenDay, + ); + } + + if (!snapshot) { + return createProviderSnapshot( + { + label: 'Claude Code', + shortLabel: 'Cld', + source: 'claude-statusline', + state: config.claudeStatusLineManaged ? 'waiting_for_first_snapshot' : 'setup_required', + planType: null, + lastUpdatedAt: null, + message: config.claudeStatusLineManaged + ? CLAUDE_STATUSLINE_SETUP_MESSAGE + : 'Claude Code statusline bridge is not configured.', + }, + fiveHour, + sevenDay, + ); + } + + const state = fiveHour.isStale && sevenDay.isStale ? 'stale' : 'ready'; + return createProviderSnapshot( + { + label: 'Claude Code', + shortLabel: 'Cld', + source: 'claude-statusline', + state, + planType: snapshot.planType, + lastUpdatedAt: snapshot.capturedAt, + message: state === 'stale' ? 'Claude quota data is older than the last reset.' : null, + }, + fiveHour, + sevenDay, + ); +} + +function titlePercent(provider: MenubarProviderSnapshot): string { + const value = provider.windows.fiveHour; + if (provider.state !== 'ready' || value.isStale || typeof value.usedPercent !== 'number') { + return '--'; + } + + const remaining = toRemainingPercent(value.usedPercent); + return typeof remaining === 'number' ? `${Math.round(remaining)}%` : '--'; +} + +export function ensureMenubarDir(path: string): void { + mkdirSync(path, { recursive: true }); +} + +export function writeJsonAtomic(path: string, value: unknown): void { + ensureMenubarDir(dirname(path)); + const tmpPath = `${path}.tmp`; + writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`); + renameSync(tmpPath, path); +} + +export function writeExecutableScript(path: string, content: string): void { + ensureMenubarDir(dirname(path)); + writeFileSync(path, content); + chmodSync(path, 0o755); +} + +export function readMenubarConfig(paths: MenubarPaths): MenubarConfig { + if (!existsSync(paths.configPath)) { + return createDefaultMenubarConfig(); + } + + const raw = JSON.parse(readFileSync(paths.configPath, 'utf8')) as Partial; + return { + schemaVersion: MENUBAR_SCHEMA_VERSION, + pollIntervalSeconds: + typeof raw.pollIntervalSeconds === 'number' && raw.pollIntervalSeconds >= 10 + ? Math.round(raw.pollIntervalSeconds) + : DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, + claudeStatusLineManaged: raw.claudeStatusLineManaged === true, + claudeStatusLineBackup: raw.claudeStatusLineBackup ?? null, + }; +} + +export function createDefaultMenubarConfig(): MenubarConfig { + return { + schemaVersion: MENUBAR_SCHEMA_VERSION, + pollIntervalSeconds: DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, + claudeStatusLineManaged: false, + claudeStatusLineBackup: null, + }; +} + +export function writeMenubarConfig(paths: MenubarPaths, config: MenubarConfig): void { + writeJsonAtomic(paths.configPath, config); +} + +export function readSnapshot(paths: MenubarPaths): MenubarSnapshot | null { + if (!existsSync(paths.snapshotPath)) { + return null; + } + + try { + return JSON.parse(readFileSync(paths.snapshotPath, 'utf8')) as MenubarSnapshot; + } catch { + return null; + } +} + +export function readClaudeBridgeSnapshot(paths: MenubarPaths): ClaudeBridgeSnapshot | null { + if (!existsSync(paths.claudeSnapshotPath)) { + return null; + } + + try { + return JSON.parse(readFileSync(paths.claudeSnapshotPath, 'utf8')) as ClaudeBridgeSnapshot; + } catch { + return null; + } +} + +export function writeClaudeBridgeSnapshot( + paths: MenubarPaths, + snapshot: ClaudeBridgeSnapshot, +): void { + writeJsonAtomic(paths.claudeSnapshotPath, snapshot); +} + +export function writeSnapshot(paths: MenubarPaths, snapshot: MenubarSnapshot): void { + writeJsonAtomic(paths.snapshotPath, snapshot); +} + +export async function refreshMenubarSnapshot(paths: MenubarPaths): Promise { + const config = readMenubarConfig(paths); + const now = new Date(); + const nowMs = now.getTime(); + + let codexSnapshot: CodexQuotaSnapshot | null = null; + let codexError: string | null = null; + try { + codexSnapshot = await extractCodexQuotaSnapshot(); + } catch (error: unknown) { + codexError = error instanceof Error ? error.message : String(error); + } + + let claudeSnapshot: ClaudeBridgeSnapshot | null = null; + let claudeError: string | null = null; + try { + claudeSnapshot = readClaudeBridgeSnapshot(paths); + } catch (error: unknown) { + claudeError = error instanceof Error ? error.message : String(error); + } + + const codex = buildCodexSnapshot(codexSnapshot, codexError, nowMs); + const claudeCode = buildClaudeSnapshot(claudeSnapshot, claudeError, config, nowMs); + const snapshot: MenubarSnapshot = { + schemaVersion: MENUBAR_SCHEMA_VERSION, + generatedAt: now.toISOString(), + title: `${codex.shortLabel} ${titlePercent(codex)} | ${claudeCode.shortLabel} ${titlePercent( + claudeCode, + )}`, + providers: { + codex, + claudeCode, + }, + }; + + writeSnapshot(paths, snapshot); + return snapshot; +} + +export function clearMenubarState(paths: MenubarPaths): void { + rmSync(paths.snapshotPath, { force: true }); + rmSync(paths.claudeSnapshotPath, { force: true }); +} diff --git a/packages/cli/src/menubar/types.ts b/packages/cli/src/menubar/types.ts new file mode 100644 index 0000000..447f1ed --- /dev/null +++ b/packages/cli/src/menubar/types.ts @@ -0,0 +1,84 @@ +export const MENUBAR_SCHEMA_VERSION = 1; +export const DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS = 30; +export const CLAUDE_STATUSLINE_SETUP_MESSAGE = + 'Claude live quota data has not arrived yet. Use Claude Code in a trusted interactive workspace and get one response.'; + +export type MenubarProviderState = + | 'ready' + | 'setup_required' + | 'waiting_for_first_snapshot' + | 'stale' + | 'error'; + +export interface StoredQuotaWindow { + usedPercent: number; + windowMinutes: number; + resetAt: string | null; +} + +export interface ClaudeBridgeSnapshot { + schemaVersion: number; + source: 'claude-statusline'; + capturedAt: string; + planType: string | null; + fiveHour: StoredQuotaWindow | null; + sevenDay: StoredQuotaWindow | null; +} + +export interface MenubarWindowSnapshot { + label: string; + usedPercent: number | null; + resetAt: string | null; + windowMinutes: number; + isStale: boolean; +} + +export interface MenubarProviderSnapshot { + label: string; + shortLabel: string; + source: string; + state: MenubarProviderState; + planType: string | null; + lastUpdatedAt: string | null; + message: string | null; + windows: { + fiveHour: MenubarWindowSnapshot; + sevenDay: MenubarWindowSnapshot; + }; +} + +export interface MenubarSnapshot { + schemaVersion: number; + generatedAt: string; + title: string; + providers: { + codex: MenubarProviderSnapshot; + claudeCode: MenubarProviderSnapshot; + }; +} + +export interface MenubarConfig { + schemaVersion: number; + pollIntervalSeconds: number; + claudeStatusLineManaged: boolean; + claudeStatusLineBackup: unknown | null; +} + +export interface MenubarPaths { + homeDir: string; + appSupportDir: string; + logsDir: string; + launchAgentsDir: string; + configPath: string; + snapshotPath: string; + claudeSnapshotPath: string; + cliWrapperPath: string; + dashboardWrapperPath: string; + claudeStatuslineWrapperPath: string; + previousClaudeStatuslineCommandPath: string; + installedAppPath: string; + appPlistPath: string; + appLogPath: string; + daemonLogPath: string; + claudeSettingsPath: string; +} diff --git a/packages/menubar/App/TokenleakUsage.swift b/packages/menubar/App/TokenleakUsage.swift new file mode 100644 index 0000000..36468a7 --- /dev/null +++ b/packages/menubar/App/TokenleakUsage.swift @@ -0,0 +1,434 @@ +import AppKit +import Foundation + +struct SnapshotWindow: Decodable { + let label: String + let usedPercent: Double? + let resetAt: String? + let windowMinutes: Int + let isStale: Bool +} + +struct SnapshotWindowGroup: Decodable { + let fiveHour: SnapshotWindow + let sevenDay: SnapshotWindow +} + +struct SnapshotProvider: Decodable { + let label: String + let shortLabel: String + let source: String + let state: String + let planType: String? + let lastUpdatedAt: String? + let message: String? + let windows: SnapshotWindowGroup +} + +struct SnapshotProviders: Decodable { + let codex: SnapshotProvider + let claudeCode: SnapshotProvider +} + +struct MenuBarSnapshot: Decodable { + let schemaVersion: Int + let generatedAt: String + let title: String + let providers: SnapshotProviders +} + +final class ProviderCardView: NSView { + init(provider: SnapshotProvider, accentColor: NSColor) { + super.init(frame: NSRect(x: 0, y: 0, width: 360, height: 150)) + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer?.cornerRadius = 14 + layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.92).cgColor + layer?.borderWidth = 1 + layer?.borderColor = accentColor.withAlphaComponent(0.22).cgColor + + let container = NSStackView() + container.translatesAutoresizingMaskIntoConstraints = false + container.orientation = .vertical + container.spacing = 10 + addSubview(container) + + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), + container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), + container.topAnchor.constraint(equalTo: topAnchor, constant: 14), + container.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14), + widthAnchor.constraint(equalToConstant: 360), + ]) + + let header = NSStackView() + header.orientation = .horizontal + header.spacing = 8 + header.alignment = .centerY + + let title = ProviderCardView.makeLabel(provider.label, font: .boldSystemFont(ofSize: 14), color: .labelColor) + header.addArrangedSubview(title) + header.addArrangedSubview(NSView()) + + if let plan = provider.planType, !plan.isEmpty { + let badge = ProviderCardView.makeBadge(plan.uppercased(), color: accentColor) + header.addArrangedSubview(badge) + } + + container.addArrangedSubview(header) + container.addArrangedSubview(makeWindowRow(provider.windows.fiveHour, accentColor: accentColor)) + container.addArrangedSubview(makeWindowRow(provider.windows.sevenDay, accentColor: accentColor)) + + let footerText: String + if let message = provider.message, !message.isEmpty { + footerText = message + } else if let lastUpdatedAt = provider.lastUpdatedAt { + footerText = "Updated \(ProviderCardView.relativeDate(lastUpdatedAt))" + } else { + footerText = ProviderCardView.stateLabel(provider.state) + } + + let footer = ProviderCardView.makeLabel(footerText, font: .systemFont(ofSize: 11), color: .secondaryLabelColor) + footer.maximumNumberOfLines = 2 + container.addArrangedSubview(footer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func makeWindowRow(_ window: SnapshotWindow, accentColor: NSColor) -> NSView { + let wrapper = NSStackView() + wrapper.orientation = .vertical + wrapper.spacing = 4 + + let topRow = NSStackView() + topRow.orientation = .horizontal + topRow.alignment = .centerY + + let label = ProviderCardView.makeLabel(window.label, font: .monospacedDigitSystemFont(ofSize: 12, weight: .semibold), color: .secondaryLabelColor) + let value = ProviderCardView.makeLabel( + ProviderCardView.percentLabel(window), + font: .monospacedDigitSystemFont(ofSize: 12, weight: .bold), + color: .labelColor + ) + + topRow.addArrangedSubview(label) + topRow.addArrangedSubview(NSView()) + topRow.addArrangedSubview(value) + + let progress = NSProgressIndicator() + progress.isIndeterminate = false + progress.minValue = 0 + progress.maxValue = 100 + progress.doubleValue = ProviderCardView.remainingPercent(window) ?? 0 + progress.style = .bar + + let reset = ProviderCardView.makeLabel( + ProviderCardView.resetLabel(window), + font: .systemFont(ofSize: 11), + color: .secondaryLabelColor + ) + + wrapper.addArrangedSubview(topRow) + wrapper.addArrangedSubview(progress) + wrapper.addArrangedSubview(reset) + return wrapper + } + + private static func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { + let label = NSTextField(labelWithString: text) + label.font = font + label.textColor = color + label.lineBreakMode = .byTruncatingTail + return label + } + + private static func makeBadge(_ text: String, color: NSColor) -> NSTextField { + let label = NSTextField(labelWithString: " \(text) ") + label.font = .systemFont(ofSize: 10, weight: .semibold) + label.textColor = color + label.alignment = .center + label.wantsLayer = true + label.layer?.cornerRadius = 8 + label.layer?.backgroundColor = color.withAlphaComponent(0.14).cgColor + return label + } + + private static func percentLabel(_ window: SnapshotWindow) -> String { + guard let remainingPercent = remainingPercent(window) else { + return "--" + } + return "\(Int(remainingPercent.rounded()))% left" + } + + private static func remainingPercent(_ window: SnapshotWindow) -> Double? { + guard let usedPercent = window.usedPercent, !window.isStale else { + return nil + } + + return max(0, min(100, 100 - usedPercent)) + } + + private static func resetLabel(_ window: SnapshotWindow) -> String { + if window.isStale { + return "Waiting for a fresh post-reset sample" + } + + guard let resetAt = window.resetAt else { + return "Reset time unavailable" + } + + return "Resets \(relativeDate(resetAt))" + } + + private static func relativeDate(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + guard let date = formatter.date(from: iso) else { + return iso + } + + let relative = RelativeDateTimeFormatter() + relative.unitsStyle = .short + return relative.localizedString(for: date, relativeTo: Date()) + } + + private static func stateLabel(_ state: String) -> String { + switch state { + case "setup_required": + return "Needs setup" + case "waiting_for_first_snapshot": + return "Waiting for first snapshot" + case "stale": + return "Snapshot is stale" + case "error": + return "Snapshot error" + default: + return "Ready" + } + } +} + +final class TokenleakUsageController: NSObject, NSApplicationDelegate { + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let decoder = JSONDecoder() + private let homeDir: String + private let supportDir: String + private let snapshotPath: String + private let cliWrapperPath: String + private let dashboardWrapperPath: String + private let daemonLogPath: String + private var snapshot: MenuBarSnapshot? + private var timer: Timer? + private var daemonProcess: Process? + + init(homeDir: String) { + self.homeDir = homeDir + self.supportDir = "\(homeDir)/Library/Application Support/tokenleak/menubar" + self.snapshotPath = "\(supportDir)/snapshot.json" + self.cliWrapperPath = "\(supportDir)/tokenleak-menubar-cli" + self.dashboardWrapperPath = "\(supportDir)/tokenleak-menubar-dashboard" + self.daemonLogPath = "\(supportDir)/logs/daemon.log" + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + statusItem.button?.title = "Cdx -- | Cld --" + startDaemonIfNeeded() + reloadSnapshot() + timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in + self?.startDaemonIfNeeded() + self?.reloadSnapshot() + } + if let timer = timer { + RunLoop.main.add(timer, forMode: .common) + } + } + + func applicationWillTerminate(_ notification: Notification) { + daemonProcess?.terminate() + } + + private func startDaemonIfNeeded() { + guard daemonProcess?.isRunning != true else { + return + } + + guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliWrapperPath) + process.arguments = ["menubar", "daemon", "--home", homeDir] + + FileManager.default.createFile(atPath: daemonLogPath, contents: nil) + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: daemonLogPath)) { + _ = try? handle.seekToEnd() + process.standardOutput = handle + process.standardError = handle + } + + do { + try process.run() + daemonProcess = process + } catch { + NSSound.beep() + } + } + + private func reloadSnapshot() { + let url = URL(fileURLWithPath: snapshotPath) + guard let data = try? Data(contentsOf: url) else { + snapshot = nil + renderTitle() + renderMenu() + return + } + + snapshot = try? decoder.decode(MenuBarSnapshot.self, from: data) + renderTitle() + renderMenu() + } + + private func renderTitle() { + let title = snapshot?.title ?? "Cdx -- | Cld --" + let attributed = NSAttributedString( + string: title, + attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .semibold)] + ) + statusItem.button?.attributedTitle = attributed + } + + private func renderMenu() { + let menu = NSMenu() + + if let snapshot = snapshot { + addHeader(title: snapshot.title, subtitle: "Live quota windows", to: menu) + menu.addItem(.separator()) + addProviderCard(snapshot.providers.codex, accentColor: .systemGreen, to: menu) + addProviderCard(snapshot.providers.claudeCode, accentColor: .systemOrange, to: menu) + } else { + let item = NSMenuItem(title: "Waiting for quota snapshot", action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } + + menu.addItem(.separator()) + menu.addItem(makeActionItem(title: "Refresh", action: #selector(refreshNow))) + menu.addItem(makeActionItem(title: "Open tokenleak dashboard", action: #selector(openDashboard))) + menu.addItem(makeActionItem(title: "Open menubar folder", action: #selector(openMenubarFolder))) + menu.addItem(.separator()) + menu.addItem(makeActionItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")) + statusItem.menu = menu + } + + private func addHeader(title: String, subtitle: String, to menu: NSMenu) { + let stack = NSStackView() + stack.orientation = .vertical + stack.spacing = 2 + stack.edgeInsets = NSEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) + + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .bold) + titleLabel.textColor = .labelColor + + let subtitleLabel = NSTextField(labelWithString: subtitle) + subtitleLabel.font = .systemFont(ofSize: 11) + subtitleLabel.textColor = .secondaryLabelColor + + stack.addArrangedSubview(titleLabel) + stack.addArrangedSubview(subtitleLabel) + + let view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 46)) + stack.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + stack.topAnchor.constraint(equalTo: view.topAnchor), + stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + view.widthAnchor.constraint(equalToConstant: 360), + ]) + + let item = NSMenuItem() + item.view = view + menu.addItem(item) + } + + private func addProviderCard(_ provider: SnapshotProvider, accentColor: NSColor, to menu: NSMenu) { + let card = ProviderCardView(provider: provider, accentColor: accentColor) + let wrapper = NSView(frame: NSRect(x: 0, y: 0, width: 376, height: 164)) + card.translatesAutoresizingMaskIntoConstraints = false + wrapper.addSubview(card) + + NSLayoutConstraint.activate([ + card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor, constant: 8), + card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor, constant: -8), + card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 6), + card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor, constant: -6), + wrapper.widthAnchor.constraint(equalToConstant: 376), + ]) + + let item = NSMenuItem() + item.view = wrapper + menu.addItem(item) + } + + private func makeActionItem(title: String, action: Selector, keyEquivalent: String = "") -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) + item.target = self + return item + } + + @objc private func refreshNow() { + runCliCommand(["menubar", "refresh", "--home", homeDir]) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.reloadSnapshot() + } + } + + @objc private func openDashboard() { + guard FileManager.default.isExecutableFile(atPath: dashboardWrapperPath) else { + NSSound.beep() + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-a", "Terminal", dashboardWrapperPath] + try? process.run() + } + + @objc private func openMenubarFolder() { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: supportDir)]) + } + + @objc private func quitApp() { + NSApplication.shared.terminate(nil) + } + + private func runCliCommand(_ arguments: [String]) { + guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { + NSSound.beep() + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliWrapperPath) + process.arguments = arguments + do { + try process.run() + } catch { + NSSound.beep() + } + } +} + +let home = ProcessInfo.processInfo.environment["TOKENLEAK_MENUBAR_HOME"] ?? NSHomeDirectory() +let app = NSApplication.shared +let delegate = TokenleakUsageController(homeDir: home) +app.setActivationPolicy(.accessory) +app.delegate = delegate +app.run() diff --git a/packages/menubar/package.json b/packages/menubar/package.json new file mode 100644 index 0000000..6cd0e31 --- /dev/null +++ b/packages/menubar/package.json @@ -0,0 +1,10 @@ +{ + "name": "@tokenleak/menubar", + "version": "2.1.0", + "private": true, + "scripts": { + "build": "bun ../../scripts/build-menubar-app.ts", + "test": "bun ../../scripts/check-menubar-app.ts", + "check": "bun ../../scripts/check-menubar-app.ts" + } +} diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts index 3fba00e..3d3f5de 100644 --- a/packages/registry/src/index.ts +++ b/packages/registry/src/index.ts @@ -14,9 +14,17 @@ export { export type { ModelPricing, CostBreakdown } from './models'; export type { IProvider } from './provider'; +export type { CodexQuotaSnapshot, QuotaWindowSnapshot } from './providers/index'; export { ProviderRegistry } from './registry'; export { splitJsonlRecords } from './parsers/index'; -export { ClaudeCodeProvider, CodexProvider, CursorProvider, OpenCodeProvider, PiProvider } from './providers/index'; +export { + ClaudeCodeProvider, + CodexProvider, + CursorProvider, + OpenCodeProvider, + PiProvider, + extractCodexQuotaSnapshot, +} from './providers/index'; export { CursorAuthError, getActiveCursorCredentials, diff --git a/packages/registry/src/providers/codex-rate-limits.test.ts b/packages/registry/src/providers/codex-rate-limits.test.ts new file mode 100644 index 0000000..1e36b70 --- /dev/null +++ b/packages/registry/src/providers/codex-rate-limits.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { extractCodexQuotaSnapshot } from './codex-rate-limits'; + +function writeSession(root: string, relativePath: string, lines: string[]): void { + const fullPath = join(root, relativePath); + mkdirSync(dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, `${lines.join('\n')}\n`); +} + +describe('extractCodexQuotaSnapshot', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns the newest non-null rate limits snapshot', async () => { + const root = mkdtempSync(join(tmpdir(), 'tokenleak-codex-quotas-')); + tempDirs.push(root); + + writeSession(root, '2026/03/21/session-a.jsonl', [ + JSON.stringify({ + timestamp: '2026-03-21T09:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 12, window_minutes: 300, resets_at: 1774184683 }, + secondary: { used_percent: 44, window_minutes: 10080, resets_at: 1774554212 }, + plan_type: 'plus', + }, + }, + }), + ]); + + writeSession(root, '2026/03/22/session-b.jsonl', [ + JSON.stringify({ + timestamp: '2026-03-22T09:00:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: null, + }, + }), + JSON.stringify({ + timestamp: '2026-03-22T10:15:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: { + primary: { used_percent: 18, window_minutes: 300, resets_at: 1774271083 }, + secondary: { used_percent: 51, window_minutes: 10080, resets_at: 1774637012 }, + plan_type: 'pro', + }, + }, + }), + ]); + + const snapshot = await extractCodexQuotaSnapshot(root); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.capturedAt).toBe('2026-03-22T10:15:00.000Z'); + expect(snapshot?.planType).toBe('pro'); + expect(snapshot?.fiveHour?.usedPercent).toBe(18); + expect(snapshot?.fiveHour?.windowMinutes).toBe(300); + expect(snapshot?.sevenDay?.usedPercent).toBe(51); + expect(snapshot?.sevenDay?.windowMinutes).toBe(10080); + }); + + it('returns null when no usable rate limits exist', async () => { + const root = mkdtempSync(join(tmpdir(), 'tokenleak-codex-quotas-')); + tempDirs.push(root); + + writeSession(root, '2026/03/22/session.jsonl', [ + JSON.stringify({ + timestamp: '2026-03-22T10:15:00.000Z', + type: 'event_msg', + payload: { + type: 'token_count', + rate_limits: null, + }, + }), + ]); + + const snapshot = await extractCodexQuotaSnapshot(root); + expect(snapshot).toBeNull(); + }); +}); diff --git a/packages/registry/src/providers/codex-rate-limits.ts b/packages/registry/src/providers/codex-rate-limits.ts new file mode 100644 index 0000000..753d9e9 --- /dev/null +++ b/packages/registry/src/providers/codex-rate-limits.ts @@ -0,0 +1,154 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { splitJsonlRecords } from '../parsers/jsonl-splitter'; + +function resolveDefaultSessionsDir(): string { + return join(process.env['CODEX_HOME'] ?? join(homedir(), '.codex'), 'sessions'); +} + +export interface QuotaWindowSnapshot { + usedPercent: number; + windowMinutes: number; + resetAt: string | null; +} + +export interface CodexQuotaSnapshot { + provider: 'codex'; + capturedAt: string; + planType: string | null; + fiveHour: QuotaWindowSnapshot | null; + sevenDay: QuotaWindowSnapshot | null; +} + +interface ParsedCodexQuotaSnapshot { + capturedAt: string; + planType: string | null; + fiveHour: QuotaWindowSnapshot | null; + sevenDay: QuotaWindowSnapshot | null; +} + +function collectJsonlFiles(dir: string): string[] { + if (!existsSync(dir)) { + return []; + } + + const files: string[] = []; + for (const entry of readdirSync(dir)) { + const fullPath = join(dir, entry); + const stats = statSync(fullPath); + if (stats.isDirectory()) { + files.push(...collectJsonlFiles(fullPath)); + } else if (entry.endsWith('.jsonl')) { + files.push(fullPath); + } + } + + return files; +} + +function toResetAtIso(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + + return new Date(value * 1000).toISOString(); +} + +function parseQuotaWindow(value: unknown): QuotaWindowSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const usedPercent = record['used_percent']; + const windowMinutes = record['window_minutes']; + + if (typeof usedPercent !== 'number' || typeof windowMinutes !== 'number') { + return null; + } + + return { + usedPercent, + windowMinutes, + resetAt: toResetAtIso(record['resets_at']), + }; +} + +function parseQuotaSnapshot(record: unknown): ParsedCodexQuotaSnapshot | null { + if (typeof record !== 'object' || record === null) { + return null; + } + + const root = record as Record; + if (root['type'] !== 'event_msg') { + return null; + } + + const timestamp = root['timestamp']; + const payload = root['payload']; + if (typeof timestamp !== 'string' || typeof payload !== 'object' || payload === null) { + return null; + } + + const payloadRecord = payload as Record; + if (payloadRecord['type'] !== 'token_count') { + return null; + } + + const rateLimits = payloadRecord['rate_limits']; + if (typeof rateLimits !== 'object' || rateLimits === null) { + return null; + } + + const limits = rateLimits as Record; + const fiveHour = parseQuotaWindow(limits['primary']); + const sevenDay = parseQuotaWindow(limits['secondary']); + + if (!fiveHour && !sevenDay) { + return null; + } + + return { + capturedAt: timestamp, + planType: typeof limits['plan_type'] === 'string' ? limits['plan_type'] : null, + fiveHour, + sevenDay, + }; +} + +export async function extractCodexQuotaSnapshot( + baseDir: string = resolveDefaultSessionsDir(), +): Promise { + const files = collectJsonlFiles(baseDir); + let latest: ParsedCodexQuotaSnapshot | null = null; + + for (const file of files) { + try { + for await (const record of splitJsonlRecords(file)) { + const parsed = parseQuotaSnapshot(record); + if (!parsed) { + continue; + } + + if (!latest || parsed.capturedAt > latest.capturedAt) { + latest = parsed; + } + } + } catch { + continue; + } + } + + if (!latest) { + return null; + } + + return { + provider: 'codex', + capturedAt: latest.capturedAt, + planType: latest.planType, + fiveHour: latest.fiveHour, + sevenDay: latest.sevenDay, + }; +} diff --git a/packages/registry/src/providers/index.ts b/packages/registry/src/providers/index.ts index 580698a..9457905 100644 --- a/packages/registry/src/providers/index.ts +++ b/packages/registry/src/providers/index.ts @@ -1,5 +1,7 @@ export { ClaudeCodeProvider } from './claude-code'; export { CodexProvider } from './codex'; +export { extractCodexQuotaSnapshot } from './codex-rate-limits'; +export type { CodexQuotaSnapshot, QuotaWindowSnapshot } from './codex-rate-limits'; export { CursorProvider } from './cursor'; export { OpenCodeProvider } from './open-code'; export { PiProvider } from './pi'; diff --git a/scripts/build-menubar-app.ts b/scripts/build-menubar-app.ts new file mode 100644 index 0000000..5279693 --- /dev/null +++ b/scripts/build-menubar-app.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env bun +import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const rootDir = resolve(import.meta.dir, '..'); +const sourceFile = join(rootDir, 'packages', 'menubar', 'App', 'TokenleakUsage.swift'); +const outputApp = join(rootDir, 'packages', 'menubar', 'dist', 'Tokenleak Usage.app'); +const outputExecutable = join(outputApp, 'Contents', 'MacOS', 'Tokenleak Usage'); +const infoPlist = join(outputApp, 'Contents', 'Info.plist'); +const cliPackage = (await Bun.file(join(rootDir, 'packages', 'cli', 'package.json')).json()) as { + version: string; +}; + +if (process.platform !== 'darwin') { + console.log('Skipping menubar app build on non-macOS host.'); + process.exit(0); +} + +if (!existsSync(sourceFile)) { + console.error(`Missing source file: ${sourceFile}`); + process.exit(1); +} + +rmSync(outputApp, { recursive: true, force: true }); +mkdirSync(dirname(outputExecutable), { recursive: true }); + +const compile = Bun.spawnSync( + [ + '/usr/bin/swiftc', + sourceFile, + '-o', + outputExecutable, + '-framework', + 'AppKit', + '-framework', + 'Foundation', + ], + { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', + }, +); + +if (compile.exitCode !== 0) { + process.exit(compile.exitCode ?? 1); +} + +const plist = ` + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Tokenleak Usage + CFBundleIdentifier + com.tokenleak.menubar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Tokenleak Usage + CFBundlePackageType + APPL + CFBundleShortVersionString + ${cliPackage.version} + CFBundleVersion + ${cliPackage.version} + LSUIElement + + NSHighResolutionCapable + + + +`; + +writeFileSync(infoPlist, plist); +chmodSync(outputExecutable, 0o755); + +const sign = Bun.spawnSync(['/usr/bin/codesign', '--force', '--deep', '--sign', '-', outputApp], { + stdout: 'inherit', + stderr: 'inherit', +}); + +if (sign.exitCode !== 0) { + process.exit(sign.exitCode ?? 1); +} + +console.log(outputApp); diff --git a/scripts/check-menubar-app.ts b/scripts/check-menubar-app.ts new file mode 100644 index 0000000..81d2582 --- /dev/null +++ b/scripts/check-menubar-app.ts @@ -0,0 +1,27 @@ +#!/usr/bin/env bun +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const rootDir = resolve(import.meta.dir, '..'); +const sourceFile = join(rootDir, 'packages', 'menubar', 'App', 'TokenleakUsage.swift'); + +if (process.platform !== 'darwin') { + console.log('Skipping menubar app checks on non-macOS host.'); + process.exit(0); +} + +if (!existsSync(sourceFile)) { + console.error(`Missing source file: ${sourceFile}`); + process.exit(1); +} + +const proc = Bun.spawnSync( + ['/usr/bin/swiftc', '-typecheck', sourceFile, '-framework', 'AppKit', '-framework', 'Foundation'], + { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', + }, +); + +process.exit(proc.exitCode ?? 0); diff --git a/scripts/package-menubar-app.ts b/scripts/package-menubar-app.ts new file mode 100644 index 0000000..aa2cad1 --- /dev/null +++ b/scripts/package-menubar-app.ts @@ -0,0 +1,47 @@ +#!/usr/bin/env bun +import { existsSync, mkdirSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const rootDir = resolve(import.meta.dir, '..'); +const buildScript = join(rootDir, 'scripts', 'build-menubar-app.ts'); +const appPath = join(rootDir, 'packages', 'menubar', 'dist', 'Tokenleak Usage.app'); +const outDir = join(rootDir, 'dist-menubar'); +const zipPath = join(outDir, 'tokenleak-menubar-macos-universal.zip'); + +if (process.platform !== 'darwin') { + console.log('Skipping menubar packaging on non-macOS host.'); + process.exit(0); +} + +const build = Bun.spawnSync([process.execPath, buildScript], { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', +}); + +if (build.exitCode !== 0) { + process.exit(build.exitCode ?? 1); +} + +if (!existsSync(appPath)) { + console.error(`Built app bundle not found: ${appPath}`); + process.exit(1); +} + +mkdirSync(outDir, { recursive: true }); +rmSync(zipPath, { force: true }); + +const zip = Bun.spawnSync( + ['/usr/bin/ditto', '-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath], + { + cwd: rootDir, + stdout: 'inherit', + stderr: 'inherit', + }, +); + +if (zip.exitCode !== 0) { + process.exit(zip.exitCode ?? 1); +} + +console.log(zipPath); From 01e11cb523cc7833851e9b5faf8f8d1fbef7835b Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 28 Mar 2026 18:54:22 +0530 Subject: [PATCH 2/5] feat: overhaul menubar UI with flat progress bars and correct brand colors - Replace arc progress rings with native-style progress bars (saves space, no text clipping) - Switch to vertical card layout with per-window progress rows - Use correct brand colors: Claude orange (#D97757), Codex green (#10A37F) - Remove hover effects, glass gradients, and heavy shadows for flat macOS aesthetic - Fix settings window title bar visibility (remove fullSizeContentView) - Remove refresh button rotation animation - Replace custom menu bar glyph with SF Symbol gauge icon - Fix popover corner gap (remove manual clipShape/stroke) - Add text truncation to settings path rows --- packages/menubar/App/AppDelegate.swift | 186 ++++++++ packages/menubar/App/LLMUsageApp.swift | 12 + .../App/Services/ClaudeUsageService.swift | 21 + .../App/Services/CodexUsageService.swift | 21 + .../menubar/App/Services/UsageService.swift | 250 ++++++++++ .../menubar/App/Settings/SettingsView.swift | 117 +++++ packages/menubar/App/TokenleakUsage.swift | 434 ------------------ .../App/Utilities/KeychainHelper.swift | 55 +++ packages/menubar/App/Utilities/Theme.swift | 56 +++ .../menubar/App/Utilities/TimerManager.swift | 28 ++ .../App/ViewModels/UsageViewModel.swift | 155 +++++++ .../menubar/App/Views/ArcProgressRing.swift | 77 ++++ .../App/Views/PopoverContentView.swift | 115 +++++ packages/menubar/App/Views/UsageCard.swift | 160 +++++++ scripts/build-menubar-app.ts | 33 +- scripts/check-menubar-app.ts | 41 +- 16 files changed, 1317 insertions(+), 444 deletions(-) create mode 100644 packages/menubar/App/AppDelegate.swift create mode 100644 packages/menubar/App/LLMUsageApp.swift create mode 100644 packages/menubar/App/Services/ClaudeUsageService.swift create mode 100644 packages/menubar/App/Services/CodexUsageService.swift create mode 100644 packages/menubar/App/Services/UsageService.swift create mode 100644 packages/menubar/App/Settings/SettingsView.swift delete mode 100644 packages/menubar/App/TokenleakUsage.swift create mode 100644 packages/menubar/App/Utilities/KeychainHelper.swift create mode 100644 packages/menubar/App/Utilities/Theme.swift create mode 100644 packages/menubar/App/Utilities/TimerManager.swift create mode 100644 packages/menubar/App/ViewModels/UsageViewModel.swift create mode 100644 packages/menubar/App/Views/ArcProgressRing.swift create mode 100644 packages/menubar/App/Views/PopoverContentView.swift create mode 100644 packages/menubar/App/Views/UsageCard.swift diff --git a/packages/menubar/App/AppDelegate.swift b/packages/menubar/App/AppDelegate.swift new file mode 100644 index 0000000..3bdf63f --- /dev/null +++ b/packages/menubar/App/AppDelegate.swift @@ -0,0 +1,186 @@ +import AppKit +import Combine +import SwiftUI + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let popover = NSPopover() + private let refreshTimer = TimerManager() + private var settingsWindowController: SettingsWindowController? + private var statusHostingView: NSHostingView? + private var subscriptions = Set() + private var daemonProcess: Process? + + private lazy var viewModel: UsageViewModel = { + UsageViewModel( + homeDirectory: homeDirectory, + supportDirectory: supportDirectory, + snapshotPath: snapshotPath, + cliWrapperPath: cliWrapperPath, + dashboardWrapperPath: dashboardWrapperPath + ) + }() + + private let homeDirectory: String + private let supportDirectory: String + private let snapshotPath: String + private let cliWrapperPath: String + private let dashboardWrapperPath: String + private let daemonLogPath: String + + override init() { + let homeDirectory = ProcessInfo.processInfo.environment["TOKENLEAK_MENUBAR_HOME"] ?? NSHomeDirectory() + self.homeDirectory = homeDirectory + self.supportDirectory = "\(homeDirectory)/Library/Application Support/tokenleak/menubar" + self.snapshotPath = "\(supportDirectory)/snapshot.json" + self.cliWrapperPath = "\(supportDirectory)/tokenleak-menubar-cli" + self.dashboardWrapperPath = "\(supportDirectory)/tokenleak-menubar-dashboard" + self.daemonLogPath = "\(supportDirectory)/logs/daemon.log" + super.init() + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + configureStatusItem() + configurePopover() + bindViewModel() + + startDaemonIfNeeded() + viewModel.reloadSnapshot(animated: false) + + refreshTimer.start(every: 5, fireImmediately: false) { [weak self] in + self?.startDaemonIfNeeded() + self?.viewModel.reloadSnapshot(animated: false) + } + } + + func applicationWillTerminate(_ notification: Notification) { + refreshTimer.stop() + daemonProcess?.terminate() + } + + @objc func togglePopover(_ sender: Any?) { + guard let button = statusItem.button else { + return + } + + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + @objc func openSettingsWindow(_ sender: Any?) { + popover.performClose(sender) + + if settingsWindowController == nil { + settingsWindowController = SettingsWindowController(viewModel: viewModel) + } + settingsWindowController?.present() + } + + private func configureStatusItem() { + guard let button = statusItem.button else { + return + } + + button.target = self + button.action = #selector(togglePopover(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + button.image = nil + button.title = "" + + let hostingView = NSHostingView(rootView: MenuBarStatusItemView(label: viewModel.statusLabel, tintColor: Color(nsColor: viewModel.statusTint))) + hostingView.translatesAutoresizingMaskIntoConstraints = false + button.addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 4), + hostingView.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -4), + hostingView.topAnchor.constraint(equalTo: button.topAnchor, constant: 1), + hostingView.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -1), + ]) + + self.statusHostingView = hostingView + } + + private func configurePopover() { + popover.behavior = .transient + popover.animates = true + popover.contentSize = NSSize(width: 360, height: 400) + popover.contentViewController = NSHostingController( + rootView: PopoverContentView(viewModel: viewModel, onOpenSettings: { [weak self] in + self?.openSettingsWindow(nil) + }) + ) + } + + private func bindViewModel() { + Publishers.CombineLatest3(viewModel.$claudeUsage, viewModel.$codexUsage, viewModel.$lastUpdatedText) + .receive(on: RunLoop.main) + .sink { [weak self] _, _, _ in + self?.updateStatusItem() + } + .store(in: &subscriptions) + } + + private func updateStatusItem() { + statusHostingView?.rootView = MenuBarStatusItemView( + label: viewModel.statusLabel, + tintColor: Color(nsColor: viewModel.statusTint) + ) + } + + private func startDaemonIfNeeded() { + guard daemonProcess?.isRunning != true else { + return + } + + guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliWrapperPath) + process.arguments = ["menubar", "daemon", "--home", homeDirectory] + + FileManager.default.createFile(atPath: daemonLogPath, contents: nil) + if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: daemonLogPath)) { + _ = try? handle.seekToEnd() + process.standardOutput = handle + process.standardError = handle + } + + do { + try process.run() + daemonProcess = process + } catch { + NSSound.beep() + } + } +} + +private struct MenuBarStatusItemView: View { + let label: String? + let tintColor: Color + + var body: some View { + HStack(spacing: 5) { + MenuBarGlyph(color: tintColor) + .frame(width: 14, height: 14) + + if let label { + Text(label) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(tintColor) + } + } + .padding(.horizontal, 4) + .frame(height: 20) + .allowsHitTesting(false) + } +} diff --git a/packages/menubar/App/LLMUsageApp.swift b/packages/menubar/App/LLMUsageApp.swift new file mode 100644 index 0000000..adf2041 --- /dev/null +++ b/packages/menubar/App/LLMUsageApp.swift @@ -0,0 +1,12 @@ +import SwiftUI + +@main +struct LLMUsageApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} diff --git a/packages/menubar/App/Services/ClaudeUsageService.swift b/packages/menubar/App/Services/ClaudeUsageService.swift new file mode 100644 index 0000000..335059d --- /dev/null +++ b/packages/menubar/App/Services/ClaudeUsageService.swift @@ -0,0 +1,21 @@ +import Foundation + +final class ClaudeUsageService: UsageService { + let kind: UsageProviderKind = .claude + + func resolveUsage(from snapshot: MenuBarSnapshot?) -> UsageCardState { + let provider = snapshot?.providers.claudeCode + let windows = makeUsageWindows(from: provider) + + return UsageCardState( + kind: kind, + serviceName: kind.title, + modelLabel: descriptorText(planType: provider?.planType, fallback: kind.fallbackDescriptor), + state: provider?.state ?? .setupRequired, + message: provider?.message, + windows: windows.isEmpty ? [placeholderWindow(label: "5H"), placeholderWindow(label: "7D")] : windows, + primaryWindow: pickPrimaryWindow(from: windows), + lastUpdatedAt: parseSnapshotDate(provider?.lastUpdatedAt) + ) + } +} diff --git a/packages/menubar/App/Services/CodexUsageService.swift b/packages/menubar/App/Services/CodexUsageService.swift new file mode 100644 index 0000000..509891f --- /dev/null +++ b/packages/menubar/App/Services/CodexUsageService.swift @@ -0,0 +1,21 @@ +import Foundation + +final class CodexUsageService: UsageService { + let kind: UsageProviderKind = .codex + + func resolveUsage(from snapshot: MenuBarSnapshot?) -> UsageCardState { + let provider = snapshot?.providers.codex + let windows = makeUsageWindows(from: provider) + + return UsageCardState( + kind: kind, + serviceName: kind.title, + modelLabel: descriptorText(planType: provider?.planType, fallback: kind.fallbackDescriptor), + state: provider?.state ?? .setupRequired, + message: provider?.message, + windows: windows.isEmpty ? [placeholderWindow(label: "5H"), placeholderWindow(label: "7D")] : windows, + primaryWindow: pickPrimaryWindow(from: windows), + lastUpdatedAt: parseSnapshotDate(provider?.lastUpdatedAt) + ) + } +} diff --git a/packages/menubar/App/Services/UsageService.swift b/packages/menubar/App/Services/UsageService.swift new file mode 100644 index 0000000..d6c1392 --- /dev/null +++ b/packages/menubar/App/Services/UsageService.swift @@ -0,0 +1,250 @@ +import AppKit +import Foundation +import SwiftUI + +enum UsageProviderKind: String { + case claude + case codex + + var title: String { + switch self { + case .claude: + return "Claude Code" + case .codex: + return "Codex" + } + } + + var fallbackDescriptor: String { + "Active quota" + } +} + +enum UsageProviderState: String, Decodable { + case ready + case setupRequired = "setup_required" + case waitingForFirstSnapshot = "waiting_for_first_snapshot" + case stale + case error + + var displayLabel: String { + switch self { + case .ready: + return "Ready" + case .setupRequired: + return "Setup required" + case .waitingForFirstSnapshot: + return "Waiting for first snapshot" + case .stale: + return "Snapshot stale" + case .error: + return "Unavailable" + } + } +} + +struct SnapshotWindow: Decodable { + let label: String + let usedPercent: Double? + let resetAt: String? + let windowMinutes: Int + let isStale: Bool +} + +struct SnapshotWindowGroup: Decodable { + let fiveHour: SnapshotWindow + let sevenDay: SnapshotWindow +} + +struct SnapshotProvider: Decodable { + let label: String + let shortLabel: String + let source: String + let state: UsageProviderState + let planType: String? + let lastUpdatedAt: String? + let message: String? + let windows: SnapshotWindowGroup +} + +struct SnapshotProviders: Decodable { + let codex: SnapshotProvider + let claudeCode: SnapshotProvider +} + +struct MenuBarSnapshot: Decodable { + let schemaVersion: Int + let generatedAt: String + let title: String + let providers: SnapshotProviders +} + +struct UsageWindowState: Identifiable { + let id: String + let label: String + let usedPercent: Double? + let remainingPercent: Double? + let resetAt: Date? + let isStale: Bool + let windowMinutes: Int + + var progress: Double { + guard let remainingPercent else { + return 0 + } + return max(0, min(1, remainingPercent / 100)) + } + + var compactLabel: String { + guard let remainingPercent else { + return "\(label) --" + } + return "\(label) \(Int(remainingPercent.rounded()))%" + } + + var remainingText: String { + guard let remainingPercent else { + return "--%" + } + return "\(Int(remainingPercent.rounded()))%" + } + + var resetText: String { + guard let resetAt else { + return "Reset time unavailable" + } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + let relative = formatter.localizedString(for: resetAt, relativeTo: Date()) + if resetAt < Date() { + return "Reset imminent" + } + return "Resets \(relative)" + } +} + +struct UsageCardState { + let kind: UsageProviderKind + let serviceName: String + let modelLabel: String + let state: UsageProviderState + let message: String? + let windows: [UsageWindowState] + let primaryWindow: UsageWindowState + let lastUpdatedAt: Date? + + var displayPercent: Double? { + guard state == .ready else { + return nil + } + return primaryWindow.remainingPercent + } + + var headerDetail: String { + if state == .ready { + return modelLabel + } + return state.displayLabel + } + + var footerText: String { + if state == .ready { + return primaryWindow.resetText + } + return message ?? state.displayLabel + } + + var primaryProgress: Double { + primaryWindow.progress + } + + var usedPercent: Double { + guard let usedPercent = primaryWindow.usedPercent else { + return 0 + } + return max(0, min(100, usedPercent)) + } +} + +protocol UsageService { + var kind: UsageProviderKind { get } + func resolveUsage(from snapshot: MenuBarSnapshot?) -> UsageCardState +} + +func parseSnapshotDate(_ value: String?) -> Date? { + guard let value else { + return nil + } + + return UsageFormatters.iso8601.date(from: value) +} + +func remainingPercent(from usedPercent: Double?) -> Double? { + guard let usedPercent else { + return nil + } + return max(0, min(100, 100 - usedPercent)) +} + +func makeUsageWindows(from provider: SnapshotProvider?) -> [UsageWindowState] { + let windows = [ + provider?.windows.fiveHour, + provider?.windows.sevenDay, + ] + + return windows.compactMap { snapshotWindow in + guard let snapshotWindow else { + return nil + } + + return UsageWindowState( + id: snapshotWindow.label, + label: snapshotWindow.label.uppercased(), + usedPercent: snapshotWindow.usedPercent, + remainingPercent: snapshotWindow.isStale ? nil : remainingPercent(from: snapshotWindow.usedPercent), + resetAt: parseSnapshotDate(snapshotWindow.resetAt), + isStale: snapshotWindow.isStale, + windowMinutes: snapshotWindow.windowMinutes + ) + } +} + +func placeholderWindow(label: String) -> UsageWindowState { + UsageWindowState( + id: label, + label: label, + usedPercent: nil, + remainingPercent: nil, + resetAt: nil, + isStale: false, + windowMinutes: 0 + ) +} + +func pickPrimaryWindow(from windows: [UsageWindowState]) -> UsageWindowState { + let valid = windows.filter { !$0.isStale && $0.remainingPercent != nil } + if let mostConstrained = valid.min(by: { ($0.remainingPercent ?? 101) < ($1.remainingPercent ?? 101) }) { + return mostConstrained + } + return windows.first ?? placeholderWindow(label: "5H") +} + +func descriptorText(planType: String?, fallback: String) -> String { + guard let planType, !planType.isEmpty else { + return fallback + } + + let pieces = planType + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { $0.capitalized } + return pieces.joined(separator: " ") +} + +enum UsageFormatters { + static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() +} diff --git a/packages/menubar/App/Settings/SettingsView.swift b/packages/menubar/App/Settings/SettingsView.swift new file mode 100644 index 0000000..1aa37c9 --- /dev/null +++ b/packages/menubar/App/Settings/SettingsView.swift @@ -0,0 +1,117 @@ +import AppKit +import SwiftUI + +struct SettingsView: View { + @ObservedObject var viewModel: UsageViewModel + let onOpenSupportFolder: () -> Void + let onOpenDashboard: () -> Void + + var body: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + .overlay(AppTheme.backgroundTint) + .overlay(AppTheme.backgroundOverlay) + + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Preferences") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + .foregroundStyle(AppTheme.textPrimary) + Text("Tokenleak Usage reads local menubar snapshots and opens the dashboard on demand.") + .font(.system(size: 12)) + .foregroundStyle(AppTheme.textSecondary) + .lineLimit(2) + } + + SettingsRow(title: "Home", value: viewModel.homeDirectory) + SettingsRow(title: "Support", value: viewModel.supportDirectory) + SettingsRow(title: "Snapshot", value: viewModel.snapshotPath) + SettingsRow(title: "Updated", value: viewModel.lastUpdatedText) + + HStack(spacing: 10) { + actionButton("Open Support Folder", action: onOpenSupportFolder) + actionButton("Open Dashboard", action: onOpenDashboard) + } + + Spacer(minLength: 0) + } + .padding(22) + } + .frame(width: 460, height: 280) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(AppTheme.panelEdge, lineWidth: 0.6) + ) + } + + private func actionButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(AppTheme.textPrimary) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Capsule() + .fill(Color.white.opacity(0.06)) + ) + .overlay( + Capsule() + .stroke(Color.white.opacity(0.10), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } +} + +private struct SettingsRow: View { + let title: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(AppTheme.textTertiary) + Text(value) + .font(.system(size: 12)) + .foregroundStyle(AppTheme.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } +} + +final class SettingsWindowController: NSWindowController { + init(viewModel: UsageViewModel) { + let rootView = SettingsView( + viewModel: viewModel, + onOpenSupportFolder: { viewModel.openSupportFolder() }, + onOpenDashboard: { viewModel.openDashboard() } + ) + let hostingController = NSHostingController(rootView: rootView) + + let window = NSWindow(contentViewController: hostingController) + window.title = "Tokenleak Usage Preferences" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.isMovableByWindowBackground = true + window.center() + + super.init(window: window) + shouldCascadeWindows = false + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func present() { + NSApp.activate(ignoringOtherApps: true) + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + } +} diff --git a/packages/menubar/App/TokenleakUsage.swift b/packages/menubar/App/TokenleakUsage.swift deleted file mode 100644 index 36468a7..0000000 --- a/packages/menubar/App/TokenleakUsage.swift +++ /dev/null @@ -1,434 +0,0 @@ -import AppKit -import Foundation - -struct SnapshotWindow: Decodable { - let label: String - let usedPercent: Double? - let resetAt: String? - let windowMinutes: Int - let isStale: Bool -} - -struct SnapshotWindowGroup: Decodable { - let fiveHour: SnapshotWindow - let sevenDay: SnapshotWindow -} - -struct SnapshotProvider: Decodable { - let label: String - let shortLabel: String - let source: String - let state: String - let planType: String? - let lastUpdatedAt: String? - let message: String? - let windows: SnapshotWindowGroup -} - -struct SnapshotProviders: Decodable { - let codex: SnapshotProvider - let claudeCode: SnapshotProvider -} - -struct MenuBarSnapshot: Decodable { - let schemaVersion: Int - let generatedAt: String - let title: String - let providers: SnapshotProviders -} - -final class ProviderCardView: NSView { - init(provider: SnapshotProvider, accentColor: NSColor) { - super.init(frame: NSRect(x: 0, y: 0, width: 360, height: 150)) - translatesAutoresizingMaskIntoConstraints = false - wantsLayer = true - layer?.cornerRadius = 14 - layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.92).cgColor - layer?.borderWidth = 1 - layer?.borderColor = accentColor.withAlphaComponent(0.22).cgColor - - let container = NSStackView() - container.translatesAutoresizingMaskIntoConstraints = false - container.orientation = .vertical - container.spacing = 10 - addSubview(container) - - NSLayoutConstraint.activate([ - container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14), - container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), - container.topAnchor.constraint(equalTo: topAnchor, constant: 14), - container.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14), - widthAnchor.constraint(equalToConstant: 360), - ]) - - let header = NSStackView() - header.orientation = .horizontal - header.spacing = 8 - header.alignment = .centerY - - let title = ProviderCardView.makeLabel(provider.label, font: .boldSystemFont(ofSize: 14), color: .labelColor) - header.addArrangedSubview(title) - header.addArrangedSubview(NSView()) - - if let plan = provider.planType, !plan.isEmpty { - let badge = ProviderCardView.makeBadge(plan.uppercased(), color: accentColor) - header.addArrangedSubview(badge) - } - - container.addArrangedSubview(header) - container.addArrangedSubview(makeWindowRow(provider.windows.fiveHour, accentColor: accentColor)) - container.addArrangedSubview(makeWindowRow(provider.windows.sevenDay, accentColor: accentColor)) - - let footerText: String - if let message = provider.message, !message.isEmpty { - footerText = message - } else if let lastUpdatedAt = provider.lastUpdatedAt { - footerText = "Updated \(ProviderCardView.relativeDate(lastUpdatedAt))" - } else { - footerText = ProviderCardView.stateLabel(provider.state) - } - - let footer = ProviderCardView.makeLabel(footerText, font: .systemFont(ofSize: 11), color: .secondaryLabelColor) - footer.maximumNumberOfLines = 2 - container.addArrangedSubview(footer) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func makeWindowRow(_ window: SnapshotWindow, accentColor: NSColor) -> NSView { - let wrapper = NSStackView() - wrapper.orientation = .vertical - wrapper.spacing = 4 - - let topRow = NSStackView() - topRow.orientation = .horizontal - topRow.alignment = .centerY - - let label = ProviderCardView.makeLabel(window.label, font: .monospacedDigitSystemFont(ofSize: 12, weight: .semibold), color: .secondaryLabelColor) - let value = ProviderCardView.makeLabel( - ProviderCardView.percentLabel(window), - font: .monospacedDigitSystemFont(ofSize: 12, weight: .bold), - color: .labelColor - ) - - topRow.addArrangedSubview(label) - topRow.addArrangedSubview(NSView()) - topRow.addArrangedSubview(value) - - let progress = NSProgressIndicator() - progress.isIndeterminate = false - progress.minValue = 0 - progress.maxValue = 100 - progress.doubleValue = ProviderCardView.remainingPercent(window) ?? 0 - progress.style = .bar - - let reset = ProviderCardView.makeLabel( - ProviderCardView.resetLabel(window), - font: .systemFont(ofSize: 11), - color: .secondaryLabelColor - ) - - wrapper.addArrangedSubview(topRow) - wrapper.addArrangedSubview(progress) - wrapper.addArrangedSubview(reset) - return wrapper - } - - private static func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { - let label = NSTextField(labelWithString: text) - label.font = font - label.textColor = color - label.lineBreakMode = .byTruncatingTail - return label - } - - private static func makeBadge(_ text: String, color: NSColor) -> NSTextField { - let label = NSTextField(labelWithString: " \(text) ") - label.font = .systemFont(ofSize: 10, weight: .semibold) - label.textColor = color - label.alignment = .center - label.wantsLayer = true - label.layer?.cornerRadius = 8 - label.layer?.backgroundColor = color.withAlphaComponent(0.14).cgColor - return label - } - - private static func percentLabel(_ window: SnapshotWindow) -> String { - guard let remainingPercent = remainingPercent(window) else { - return "--" - } - return "\(Int(remainingPercent.rounded()))% left" - } - - private static func remainingPercent(_ window: SnapshotWindow) -> Double? { - guard let usedPercent = window.usedPercent, !window.isStale else { - return nil - } - - return max(0, min(100, 100 - usedPercent)) - } - - private static func resetLabel(_ window: SnapshotWindow) -> String { - if window.isStale { - return "Waiting for a fresh post-reset sample" - } - - guard let resetAt = window.resetAt else { - return "Reset time unavailable" - } - - return "Resets \(relativeDate(resetAt))" - } - - private static func relativeDate(_ iso: String) -> String { - let formatter = ISO8601DateFormatter() - guard let date = formatter.date(from: iso) else { - return iso - } - - let relative = RelativeDateTimeFormatter() - relative.unitsStyle = .short - return relative.localizedString(for: date, relativeTo: Date()) - } - - private static func stateLabel(_ state: String) -> String { - switch state { - case "setup_required": - return "Needs setup" - case "waiting_for_first_snapshot": - return "Waiting for first snapshot" - case "stale": - return "Snapshot is stale" - case "error": - return "Snapshot error" - default: - return "Ready" - } - } -} - -final class TokenleakUsageController: NSObject, NSApplicationDelegate { - private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - private let decoder = JSONDecoder() - private let homeDir: String - private let supportDir: String - private let snapshotPath: String - private let cliWrapperPath: String - private let dashboardWrapperPath: String - private let daemonLogPath: String - private var snapshot: MenuBarSnapshot? - private var timer: Timer? - private var daemonProcess: Process? - - init(homeDir: String) { - self.homeDir = homeDir - self.supportDir = "\(homeDir)/Library/Application Support/tokenleak/menubar" - self.snapshotPath = "\(supportDir)/snapshot.json" - self.cliWrapperPath = "\(supportDir)/tokenleak-menubar-cli" - self.dashboardWrapperPath = "\(supportDir)/tokenleak-menubar-dashboard" - self.daemonLogPath = "\(supportDir)/logs/daemon.log" - super.init() - } - - func applicationDidFinishLaunching(_ notification: Notification) { - statusItem.button?.title = "Cdx -- | Cld --" - startDaemonIfNeeded() - reloadSnapshot() - timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { [weak self] _ in - self?.startDaemonIfNeeded() - self?.reloadSnapshot() - } - if let timer = timer { - RunLoop.main.add(timer, forMode: .common) - } - } - - func applicationWillTerminate(_ notification: Notification) { - daemonProcess?.terminate() - } - - private func startDaemonIfNeeded() { - guard daemonProcess?.isRunning != true else { - return - } - - guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { - return - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: cliWrapperPath) - process.arguments = ["menubar", "daemon", "--home", homeDir] - - FileManager.default.createFile(atPath: daemonLogPath, contents: nil) - if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: daemonLogPath)) { - _ = try? handle.seekToEnd() - process.standardOutput = handle - process.standardError = handle - } - - do { - try process.run() - daemonProcess = process - } catch { - NSSound.beep() - } - } - - private func reloadSnapshot() { - let url = URL(fileURLWithPath: snapshotPath) - guard let data = try? Data(contentsOf: url) else { - snapshot = nil - renderTitle() - renderMenu() - return - } - - snapshot = try? decoder.decode(MenuBarSnapshot.self, from: data) - renderTitle() - renderMenu() - } - - private func renderTitle() { - let title = snapshot?.title ?? "Cdx -- | Cld --" - let attributed = NSAttributedString( - string: title, - attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .semibold)] - ) - statusItem.button?.attributedTitle = attributed - } - - private func renderMenu() { - let menu = NSMenu() - - if let snapshot = snapshot { - addHeader(title: snapshot.title, subtitle: "Live quota windows", to: menu) - menu.addItem(.separator()) - addProviderCard(snapshot.providers.codex, accentColor: .systemGreen, to: menu) - addProviderCard(snapshot.providers.claudeCode, accentColor: .systemOrange, to: menu) - } else { - let item = NSMenuItem(title: "Waiting for quota snapshot", action: nil, keyEquivalent: "") - item.isEnabled = false - menu.addItem(item) - } - - menu.addItem(.separator()) - menu.addItem(makeActionItem(title: "Refresh", action: #selector(refreshNow))) - menu.addItem(makeActionItem(title: "Open tokenleak dashboard", action: #selector(openDashboard))) - menu.addItem(makeActionItem(title: "Open menubar folder", action: #selector(openMenubarFolder))) - menu.addItem(.separator()) - menu.addItem(makeActionItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")) - statusItem.menu = menu - } - - private func addHeader(title: String, subtitle: String, to menu: NSMenu) { - let stack = NSStackView() - stack.orientation = .vertical - stack.spacing = 2 - stack.edgeInsets = NSEdgeInsets(top: 6, left: 14, bottom: 6, right: 14) - - let titleLabel = NSTextField(labelWithString: title) - titleLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .bold) - titleLabel.textColor = .labelColor - - let subtitleLabel = NSTextField(labelWithString: subtitle) - subtitleLabel.font = .systemFont(ofSize: 11) - subtitleLabel.textColor = .secondaryLabelColor - - stack.addArrangedSubview(titleLabel) - stack.addArrangedSubview(subtitleLabel) - - let view = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 46)) - stack.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(stack) - - NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), - stack.topAnchor.constraint(equalTo: view.topAnchor), - stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), - view.widthAnchor.constraint(equalToConstant: 360), - ]) - - let item = NSMenuItem() - item.view = view - menu.addItem(item) - } - - private func addProviderCard(_ provider: SnapshotProvider, accentColor: NSColor, to menu: NSMenu) { - let card = ProviderCardView(provider: provider, accentColor: accentColor) - let wrapper = NSView(frame: NSRect(x: 0, y: 0, width: 376, height: 164)) - card.translatesAutoresizingMaskIntoConstraints = false - wrapper.addSubview(card) - - NSLayoutConstraint.activate([ - card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor, constant: 8), - card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor, constant: -8), - card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 6), - card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor, constant: -6), - wrapper.widthAnchor.constraint(equalToConstant: 376), - ]) - - let item = NSMenuItem() - item.view = wrapper - menu.addItem(item) - } - - private func makeActionItem(title: String, action: Selector, keyEquivalent: String = "") -> NSMenuItem { - let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) - item.target = self - return item - } - - @objc private func refreshNow() { - runCliCommand(["menubar", "refresh", "--home", homeDir]) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in - self?.reloadSnapshot() - } - } - - @objc private func openDashboard() { - guard FileManager.default.isExecutableFile(atPath: dashboardWrapperPath) else { - NSSound.beep() - return - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/open") - process.arguments = ["-a", "Terminal", dashboardWrapperPath] - try? process.run() - } - - @objc private func openMenubarFolder() { - NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: supportDir)]) - } - - @objc private func quitApp() { - NSApplication.shared.terminate(nil) - } - - private func runCliCommand(_ arguments: [String]) { - guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { - NSSound.beep() - return - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: cliWrapperPath) - process.arguments = arguments - do { - try process.run() - } catch { - NSSound.beep() - } - } -} - -let home = ProcessInfo.processInfo.environment["TOKENLEAK_MENUBAR_HOME"] ?? NSHomeDirectory() -let app = NSApplication.shared -let delegate = TokenleakUsageController(homeDir: home) -app.setActivationPolicy(.accessory) -app.delegate = delegate -app.run() diff --git a/packages/menubar/App/Utilities/KeychainHelper.swift b/packages/menubar/App/Utilities/KeychainHelper.swift new file mode 100644 index 0000000..112ab2f --- /dev/null +++ b/packages/menubar/App/Utilities/KeychainHelper.swift @@ -0,0 +1,55 @@ +import Foundation +import Security + +enum KeychainHelper { + static func save(value: Data, service: String, account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + + SecItemDelete(query as CFDictionary) + + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: value, + ] + + let status = SecItemAdd(attributes as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func load(service: String, account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + return item as? Data + } + + static func delete(service: String, account: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/packages/menubar/App/Utilities/Theme.swift b/packages/menubar/App/Utilities/Theme.swift new file mode 100644 index 0000000..8ba3250 --- /dev/null +++ b/packages/menubar/App/Utilities/Theme.swift @@ -0,0 +1,56 @@ +import AppKit +import SwiftUI + +enum AppTheme { + static let backgroundTint = Color(hex: 0x0A0A0F, opacity: 0.78) + static let backgroundOverlay = Color.black.opacity(0.18) + static let panelEdge = Color.white.opacity(0.10) + static let cardFill = Color.white.opacity(0.04) + static let cardFillHover = Color.white.opacity(0.07) + static let divider = Color.white.opacity(0.06) + static let separatorGradient = LinearGradient( + colors: [Color.white.opacity(0), Color.white.opacity(0.08), Color.white.opacity(0)], + startPoint: .leading, + endPoint: .trailing + ) + static let textPrimary = Color(hex: 0xF0EEFF) + static let textSecondary = Color(hex: 0xF0EEFF, opacity: 0.45) + static let textTertiary = Color(hex: 0xF0EEFF, opacity: 0.28) + static let statusHealthy = NSColor(hex: 0x5ED486) + static let statusWarning = NSColor(hex: 0xF4B657) + static let statusCritical = NSColor(hex: 0xFF5C74) + static let statusNeutral = NSColor(hex: 0xB6B1CC) +} + +extension UsageProviderKind { + var gradient: [Color] { + switch self { + case .claude: + return [Color(hex: 0xD97757), Color(hex: 0xE8996A)] + case .codex: + return [Color(hex: 0x10A37F), Color(hex: 0x6FCF97)] + } + } + + var markColor: Color { + gradient.first ?? .white + } +} + +extension Color { + init(hex: UInt64, opacity: Double = 1) { + let red = Double((hex >> 16) & 0xFF) / 255 + let green = Double((hex >> 8) & 0xFF) / 255 + let blue = Double(hex & 0xFF) / 255 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: opacity) + } +} + +extension NSColor { + convenience init(hex: UInt64, alpha: CGFloat = 1) { + let red = CGFloat((hex >> 16) & 0xFF) / 255 + let green = CGFloat((hex >> 8) & 0xFF) / 255 + let blue = CGFloat(hex & 0xFF) / 255 + self.init(srgbRed: red, green: green, blue: blue, alpha: alpha) + } +} diff --git a/packages/menubar/App/Utilities/TimerManager.swift b/packages/menubar/App/Utilities/TimerManager.swift new file mode 100644 index 0000000..ef56294 --- /dev/null +++ b/packages/menubar/App/Utilities/TimerManager.swift @@ -0,0 +1,28 @@ +import Foundation + +final class TimerManager { + private var timer: Timer? + + func start(every interval: TimeInterval, fireImmediately: Bool = false, action: @escaping () -> Void) { + stop() + + if fireImmediately { + action() + } + + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + action() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stop() { + timer?.invalidate() + timer = nil + } + + deinit { + stop() + } +} diff --git a/packages/menubar/App/ViewModels/UsageViewModel.swift b/packages/menubar/App/ViewModels/UsageViewModel.swift new file mode 100644 index 0000000..93f3198 --- /dev/null +++ b/packages/menubar/App/ViewModels/UsageViewModel.swift @@ -0,0 +1,155 @@ +import AppKit +import Foundation +import SwiftUI + +@MainActor +final class UsageViewModel: ObservableObject { + @Published private(set) var claudeUsage: UsageCardState + @Published private(set) var codexUsage: UsageCardState + @Published private(set) var lastUpdatedText: String + @Published private(set) var lastUpdatedAt: Date? + @Published private(set) var isRefreshing: Bool + + let homeDirectory: String + let supportDirectory: String + let snapshotPath: String + let cliWrapperPath: String + let dashboardWrapperPath: String + + private let services: [UsageService] + private let decoder = JSONDecoder() + + init( + homeDirectory: String, + supportDirectory: String, + snapshotPath: String, + cliWrapperPath: String, + dashboardWrapperPath: String, + services: [UsageService] = [ClaudeUsageService(), CodexUsageService()] + ) { + self.homeDirectory = homeDirectory + self.supportDirectory = supportDirectory + self.snapshotPath = snapshotPath + self.cliWrapperPath = cliWrapperPath + self.dashboardWrapperPath = dashboardWrapperPath + self.services = services + + self.claudeUsage = ClaudeUsageService().resolveUsage(from: nil) + self.codexUsage = CodexUsageService().resolveUsage(from: nil) + self.lastUpdatedText = "Waiting for first refresh" + self.isRefreshing = false + } + + var cards: [UsageCardState] { + [claudeUsage, codexUsage] + } + + var statusTint: NSColor { + guard let value = lowestRemainingPercent else { + return AppTheme.statusNeutral + } + if value < 20 { + return AppTheme.statusCritical + } + if value < 50 { + return AppTheme.statusWarning + } + return AppTheme.statusHealthy + } + + var statusLabel: String? { + guard let value = lowestRemainingPercent else { + return nil + } + return "\(Int(value.rounded()))%" + } + + private var lowestRemainingPercent: Double? { + cards.compactMap(\.displayPercent).min() + } + + func reloadSnapshot(animated: Bool = true) { + let snapshot = loadSnapshot() + let updatedClaude = services.first(where: { $0.kind == .claude })?.resolveUsage(from: snapshot) ?? ClaudeUsageService().resolveUsage(from: snapshot) + let updatedCodex = services.first(where: { $0.kind == .codex })?.resolveUsage(from: snapshot) ?? CodexUsageService().resolveUsage(from: snapshot) + let generatedAt = parseSnapshotDate(snapshot?.generatedAt) + + let updateState = { + self.claudeUsage = updatedClaude + self.codexUsage = updatedCodex + self.lastUpdatedAt = generatedAt + self.lastUpdatedText = self.makeLastUpdatedText(from: generatedAt) + } + + if animated { + withAnimation(.spring(response: 0.35, dampingFraction: 0.78)) { + updateState() + } + } else { + updateState() + } + } + + func refresh() { + guard !isRefreshing else { + return + } + + isRefreshing = true + let cliWrapperPath = self.cliWrapperPath + let homeDirectory = self.homeDirectory + + Task.detached(priority: .userInitiated) { + guard FileManager.default.isExecutableFile(atPath: cliWrapperPath) else { + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: cliWrapperPath) + process.arguments = ["menubar", "refresh", "--home", homeDirectory] + try? process.run() + process.waitUntilExit() + } + + Task { + try? await Task.sleep(for: .milliseconds(500)) + self.reloadSnapshot() + self.isRefreshing = false + } + } + + func openSupportFolder() { + NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: supportDirectory)]) + } + + func openDashboard() { + guard FileManager.default.isExecutableFile(atPath: dashboardWrapperPath) else { + NSSound.beep() + return + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = ["-a", "Terminal", dashboardWrapperPath] + try? process.run() + } + + private func loadSnapshot() -> MenuBarSnapshot? { + let url = URL(fileURLWithPath: snapshotPath) + guard let data = try? Data(contentsOf: url) else { + return nil + } + + return try? decoder.decode(MenuBarSnapshot.self, from: data) + } + + private func makeLastUpdatedText(from date: Date?) -> String { + guard let date else { + return "Waiting for first refresh" + } + + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return "Last updated: \(formatter.localizedString(for: date, relativeTo: Date()))" + } +} diff --git a/packages/menubar/App/Views/ArcProgressRing.swift b/packages/menubar/App/Views/ArcProgressRing.swift new file mode 100644 index 0000000..e41b57f --- /dev/null +++ b/packages/menubar/App/Views/ArcProgressRing.swift @@ -0,0 +1,77 @@ +import SwiftUI + +struct ArcProgressRing: View { + let progress: Double + let gradient: [Color] + let critical: Bool + + @State private var animatedProgress: Double = 0 + @State private var glow = false + + var body: some View { + ZStack { + ArcTrackShape(progress: 1) + .stroke( + Color.white.opacity(0.08), + style: StrokeStyle(lineWidth: 7, lineCap: .round) + ) + + ArcTrackShape(progress: animatedProgress) + .stroke( + AngularGradient( + colors: gradient, + center: .center, + startAngle: .degrees(150), + endAngle: .degrees(390) + ), + style: StrokeStyle(lineWidth: 7, lineCap: .round) + ) + .shadow( + color: critical + ? Color.red.opacity(glow ? 0.35 : 0.14) + : (gradient.last ?? .white).opacity(0.34), + radius: critical ? (glow ? 12 : 6) : 8 + ) + } + .padding(6) + .onAppear { + withAnimation(.easeOut(duration: 0.7)) { + animatedProgress = progress + } + + guard critical else { + return + } + + withAnimation(.easeInOut(duration: 1.4).repeatForever(autoreverses: true)) { + glow.toggle() + } + } + .onChange(of: progress) { _, newValue in + withAnimation(.easeOut(duration: 0.7)) { + animatedProgress = newValue + } + } + } +} + +private struct ArcTrackShape: Shape { + let progress: Double + + func path(in rect: CGRect) -> Path { + var path = Path() + let radius = min(rect.width, rect.height) / 2 + let center = CGPoint(x: rect.midX, y: rect.midY) + let startAngle = Angle.degrees(150) + let endAngle = Angle.degrees(150 + (240 * max(0, min(1, progress)))) + + path.addArc( + center: center, + radius: radius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: false + ) + return path + } +} diff --git a/packages/menubar/App/Views/PopoverContentView.swift b/packages/menubar/App/Views/PopoverContentView.swift new file mode 100644 index 0000000..41de0bd --- /dev/null +++ b/packages/menubar/App/Views/PopoverContentView.swift @@ -0,0 +1,115 @@ +import AppKit +import SwiftUI + +struct PopoverContentView: View { + @ObservedObject var viewModel: UsageViewModel + let onOpenSettings: () -> Void + + @State private var didAppear = false + + var body: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + .overlay(AppTheme.backgroundTint) + .overlay(AppTheme.backgroundOverlay) + + VStack(alignment: .leading, spacing: 14) { + header + + Rectangle() + .fill(AppTheme.separatorGradient) + .frame(height: 0.5) + + VStack(spacing: 10) { + UsageCard(card: viewModel.claudeUsage) + UsageCard(card: viewModel.codexUsage) + } + + Text(viewModel.lastUpdatedText) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.textSecondary) + .padding(.horizontal, 2) + } + .padding(16) + } + .frame(width: 360) + .opacity(didAppear ? 1 : 0) + .scaleEffect(didAppear ? 1 : 0.92, anchor: .topTrailing) + .onAppear { + withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + didAppear = true + } + } + .onDisappear { + didAppear = false + } + } + + private var header: some View { + HStack(spacing: 12) { + MenuBarGlyph(color: Color(nsColor: viewModel.statusTint)) + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 2) { + Text("LLM Usage") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(AppTheme.textPrimary) + Text("Codex and Claude Code quotas") + .font(.system(size: 11)) + .foregroundStyle(AppTheme.textSecondary) + } + + Spacer() + + Button { + viewModel.refresh() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.textTertiary) + .frame(width: 26, height: 26) + .contentShape(Circle()) + } + .buttonStyle(.plain) + + Button { + onOpenSettings() + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(AppTheme.textTertiary) + .frame(width: 26, height: 26) + .contentShape(Circle()) + } + .buttonStyle(.plain) + } + } +} + +struct VisualEffectBlur: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} + +struct MenuBarGlyph: View { + let color: Color + + var body: some View { + Image(systemName: "gauge.with.dots.needle.33percent") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(color) + } +} diff --git a/packages/menubar/App/Views/UsageCard.swift b/packages/menubar/App/Views/UsageCard.swift new file mode 100644 index 0000000..3993d9b --- /dev/null +++ b/packages/menubar/App/Views/UsageCard.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct UsageCard: View { + let card: UsageCardState + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + headerRow + + ForEach(card.windows) { window in + WindowRow(window: window, gradient: card.kind.gradient) + } + + Text(card.footerText) + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(AppTheme.textTertiary) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(AppTheme.cardFill) + ) + } + + private var headerRow: some View { + HStack(alignment: .center, spacing: 8) { + ServiceGlyph(kind: card.kind) + .frame(width: 22, height: 22) + + Text(card.serviceName) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AppTheme.textPrimary) + .lineLimit(1) + + Spacer() + + Text(card.headerDetail) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(AppTheme.textTertiary) + .lineLimit(1) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule() + .fill(Color.white.opacity(0.06)) + ) + } + } +} + +private struct WindowRow: View { + let window: UsageWindowState + let gradient: [Color] + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline) { + Text(window.label) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(AppTheme.textTertiary) + + Spacer() + + if let pct = window.remainingPercent { + Text("\(Int(pct.rounded()))%") + .font(.system(size: 15, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(AppTheme.textPrimary) + } else { + Text("--%") + .font(.system(size: 15, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(AppTheme.textTertiary) + } + } + + UsageProgressBar(progress: window.progress, gradient: gradient) + .frame(height: 5) + } + } +} + +private struct UsageProgressBar: View { + let progress: Double + let gradient: [Color] + + @State private var animatedProgress: Double = 0 + + var body: some View { + GeometryReader { geometry in + let fillWidth = geometry.size.width * CGFloat(max(0, min(1, animatedProgress))) + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.06)) + + Capsule() + .fill( + LinearGradient( + colors: gradient, + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: max(fillWidth, fillWidth > 0 ? 4 : 0)) + } + } + .onAppear { + withAnimation(.easeOut(duration: 0.6)) { + animatedProgress = progress + } + } + .onChange(of: progress) { _, newValue in + withAnimation(.easeOut(duration: 0.5)) { + animatedProgress = newValue + } + } + } +} + +private struct ServiceGlyph: View { + let kind: UsageProviderKind + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill( + LinearGradient( + colors: kind.gradient.map { $0.opacity(0.25) }, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + if kind == .claude { + ZStack { + Circle() + .stroke(kind.markColor, lineWidth: 1.4) + Circle() + .trim(from: 0.05, to: 0.68) + .stroke(kind.markColor.opacity(0.45), style: StrokeStyle(lineWidth: 1.4, lineCap: .round)) + .rotationEffect(.degrees(-40)) + } + .padding(4) + } else { + RoundedRectangle(cornerRadius: 3, style: .continuous) + .stroke(kind.markColor, lineWidth: 1.4) + .overlay { + VStack(spacing: 2) { + Capsule().fill(kind.markColor).frame(width: 6, height: 1.5) + Capsule().fill(kind.markColor).frame(width: 6, height: 1.5) + } + } + .padding(4) + } + } + } +} diff --git a/scripts/build-menubar-app.ts b/scripts/build-menubar-app.ts index 5279693..86132e0 100644 --- a/scripts/build-menubar-app.ts +++ b/scripts/build-menubar-app.ts @@ -1,9 +1,9 @@ #!/usr/bin/env bun -import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; const rootDir = resolve(import.meta.dir, '..'); -const sourceFile = join(rootDir, 'packages', 'menubar', 'App', 'TokenleakUsage.swift'); +const sourceDir = join(rootDir, 'packages', 'menubar', 'App'); const outputApp = join(rootDir, 'packages', 'menubar', 'dist', 'Tokenleak Usage.app'); const outputExecutable = join(outputApp, 'Contents', 'MacOS', 'Tokenleak Usage'); const infoPlist = join(outputApp, 'Contents', 'Info.plist'); @@ -11,13 +11,32 @@ const cliPackage = (await Bun.file(join(rootDir, 'packages', 'cli', 'package.jso version: string; }; +function collectSwiftFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + return entries + .flatMap((entry) => { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + return collectSwiftFiles(fullPath); + } + return entry.name.endsWith('.swift') ? [fullPath] : []; + }) + .sort(); +} + if (process.platform !== 'darwin') { console.log('Skipping menubar app build on non-macOS host.'); process.exit(0); } -if (!existsSync(sourceFile)) { - console.error(`Missing source file: ${sourceFile}`); +if (!existsSync(sourceDir)) { + console.error(`Missing source directory: ${sourceDir}`); + process.exit(1); +} + +const sourceFiles = collectSwiftFiles(sourceDir); +if (sourceFiles.length === 0) { + console.error(`No Swift sources found in: ${sourceDir}`); process.exit(1); } @@ -27,13 +46,17 @@ mkdirSync(dirname(outputExecutable), { recursive: true }); const compile = Bun.spawnSync( [ '/usr/bin/swiftc', - sourceFile, + ...sourceFiles, '-o', outputExecutable, '-framework', 'AppKit', '-framework', 'Foundation', + '-framework', + 'SwiftUI', + '-framework', + 'Security', ], { cwd: rootDir, diff --git a/scripts/check-menubar-app.ts b/scripts/check-menubar-app.ts index 81d2582..5ea0edc 100644 --- a/scripts/check-menubar-app.ts +++ b/scripts/check-menubar-app.ts @@ -1,22 +1,53 @@ #!/usr/bin/env bun -import { existsSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; const rootDir = resolve(import.meta.dir, '..'); -const sourceFile = join(rootDir, 'packages', 'menubar', 'App', 'TokenleakUsage.swift'); +const sourceDir = join(rootDir, 'packages', 'menubar', 'App'); + +function collectSwiftFiles(dir: string): string[] { + const entries = readdirSync(dir, { withFileTypes: true }); + return entries + .flatMap((entry) => { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + return collectSwiftFiles(fullPath); + } + return entry.name.endsWith('.swift') ? [fullPath] : []; + }) + .sort(); +} if (process.platform !== 'darwin') { console.log('Skipping menubar app checks on non-macOS host.'); process.exit(0); } -if (!existsSync(sourceFile)) { - console.error(`Missing source file: ${sourceFile}`); +if (!existsSync(sourceDir)) { + console.error(`Missing source directory: ${sourceDir}`); + process.exit(1); +} + +const sourceFiles = collectSwiftFiles(sourceDir); +if (sourceFiles.length === 0) { + console.error(`No Swift sources found in: ${sourceDir}`); process.exit(1); } const proc = Bun.spawnSync( - ['/usr/bin/swiftc', '-typecheck', sourceFile, '-framework', 'AppKit', '-framework', 'Foundation'], + [ + '/usr/bin/swiftc', + '-typecheck', + ...sourceFiles, + '-framework', + 'AppKit', + '-framework', + 'Foundation', + '-framework', + 'SwiftUI', + '-framework', + 'Security', + ], { cwd: rootDir, stdout: 'inherit', From 67d1569329b6434d55f3875fddafb86a34899d0e Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 28 Mar 2026 19:06:46 +0530 Subject: [PATCH 3/5] fix: handle epoch timestamps and broaden Claude statusline parser - Add toResetAtIso() to convert Unix epoch seconds to ISO strings (Claude Code sends resets_at as numbers, not strings) - Add used_percent/reset_at field name variants for flexibility - Add primary/secondary fallback (Codex-style naming) - Add top-level five_hour/seven_day fallback - Add debug logging to diagnose statusline hook data --- packages/cli/src/menubar/claude-statusline.ts | 82 +++++++++++++++---- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/menubar/claude-statusline.ts b/packages/cli/src/menubar/claude-statusline.ts index 8fc6e9c..d0ef21b 100644 --- a/packages/cli/src/menubar/claude-statusline.ts +++ b/packages/cli/src/menubar/claude-statusline.ts @@ -1,15 +1,27 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import type { MenubarPaths } from './types.js'; import { MENUBAR_SCHEMA_VERSION, type ClaudeBridgeSnapshot, type StoredQuotaWindow } from './types.js'; import { writeClaudeBridgeSnapshot } from './state.js'; +function toResetAtIso(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value * 1000).toISOString(); + } + return null; +} + function parseWindow(value: unknown, fallbackMinutes: number): StoredQuotaWindow | null { if (typeof value !== 'object' || value === null) { return null; } const record = value as Record; - const usedPercent = record['used_percentage'] ?? record['usedPercent']; - const resetAt = record['resets_at'] ?? record['resetAt']; + const usedPercent = record['used_percentage'] ?? record['usedPercent'] ?? record['used_percent']; + const resetAt = record['resets_at'] ?? record['resetAt'] ?? record['reset_at']; const windowMinutes = record['window_minutes'] ?? record['windowMinutes'] ?? fallbackMinutes; if (typeof usedPercent !== 'number') { @@ -19,7 +31,7 @@ function parseWindow(value: unknown, fallbackMinutes: number): StoredQuotaWindow return { usedPercent, windowMinutes: typeof windowMinutes === 'number' ? windowMinutes : fallbackMinutes, - resetAt: typeof resetAt === 'string' ? resetAt : null, + resetAt: toResetAtIso(resetAt), }; } @@ -56,46 +68,84 @@ async function readStdinText(): Promise { return Buffer.concat(chunks).toString('utf8'); } +function debugLog(paths: MenubarPaths, message: string): void { + try { + const logPath = join(paths.logsDir, 'claude-statusline-debug.log'); + mkdirSync(dirname(logPath), { recursive: true }); + const timestamp = new Date().toISOString(); + appendFileSync(logPath, `[${timestamp}] ${message}\n`); + } catch { + // best-effort logging + } +} + +function extractRateLimits(root: Record): { + fiveHour: StoredQuotaWindow | null; + sevenDay: StoredQuotaWindow | null; +} | null { + // Try standard structure: { rate_limits: { five_hour: ..., seven_day: ... } } + const rateLimits = root['rate_limits'] ?? root['rateLimits']; + if (typeof rateLimits === 'object' && rateLimits !== null) { + const rl = rateLimits as Record; + const fiveHour = parseWindow(rl['five_hour'] ?? rl['fiveHour'], 300); + const sevenDay = parseWindow(rl['seven_day'] ?? rl['sevenDay'], 10080); + // Also try primary/secondary (Codex-style naming) + const primary = fiveHour ?? parseWindow(rl['primary'], 300); + const secondary = sevenDay ?? parseWindow(rl['secondary'], 10080); + if (primary || secondary) { + return { fiveHour: primary, sevenDay: secondary }; + } + } + + // Try top-level windows: { five_hour: ..., seven_day: ... } + const topFive = parseWindow(root['five_hour'] ?? root['fiveHour'], 300); + const topSeven = parseWindow(root['seven_day'] ?? root['sevenDay'], 10080); + if (topFive || topSeven) { + return { fiveHour: topFive, sevenDay: topSeven }; + } + + return null; +} + export async function recordClaudeStatuslineSnapshot(paths: MenubarPaths): Promise { const input = (await readStdinText()).trim(); if (!input) { + debugLog(paths, 'Empty stdin — no data received'); return false; } + debugLog(paths, `Received ${input.length} bytes: ${input.slice(0, 500)}`); + let parsed: unknown; try { parsed = JSON.parse(input); } catch { + debugLog(paths, `JSON parse error for input: ${input.slice(0, 200)}`); return false; } if (typeof parsed !== 'object' || parsed === null) { + debugLog(paths, 'Parsed value is not an object'); return false; } const root = parsed as Record; - const rateLimits = root['rate_limits'] ?? root['rateLimits']; - if (typeof rateLimits !== 'object' || rateLimits === null) { - return false; - } - - const rateLimitsRecord = rateLimits as Record; - const fiveHour = - parseWindow(rateLimitsRecord['five_hour'] ?? rateLimitsRecord['fiveHour'], 300); - const sevenDay = - parseWindow(rateLimitsRecord['seven_day'] ?? rateLimitsRecord['sevenDay'], 10080); + const result = extractRateLimits(root); - if (!fiveHour && !sevenDay) { + if (!result || (!result.fiveHour && !result.sevenDay)) { + debugLog(paths, `No rate limit windows found in keys: ${Object.keys(root).join(', ')}`); return false; } + debugLog(paths, `Parsed OK — 5h: ${result.fiveHour?.usedPercent ?? '--'}%, 7d: ${result.sevenDay?.usedPercent ?? '--'}%`); + const snapshot: ClaudeBridgeSnapshot = { schemaVersion: MENUBAR_SCHEMA_VERSION, source: 'claude-statusline', capturedAt: new Date().toISOString(), planType: resolvePlanType(root), - fiveHour, - sevenDay, + fiveHour: result.fiveHour, + sevenDay: result.sevenDay, }; writeClaudeBridgeSnapshot(paths, snapshot); return true; From d07b21bc5a941f0b998fae5b1021caff7533093e Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 28 Mar 2026 20:32:47 +0530 Subject: [PATCH 4/5] fix: rewrite Claude Code rate limit collection for menu bar Replace the fragile 3-script statusline wrapper chain with a single self-contained bridge script that uses /usr/bin/python3 to extract rate_limits from Claude Code's statusline JSON. Key improvements: - No tokenleak CLI binary spawned in the hot path (zero latency) - Rate limit extraction runs in background, user statusline renders instantly - Self-healing daemon: auto-repairs ~/.claude/settings.json when statusLine is overwritten by the user or Claude Code - Bridge version tracking: daemon auto-upgrades old bridge scripts - New claude-rate-limits.ts reader in registry (symmetric with Codex) - Atomic write pattern prevents partial reads by the Swift app Fixes Claude Code showing "Cld --" in the menu bar while Codex stats displayed correctly. --- packages/cli/src/menubar/command.ts | 3 + packages/cli/src/menubar/install.ts | 33 +--- packages/cli/src/menubar/launchd.test.ts | 29 +++- packages/cli/src/menubar/launchd.ts | 102 ++++++++++- packages/cli/src/menubar/state.test.ts | 119 ++++++++++++- packages/cli/src/menubar/state.ts | 117 +++++++++++-- packages/cli/src/menubar/types.ts | 3 + packages/registry/src/index.ts | 3 +- .../src/providers/claude-rate-limits.test.ts | 159 ++++++++++++++++++ .../src/providers/claude-rate-limits.ts | 107 ++++++++++++ packages/registry/src/providers/index.ts | 2 + 11 files changed, 620 insertions(+), 57 deletions(-) create mode 100644 packages/registry/src/providers/claude-rate-limits.test.ts create mode 100644 packages/registry/src/providers/claude-rate-limits.ts diff --git a/packages/cli/src/menubar/command.ts b/packages/cli/src/menubar/command.ts index 38fa2c7..40f4b9d 100644 --- a/packages/cli/src/menubar/command.ts +++ b/packages/cli/src/menubar/command.ts @@ -14,6 +14,7 @@ import { import { resolveMenubarPaths } from './paths.js'; import { createDefaultMenubarConfig, + ensureClaudeStatusLineConfig, readMenubarConfig, refreshMenubarSnapshot, writeMenubarConfig, @@ -106,6 +107,8 @@ async function runDaemon(parsed: ParsedMenubarArgs): Promise { const { paths, config } = resolveCommandConfig(parsed); const tick = async () => { + // Self-healing: repair statusline settings if overwritten + ensureClaudeStatusLineConfig(paths, readMenubarConfig(paths)); const snapshot = await refreshMenubarSnapshot(paths); process.stdout.write(`[menubar] ${formatTimestamp(snapshot.generatedAt)} ${snapshot.title}\n`); }; diff --git a/packages/cli/src/menubar/install.ts b/packages/cli/src/menubar/install.ts index 2d5f23d..dd5677a 100644 --- a/packages/cli/src/menubar/install.ts +++ b/packages/cli/src/menubar/install.ts @@ -13,7 +13,7 @@ import { formatPercentLeft, formatTimestamp } from './format.js'; import { buildAppPlist, buildCliWrapper, - buildClaudeStatuslineWrapper, + buildClaudeStatuslineBridge, buildDashboardWrapper, buildOriginalClaudeStatuslineCommandScript, } from './launchd.js'; @@ -22,18 +22,15 @@ import { clearMenubarState, createDefaultMenubarConfig, ensureMenubarDir, + isManagedClaudeStatusLineSetting, readMenubarConfig, readSnapshot, writeExecutableScript, writeMenubarConfig, } from './state.js'; +import { CURRENT_BRIDGE_VERSION } from './types.js'; import type { MenubarConfig, MenubarPaths } from './types.js'; -interface CommandStatusLine { - type: 'command'; - command: string; -} - const LEGACY_APP_LABEL = 'com.tokenleak.menubar.app'; const LEGACY_SERVICE_LABEL = 'com.tokenleak.menubar.service'; const LEGACY_APP_NAME = 'Tokenleak Menu.app'; @@ -99,14 +96,7 @@ function writeClaudeSettings(paths: MenubarPaths, settings: Record { @@ -17,9 +17,28 @@ describe('menubar launchd and wrapper generation', () => { expect(wrapper).toContain('--provider codex,claude-code'); }); - it('builds a Claude statusline wrapper that records bridge snapshots', () => { - const wrapper = buildClaudeStatuslineWrapper(paths); - expect(wrapper).toContain('menubar claude-statusline'); - expect(wrapper).toContain('claude-statusline-original'); + it('builds a self-contained Claude statusline bridge using python3', () => { + const bridge = buildClaudeStatuslineBridge(paths); + expect(bridge).toContain('/usr/bin/python3'); + expect(bridge).toContain('claude-rate-limits.json'); + expect(bridge).toContain('claude-statusline-original'); + // Must NOT spawn the tokenleak CLI binary + expect(bridge).not.toContain('menubar claude-statusline'); + expect(bridge).not.toContain('tokenleak-menubar-cli'); + }); + + it('bridge script extracts rate_limits fields', () => { + const bridge = buildClaudeStatuslineBridge(paths); + expect(bridge).toContain('rate_limits'); + expect(bridge).toContain('five_hour'); + expect(bridge).toContain('seven_day'); + expect(bridge).toContain('used_percentage'); + expect(bridge).toContain('resets_at'); + }); + + it('bridge script performs atomic write', () => { + const bridge = buildClaudeStatuslineBridge(paths); + expect(bridge).toContain('os.rename'); + expect(bridge).toContain('mkstemp'); }); }); diff --git a/packages/cli/src/menubar/launchd.ts b/packages/cli/src/menubar/launchd.ts index 23cc7ef..ae7b463 100644 --- a/packages/cli/src/menubar/launchd.ts +++ b/packages/cli/src/menubar/launchd.ts @@ -60,18 +60,102 @@ exec ${shellQuote(paths.cliWrapperPath)} --provider codex,claude-code "$@" `; } -export function buildClaudeStatuslineWrapper(paths: MenubarPaths): string { +export function buildClaudeStatuslineBridge(paths: MenubarPaths): string { + const snapshotPath = shellQuote(paths.claudeSnapshotPath); + const originalCmd = shellQuote(paths.previousClaudeStatuslineCommandPath); + + // Self-contained bridge: uses /usr/bin/python3 (pre-installed on macOS 12.3+) + // to extract rate_limits from Claude Code's statusline JSON and atomic-write + // the snapshot file. The python3 process runs in the background so the user's + // original statusline command renders with zero added latency. return `#!/bin/zsh set -u -tmp_file=$(mktemp "\${TMPDIR:-/tmp}/tokenleak-claude-statusline.XXXXXX") -cleanup() { - rm -f "$tmp_file" -} -trap cleanup EXIT +SNAPSHOT_PATH=${snapshotPath} +ORIGINAL_CMD=${originalCmd} + +tmp_file=$(mktemp "\${TMPDIR:-/tmp}/tl-claude-sl.XXXXXX") cat > "$tmp_file" -${shellQuote(paths.cliWrapperPath)} menubar claude-statusline --home ${shellQuote(paths.homeDir)} < "$tmp_file" >/dev/null 2>/dev/null || true -if [ -x ${shellQuote(paths.previousClaudeStatuslineCommandPath)} ]; then - exec ${shellQuote(paths.previousClaudeStatuslineCommandPath)} < "$tmp_file" + +# Background: extract rate_limits and write snapshot atomically +(/usr/bin/python3 -c ' +import json, sys, os, tempfile +from datetime import datetime, timezone + +snap_path = sys.argv[1] +input_path = sys.argv[2] + +try: + with open(input_path) as f: + data = json.load(f) +except Exception: + sys.exit(0) + +rl = data.get("rate_limits") or data.get("rateLimits") +if not isinstance(rl, dict): + sys.exit(0) + +def parse_window(w, fallback_min): + if not isinstance(w, dict): + return None + pct = w.get("used_percentage") or w.get("usedPercent") or w.get("used_percent") + if not isinstance(pct, (int, float)): + return None + reset = w.get("resets_at") or w.get("resetAt") or w.get("reset_at") + reset_iso = None + if isinstance(reset, (int, float)) and reset > 0: + reset_iso = datetime.fromtimestamp(reset, tz=timezone.utc).isoformat().replace("+00:00", "Z") + elif isinstance(reset, str) and reset: + reset_iso = reset + wm = w.get("window_minutes") or w.get("windowMinutes") or fallback_min + return {"usedPercent": pct, "windowMinutes": wm if isinstance(wm, int) else fallback_min, "resetAt": reset_iso} + +five = parse_window(rl.get("five_hour") or rl.get("fiveHour"), 300) +seven = parse_window(rl.get("seven_day") or rl.get("sevenDay"), 10080) + +if not five and not seven: + sys.exit(0) + +plan = None +for k in ("subscription_type", "subscriptionType", "plan_type"): + v = data.get(k) + if isinstance(v, str) and v.strip(): + plan = v.strip() + break +if plan is None: + acct = data.get("account") + if isinstance(acct, dict): + v = acct.get("subscription_type") + if isinstance(v, str) and v.strip(): + plan = v.strip() + +snapshot = { + "schemaVersion": 1, + "source": "claude-statusline", + "capturedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "planType": plan, + "fiveHour": five, + "sevenDay": seven, +} + +snap_dir = os.path.dirname(snap_path) +os.makedirs(snap_dir, exist_ok=True) +fd, tmp = tempfile.mkstemp(dir=snap_dir, suffix=".tmp") +try: + with os.fdopen(fd, "w") as out: + json.dump(snapshot, out, indent=2) + out.write("\\n") + os.rename(tmp, snap_path) +except Exception: + try: + os.unlink(tmp) + except Exception: + pass +' "$SNAPSHOT_PATH" "$tmp_file" +rm -f "$tmp_file") 2>/dev/null & + +# Forward to user's original statusline command immediately (no delay) +if [ -x "$ORIGINAL_CMD" ]; then + exec "$ORIGINAL_CMD" < "$tmp_file" fi exit 0 `; diff --git a/packages/cli/src/menubar/state.test.ts b/packages/cli/src/menubar/state.test.ts index d263ecb..b6f09a1 100644 --- a/packages/cli/src/menubar/state.test.ts +++ b/packages/cli/src/menubar/state.test.ts @@ -1,14 +1,16 @@ import { afterEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { resolveMenubarPaths } from './paths'; import { createDefaultMenubarConfig, + ensureClaudeStatusLineConfig, refreshMenubarSnapshot, writeClaudeBridgeSnapshot, writeMenubarConfig, } from './state'; +import { CURRENT_BRIDGE_VERSION } from './types'; function writeSession(root: string, relativePath: string, line: Record): void { const fullPath = join(root, relativePath); @@ -16,6 +18,15 @@ function writeSession(root: string, relativePath: string, line: Record, settings: Record): void { + mkdirSync(dirname(paths.claudeSettingsPath), { recursive: true }); + writeFileSync(paths.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function readClaudeSettings(paths: ReturnType): Record { + return JSON.parse(readFileSync(paths.claudeSettingsPath, 'utf8')) as Record; +} + describe('refreshMenubarSnapshot', () => { const tempDirs: string[] = []; const originalCodexHome = process.env['CODEX_HOME']; @@ -89,3 +100,109 @@ describe('refreshMenubarSnapshot', () => { expect(snapshot.title).toContain('Cld --'); }); }); + +describe('ensureClaudeStatusLineConfig', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('repairs settings.json when statusLine was overwritten', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeMenubarConfig(paths, config); + + // Simulate user/Claude overwriting the statusLine + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: 'sh ~/.claude/my-custom-statusline.sh' }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + // Settings should be repaired + const settings = readClaudeSettings(paths); + const statusLine = settings['statusLine'] as Record; + expect(statusLine['command']).toBe(paths.claudeStatuslineWrapperPath); + + // Backup should capture the overwritten command + const backup = updated.claudeStatusLineBackup as Record; + expect(backup['command']).toBe('sh ~/.claude/my-custom-statusline.sh'); + + // Original command script should exist + expect(existsSync(paths.previousClaudeStatuslineCommandPath)).toBe(true); + }); + + it('does nothing when settings.json already points to our bridge', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeMenubarConfig(paths, config); + + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: paths.claudeStatuslineWrapperPath }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + // No change — backup should remain null + expect(updated.claudeStatusLineBackup).toBeNull(); + }); + + it('skips repair when claudeStatusLineManaged is false', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = false; + writeMenubarConfig(paths, config); + + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: 'something-else' }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + // Settings should NOT be modified + const settings = readClaudeSettings(paths); + const statusLine = settings['statusLine'] as Record; + expect(statusLine['command']).toBe('something-else'); + expect(updated.claudeStatusLineManaged).toBe(false); + }); + + it('upgrades bridge script when claudeBridgeVersion is outdated', () => { + const homeDir = mkdtempSync(join(tmpdir(), 'tokenleak-menubar-heal-')); + tempDirs.push(homeDir); + + const paths = resolveMenubarPaths(homeDir); + const config = createDefaultMenubarConfig(); + config.claudeStatusLineManaged = true; + config.claudeBridgeVersion = 0; // Old version + writeMenubarConfig(paths, config); + + // Settings already point to our wrapper — but bridge version is old + writeClaudeSettings(paths, { + statusLine: { type: 'command', command: paths.claudeStatuslineWrapperPath }, + }); + + const updated = ensureClaudeStatusLineConfig(paths, config); + + expect(updated.claudeBridgeVersion).toBe(CURRENT_BRIDGE_VERSION); + // Bridge script should have been regenerated + expect(existsSync(paths.claudeStatuslineWrapperPath)).toBe(true); + const bridgeContent = readFileSync(paths.claudeStatuslineWrapperPath, 'utf8'); + expect(bridgeContent).toContain('/usr/bin/python3'); + }); +}); diff --git a/packages/cli/src/menubar/state.ts b/packages/cli/src/menubar/state.ts index f7ed29a..f6f77df 100644 --- a/packages/cli/src/menubar/state.ts +++ b/packages/cli/src/menubar/state.ts @@ -9,7 +9,9 @@ import { } from 'node:fs'; import { dirname } from 'node:path'; import { + extractClaudeQuotaSnapshot, extractCodexQuotaSnapshot, + type ClaudeQuotaSnapshot, type CodexQuotaSnapshot, type QuotaWindowSnapshot, } from '@tokenleak/registry'; @@ -20,14 +22,15 @@ import type { MenubarProviderSnapshot, MenubarSnapshot, MenubarWindowSnapshot, - StoredQuotaWindow, } from './types.js'; import { CLAUDE_STATUSLINE_SETUP_MESSAGE, + CURRENT_BRIDGE_VERSION, DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, MENUBAR_SCHEMA_VERSION, } from './types.js'; import { toRemainingPercent } from './format.js'; +import { buildClaudeStatuslineBridge, buildOriginalClaudeStatuslineCommandScript } from './launchd.js'; const WINDOW_STALE_GRACE_MS = 5 * 60 * 1000; @@ -43,7 +46,7 @@ function createEmptyWindow(label: string, windowMinutes: number): MenubarWindowS function toMenubarWindow( label: string, - value: QuotaWindowSnapshot | StoredQuotaWindow | null, + value: QuotaWindowSnapshot | null, nowMs: number, fallbackMinutes: number, ): MenubarWindowSnapshot { @@ -134,7 +137,7 @@ function buildCodexSnapshot( } function buildClaudeSnapshot( - snapshot: ClaudeBridgeSnapshot | null, + snapshot: ClaudeQuotaSnapshot | null, error: string | null, config: MenubarConfig, nowMs: number, @@ -233,6 +236,7 @@ export function readMenubarConfig(paths: MenubarPaths): MenubarConfig { : DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, claudeStatusLineManaged: raw.claudeStatusLineManaged === true, claudeStatusLineBackup: raw.claudeStatusLineBackup ?? null, + claudeBridgeVersion: typeof raw.claudeBridgeVersion === 'number' ? raw.claudeBridgeVersion : 0, }; } @@ -242,6 +246,7 @@ export function createDefaultMenubarConfig(): MenubarConfig { pollIntervalSeconds: DEFAULT_MENUBAR_POLL_INTERVAL_SECONDS, claudeStatusLineManaged: false, claudeStatusLineBackup: null, + claudeBridgeVersion: 0, }; } @@ -261,29 +266,109 @@ export function readSnapshot(paths: MenubarPaths): MenubarSnapshot | null { } } -export function readClaudeBridgeSnapshot(paths: MenubarPaths): ClaudeBridgeSnapshot | null { - if (!existsSync(paths.claudeSnapshotPath)) { - return null; +// Keep for backward compatibility with tests that write bridge snapshots directly +export function writeClaudeBridgeSnapshot( + paths: MenubarPaths, + snapshot: ClaudeBridgeSnapshot, +): void { + writeJsonAtomic(paths.claudeSnapshotPath, snapshot); +} + +export function writeSnapshot(paths: MenubarPaths, snapshot: MenubarSnapshot): void { + writeJsonAtomic(paths.snapshotPath, snapshot); +} + +// --------------------------------------------------------------------------- +// Self-healing: detect when ~/.claude/settings.json statusLine was overwritten +// and auto-repair it to point back to the tokenleak bridge script. +// --------------------------------------------------------------------------- + +interface CommandStatusLine { + type: 'command'; + command: string; +} + +function readClaudeSettings(paths: MenubarPaths): Record { + if (!existsSync(paths.claudeSettingsPath)) { + return {}; } try { - return JSON.parse(readFileSync(paths.claudeSnapshotPath, 'utf8')) as ClaudeBridgeSnapshot; + return JSON.parse(readFileSync(paths.claudeSettingsPath, 'utf8')) as Record; } catch { + return {}; + } +} + +function writeClaudeSettings(paths: MenubarPaths, settings: Record): void { + ensureMenubarDir(dirname(paths.claudeSettingsPath)); + writeFileSync(paths.claudeSettingsPath, `${JSON.stringify(settings, null, 2)}\n`); +} + +function parseCommandStatusLine(setting: unknown): CommandStatusLine | null { + if (typeof setting !== 'object' || setting === null) { + return null; + } + + const record = setting as Record; + if (record['type'] !== 'command' || typeof record['command'] !== 'string') { return null; } + + return { type: 'command', command: record['command'] }; } -export function writeClaudeBridgeSnapshot( - paths: MenubarPaths, - snapshot: ClaudeBridgeSnapshot, -): void { - writeJsonAtomic(paths.claudeSnapshotPath, snapshot); +function isManagedStatusLine(paths: MenubarPaths, value: unknown): boolean { + const parsed = parseCommandStatusLine(value); + return parsed?.command === paths.claudeStatuslineWrapperPath; } -export function writeSnapshot(paths: MenubarPaths, snapshot: MenubarSnapshot): void { - writeJsonAtomic(paths.snapshotPath, snapshot); +function managedStatusLineSetting(paths: MenubarPaths): CommandStatusLine { + return { type: 'command', command: paths.claudeStatuslineWrapperPath }; +} + +export function ensureClaudeStatusLineConfig( + paths: MenubarPaths, + config: MenubarConfig, +): MenubarConfig { + if (!config.claudeStatusLineManaged) { + return config; + } + + const settings = readClaudeSettings(paths); + const needsSettingsRepair = !isManagedStatusLine(paths, settings['statusLine']); + const needsBridgeUpgrade = config.claudeBridgeVersion < CURRENT_BRIDGE_VERSION; + + if (!needsSettingsRepair && !needsBridgeUpgrade) { + return config; + } + + // If settings were overwritten, capture the new command as the "original" + if (needsSettingsRepair) { + const current = parseCommandStatusLine(settings['statusLine']); + if (current) { + config.claudeStatusLineBackup = settings['statusLine']; + writeExecutableScript( + paths.previousClaudeStatuslineCommandPath, + buildOriginalClaudeStatuslineCommandScript(current.command), + ); + } + + settings['statusLine'] = managedStatusLineSetting(paths); + writeClaudeSettings(paths, settings); + } + + // Regenerate the bridge script (handles both repair and upgrade) + writeExecutableScript(paths.claudeStatuslineWrapperPath, buildClaudeStatuslineBridge(paths)); + config.claudeBridgeVersion = CURRENT_BRIDGE_VERSION; + writeMenubarConfig(paths, config); + + return config; } +// Re-export for install.ts +export { isManagedStatusLine as isManagedClaudeStatusLineSetting }; + export async function refreshMenubarSnapshot(paths: MenubarPaths): Promise { const config = readMenubarConfig(paths); const now = new Date(); @@ -297,10 +382,10 @@ export async function refreshMenubarSnapshot(paths: MenubarPaths): Promise { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function writeSnapshot(dir: string, data: Record): string { + const path = join(dir, 'claude-rate-limits.json'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`); + return path; + } + + it('returns a valid snapshot with both windows', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + source: 'claude-statusline', + capturedAt: '2026-03-28T10:00:00.000Z', + planType: 'max', + fiveHour: { usedPercent: 23, windowMinutes: 300, resetAt: '2026-03-28T15:00:00.000Z' }, + sevenDay: { usedPercent: 41, windowMinutes: 10080, resetAt: '2026-04-04T10:00:00.000Z' }, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.provider).toBe('claude-code'); + expect(snapshot?.capturedAt).toBe('2026-03-28T10:00:00.000Z'); + expect(snapshot?.planType).toBe('max'); + expect(snapshot?.fiveHour?.usedPercent).toBe(23); + expect(snapshot?.fiveHour?.windowMinutes).toBe(300); + expect(snapshot?.sevenDay?.usedPercent).toBe(41); + expect(snapshot?.sevenDay?.windowMinutes).toBe(10080); + }); + + it('returns null when the file does not exist', () => { + const snapshot = extractClaudeQuotaSnapshot('/tmp/nonexistent-claude-rl.json'); + expect(snapshot).toBeNull(); + }); + + it('returns null for invalid JSON', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = join(dir, 'claude-rate-limits.json'); + writeFileSync(path, 'not json at all'); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).toBeNull(); + }); + + it('returns null when both windows are missing', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: null, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).toBeNull(); + }); + + it('returns snapshot with only fiveHour present', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + planType: 'pro', + fiveHour: { usedPercent: 55, windowMinutes: 300, resetAt: '2026-03-28T15:00:00.000Z' }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.fiveHour?.usedPercent).toBe(55); + expect(snapshot?.sevenDay).toBeNull(); + }); + + it('converts epoch resetAt to ISO string', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const epochSeconds = 1774900800; + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { usedPercent: 10, windowMinutes: 300, resetAt: epochSeconds }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.fiveHour?.resetAt).toBe(new Date(epochSeconds * 1000).toISOString()); + }); + + it('preserves ISO string resetAt as-is', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { usedPercent: 10, windowMinutes: 300, resetAt: '2026-03-28T15:00:00.000Z' }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot?.fiveHour?.resetAt).toBe('2026-03-28T15:00:00.000Z'); + }); + + it('returns null for unsupported schema version', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 999, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { usedPercent: 10, windowMinutes: 300, resetAt: null }, + sevenDay: null, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).toBeNull(); + }); + + it('handles snake_case field names from the bridge script', () => { + const dir = mkdtempSync(join(tmpdir(), 'tl-claude-rl-')); + tempDirs.push(dir); + + const path = writeSnapshot(dir, { + schemaVersion: 1, + capturedAt: '2026-03-28T10:00:00.000Z', + fiveHour: { used_percentage: 30, window_minutes: 300, resets_at: 1774900800 }, + sevenDay: { used_percent: 50, window_minutes: 10080, reset_at: '2026-04-04T10:00:00.000Z' }, + }); + + const snapshot = extractClaudeQuotaSnapshot(path); + expect(snapshot).not.toBeNull(); + expect(snapshot?.fiveHour?.usedPercent).toBe(30); + expect(snapshot?.sevenDay?.usedPercent).toBe(50); + }); +}); diff --git a/packages/registry/src/providers/claude-rate-limits.ts b/packages/registry/src/providers/claude-rate-limits.ts new file mode 100644 index 0000000..d038b50 --- /dev/null +++ b/packages/registry/src/providers/claude-rate-limits.ts @@ -0,0 +1,107 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import type { QuotaWindowSnapshot } from './codex-rate-limits'; + +export { type QuotaWindowSnapshot } from './codex-rate-limits'; + +const SCHEMA_VERSION = 1; + +export interface ClaudeQuotaSnapshot { + provider: 'claude-code'; + capturedAt: string; + planType: string | null; + fiveHour: QuotaWindowSnapshot | null; + sevenDay: QuotaWindowSnapshot | null; +} + +function resolveDefaultSnapshotPath(): string { + return join( + homedir(), + 'Library', + 'Application Support', + 'tokenleak', + 'menubar', + 'claude-rate-limits.json', + ); +} + +function toResetAtIso(value: unknown): string | null { + if (typeof value === 'string' && value.length > 0) { + return value; + } + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return new Date(value * 1000).toISOString(); + } + return null; +} + +function parseStoredWindow( + value: unknown, + fallbackMinutes: number, +): QuotaWindowSnapshot | null { + if (typeof value !== 'object' || value === null) { + return null; + } + + const record = value as Record; + const usedPercent = record['usedPercent'] ?? record['used_percent'] ?? record['used_percentage']; + const windowMinutes = record['windowMinutes'] ?? record['window_minutes'] ?? fallbackMinutes; + const resetAt = record['resetAt'] ?? record['reset_at'] ?? record['resets_at']; + + if (typeof usedPercent !== 'number') { + return null; + } + + return { + usedPercent, + windowMinutes: typeof windowMinutes === 'number' ? windowMinutes : fallbackMinutes, + resetAt: toResetAtIso(resetAt), + }; +} + +export function extractClaudeQuotaSnapshot( + snapshotPath: string = resolveDefaultSnapshotPath(), +): ClaudeQuotaSnapshot | null { + if (!existsSync(snapshotPath)) { + return null; + } + + let raw: unknown; + try { + raw = JSON.parse(readFileSync(snapshotPath, 'utf8')); + } catch { + return null; + } + + if (typeof raw !== 'object' || raw === null) { + return null; + } + + const root = raw as Record; + if (typeof root['schemaVersion'] === 'number' && root['schemaVersion'] > SCHEMA_VERSION) { + return null; + } + + const capturedAt = root['capturedAt']; + if (typeof capturedAt !== 'string' || capturedAt.length === 0) { + return null; + } + + const fiveHour = parseStoredWindow(root['fiveHour'], 300); + const sevenDay = parseStoredWindow(root['sevenDay'], 10080); + + if (!fiveHour && !sevenDay) { + return null; + } + + const planType = typeof root['planType'] === 'string' ? root['planType'] : null; + + return { + provider: 'claude-code', + capturedAt, + planType, + fiveHour, + sevenDay, + }; +} diff --git a/packages/registry/src/providers/index.ts b/packages/registry/src/providers/index.ts index 9457905..49fd4b5 100644 --- a/packages/registry/src/providers/index.ts +++ b/packages/registry/src/providers/index.ts @@ -1,4 +1,6 @@ export { ClaudeCodeProvider } from './claude-code'; +export { extractClaudeQuotaSnapshot } from './claude-rate-limits'; +export type { ClaudeQuotaSnapshot } from './claude-rate-limits'; export { CodexProvider } from './codex'; export { extractCodexQuotaSnapshot } from './codex-rate-limits'; export type { CodexQuotaSnapshot, QuotaWindowSnapshot } from './codex-rate-limits'; From b7f9c7d00435320616059295e7226c3d164f38f2 Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Sat, 28 Mar 2026 20:40:02 +0530 Subject: [PATCH 5/5] fix: eliminate menu bar popover lag - Disable NSPopover's built-in animation (popover.animates = false) which was stacking with SwiftUI's spring animation causing sluggish open/close - Replace heavy spring + scaleEffect with a fast 0.15s easeOut fade - Check daemon health every 30s instead of every 5s to reduce main thread work --- packages/menubar/App/AppDelegate.swift | 13 ++++++++++--- packages/menubar/App/Views/PopoverContentView.swift | 3 +-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/menubar/App/AppDelegate.swift b/packages/menubar/App/AppDelegate.swift index 3bdf63f..47e9d86 100644 --- a/packages/menubar/App/AppDelegate.swift +++ b/packages/menubar/App/AppDelegate.swift @@ -40,6 +40,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { super.init() } + private var daemonCheckCounter = 0 + func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) configureStatusItem() @@ -50,8 +52,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { viewModel.reloadSnapshot(animated: false) refreshTimer.start(every: 5, fireImmediately: false) { [weak self] in - self?.startDaemonIfNeeded() - self?.viewModel.reloadSnapshot(animated: false) + guard let self else { return } + // Only check daemon every 6th tick (30s) instead of every 5s + self.daemonCheckCounter += 1 + if self.daemonCheckCounter % 6 == 0 { + self.startDaemonIfNeeded() + } + self.viewModel.reloadSnapshot(animated: false) } } @@ -109,7 +116,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func configurePopover() { popover.behavior = .transient - popover.animates = true + popover.animates = false popover.contentSize = NSSize(width: 360, height: 400) popover.contentViewController = NSHostingController( rootView: PopoverContentView(viewModel: viewModel, onOpenSettings: { [weak self] in diff --git a/packages/menubar/App/Views/PopoverContentView.swift b/packages/menubar/App/Views/PopoverContentView.swift index 41de0bd..082d363 100644 --- a/packages/menubar/App/Views/PopoverContentView.swift +++ b/packages/menubar/App/Views/PopoverContentView.swift @@ -34,9 +34,8 @@ struct PopoverContentView: View { } .frame(width: 360) .opacity(didAppear ? 1 : 0) - .scaleEffect(didAppear ? 1 : 0.92, anchor: .topTrailing) .onAppear { - withAnimation(.spring(response: 0.35, dampingFraction: 0.75)) { + withAnimation(.easeOut(duration: 0.15)) { didAppear = true } }