@brimveyn/aimux 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.5.0",
63
+ "@brimveyn/aimux-config": "0.5.1",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@xterm/headless": "^6.0.0",
@@ -24,6 +24,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
24
24
  }
25
25
 
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
+ 'ai-usage': 'modal.ai-usage',
27
28
  'update-available': 'modal.update-available',
28
29
  }
29
30
 
@@ -2,6 +2,7 @@ import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
4
  'git-mode': ['navigation', 'modal.git-commit'],
5
+ 'modal.ai-usage': ['navigation', 'terminal-input'],
5
6
  'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
6
7
  'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
7
8
  'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
@@ -27,9 +28,10 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
27
28
  'modal.theme-picker.filtering',
28
29
  'modal.rename-tab',
29
30
  'modal.update-available',
31
+ 'modal.ai-usage',
30
32
  'git-mode',
31
33
  ],
32
- 'terminal-input': ['navigation', 'modal.split-picker'],
34
+ 'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
33
35
  }
34
36
 
35
37
  export function isValidTransition(from: ModeId, to: ModeId): boolean {
@@ -21,6 +21,7 @@ export type ModeId =
21
21
  | 'modal.git-commit.confirm'
22
22
  | 'modal.git-commit.generating'
23
23
  | 'modal.update-available'
24
+ | 'modal.ai-usage'
24
25
 
25
26
  export type SideEffect =
26
27
  | { type: 'quit'; state: AppState }
@@ -1,12 +1,14 @@
1
1
  import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
2
2
 
3
- import type { UsageSnapshot } from '../types'
3
+ import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
4
4
 
5
+ import { computePace, formatTimeRemaining } from '../pace'
5
6
  import { runCli } from '../spawn'
6
7
 
7
8
  interface ClaudeOAuthCreds {
8
9
  accessToken: string
9
10
  expiresAt?: number
11
+ planTier: string | null
10
12
  }
11
13
 
12
14
  interface ClaudeKeychainPayload {
@@ -14,29 +16,67 @@ interface ClaudeKeychainPayload {
14
16
  accessToken?: string
15
17
  expiresAt?: number
16
18
  refreshToken?: string
19
+ rateLimitTier?: string
20
+ rate_limit_tier?: string
21
+ subscriptionType?: string
22
+ scopes?: string[]
17
23
  }
18
24
  }
19
25
 
20
- interface UsageWindow {
26
+ interface UsageWindowPayload {
21
27
  utilization?: number
22
28
  resets_at?: string
23
29
  }
24
30
 
25
31
  interface ClaudeUsageResponse {
26
- five_hour?: UsageWindow
27
- seven_day?: UsageWindow
28
- seven_day_sonnet?: UsageWindow
29
- seven_day_opus?: UsageWindow
30
- extra_usage?: UsageWindow & { spent_usd?: number; limit_usd?: number }
32
+ five_hour?: UsageWindowPayload
33
+ seven_day?: UsageWindowPayload
34
+ seven_day_sonnet?: UsageWindowPayload
35
+ seven_day_opus?: UsageWindowPayload
36
+ extra_usage?: UsageWindowPayload & { spent_usd?: number; limit_usd?: number }
31
37
  }
32
38
 
33
39
  const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
34
40
  const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
35
41
  const FETCH_TIMEOUT_MS = 15_000
36
42
 
43
+ const FIVE_HOUR_SECONDS = 5 * 60 * 60
44
+ const SEVEN_DAY_SECONDS = 7 * 24 * 60 * 60
45
+
37
46
  let cachedCreds: ClaudeOAuthCreds | null = null
38
47
  const CREDS_EXPIRY_BUFFER_MS = 60_000
39
48
 
49
+ function normalizePlanTier(raw: string | undefined | null): string | null {
50
+ if (!raw) return null
51
+ const trimmed = raw.trim()
52
+ if (!trimmed) return null
53
+ const lower = trimmed.toLowerCase()
54
+ if (lower.includes('max')) return 'Max'
55
+ if (lower.includes('pro')) return 'Pro'
56
+ if (lower.includes('team')) return 'Team'
57
+ if (lower.includes('enterprise')) return 'Enterprise'
58
+ if (lower.includes('ultra')) return 'Ultra'
59
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
60
+ }
61
+
62
+ function planTierFromPayload(payload: ClaudeKeychainPayload): string | null {
63
+ const o = payload.claudeAiOauth
64
+ if (!o) return null
65
+ const raw = o.rateLimitTier ?? o.rate_limit_tier ?? o.subscriptionType
66
+ const normalized = normalizePlanTier(raw)
67
+ if (normalized) return normalized
68
+ if (Array.isArray(o.scopes)) {
69
+ for (const scope of o.scopes) {
70
+ const s = scope.toLowerCase()
71
+ if (s.includes('max')) return 'Max'
72
+ if (s.includes('pro')) return 'Pro'
73
+ if (s.includes('team')) return 'Team'
74
+ if (s.includes('enterprise')) return 'Enterprise'
75
+ }
76
+ }
77
+ return null
78
+ }
79
+
40
80
  async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
41
81
  const now = Date.now()
42
82
  if (
@@ -64,31 +104,51 @@ async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
64
104
  if (!access) {
65
105
  throw new Error('no accessToken in Claude Code keychain')
66
106
  }
67
- cachedCreds = { accessToken: access, expiresAt: parsed.claudeAiOauth?.expiresAt }
107
+ cachedCreds = {
108
+ accessToken: access,
109
+ expiresAt: parsed.claudeAiOauth?.expiresAt,
110
+ planTier: planTierFromPayload(parsed),
111
+ }
68
112
  return cachedCreds
69
113
  }
70
114
 
71
- function formatRemainingFromIso(iso: string | undefined): string | null {
72
- if (!iso) return null
73
- const ms = new Date(iso).getTime() - Date.now()
74
- if (ms <= 0) return null
75
- const totalMin = Math.round(ms / 60_000)
76
- const h = Math.floor(totalMin / 60)
77
- const m = totalMin % 60
78
- return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
115
+ function buildWindow(
116
+ kind: UsageWindowKind,
117
+ label: string,
118
+ payload: UsageWindowPayload | undefined,
119
+ windowSeconds: number,
120
+ now: number
121
+ ): UsageWindow | null {
122
+ if (!payload) return null
123
+ const util = typeof payload.utilization === 'number' ? payload.utilization : null
124
+ const percent = util === null ? null : Math.max(0, Math.min(100, util))
125
+ const resetAt = payload.resets_at ?? null
126
+ const resetAtMs = resetAt ? new Date(resetAt).getTime() : null
127
+ if (percent === null && !resetAt) return null
128
+ return {
129
+ kind,
130
+ label,
131
+ pace: kind === 'weekly' ? computePace({ now, percent, resetAtMs, windowSeconds }) : null,
132
+ percent,
133
+ resetAt,
134
+ timeRemaining: formatTimeRemaining(resetAtMs, now),
135
+ windowSeconds,
136
+ }
79
137
  }
80
138
 
81
139
  export async function fetchClaudeUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
82
- const now = new Date().toISOString()
140
+ const nowIso = new Date().toISOString()
83
141
  const base: UsageSnapshot = {
84
142
  burnRatePerHour: null,
85
143
  costUSD: null,
86
- lastUpdated: now,
144
+ lastUpdated: nowIso,
87
145
  percent: null,
146
+ planTier: null,
88
147
  resetAt: null,
89
148
  timeRemaining: null,
90
149
  tokens: { cache: 0, input: 0, output: 0, total: 0 },
91
150
  tool: 'claude',
151
+ windows: [],
92
152
  }
93
153
 
94
154
  try {
@@ -112,23 +172,39 @@ export async function fetchClaudeUsage(_config: AIUsageToolConfig): Promise<Usag
112
172
 
113
173
  if (response.status === 401 || response.status === 403) {
114
174
  cachedCreds = null
115
- return { ...base, error: 'claude oauth expired — run `claude` to re-auth' }
175
+ return {
176
+ ...base,
177
+ error: 'claude oauth expired — run `claude` to re-auth',
178
+ planTier: creds.planTier,
179
+ }
116
180
  }
117
181
  if (!response.ok) {
118
- return { ...base, error: `claude api ${response.status}` }
182
+ return { ...base, error: `claude api ${response.status}`, planTier: creds.planTier }
119
183
  }
120
184
 
121
185
  const parsed = (await response.json()) as ClaudeUsageResponse
122
- const fiveHour = parsed.five_hour
123
- const utilization = typeof fiveHour?.utilization === 'number' ? fiveHour.utilization : null
124
- const percent = utilization === null ? null : Math.max(0, Math.min(100, utilization))
186
+ const now = Date.now()
187
+
188
+ const windows: UsageWindow[] = []
189
+ const session = buildWindow('session', 'Session', parsed.five_hour, FIVE_HOUR_SECONDS, now)
190
+ if (session) windows.push(session)
191
+ const weekly = buildWindow('weekly', 'Weekly', parsed.seven_day, SEVEN_DAY_SECONDS, now)
192
+ if (weekly) windows.push(weekly)
193
+ const opus = buildWindow('opus', 'Opus', parsed.seven_day_opus, SEVEN_DAY_SECONDS, now)
194
+ if (opus) windows.push(opus)
195
+ const sonnet = buildWindow('sonnet', 'Sonnet', parsed.seven_day_sonnet, SEVEN_DAY_SECONDS, now)
196
+ if (sonnet) windows.push(sonnet)
197
+
198
+ const summary = session ?? weekly
125
199
 
126
200
  return {
127
201
  ...base,
128
- percent,
129
- resetAt: fiveHour?.resets_at ?? null,
130
- timeRemaining: formatRemainingFromIso(fiveHour?.resets_at),
202
+ percent: summary?.percent ?? null,
203
+ planTier: creds.planTier,
204
+ resetAt: summary?.resetAt ?? null,
205
+ timeRemaining: summary?.timeRemaining ?? null,
131
206
  tool: 'claude',
207
+ windows,
132
208
  }
133
209
  } catch (error) {
134
210
  return {
@@ -4,7 +4,9 @@ import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
 
7
- import type { UsageSnapshot } from '../types'
7
+ import type { UsageSnapshot, UsageWindow, UsageWindowKind } from '../types'
8
+
9
+ import { computePace, formatTimeRemaining } from '../pace'
8
10
 
9
11
  interface CodexAuthFile {
10
12
  tokens?: {
@@ -101,39 +103,78 @@ async function loadAuth(): Promise<{ accessToken: string; accountId: string | nu
101
103
  }
102
104
  }
103
105
 
104
- function formatRemainingFromReset(resetAtSeconds: number | undefined): string | null {
105
- if (!resetAtSeconds) return null
106
- const diffMs = resetAtSeconds * 1000 - Date.now()
107
- if (diffMs <= 0) return null
108
- const totalMin = Math.round(diffMs / 60_000)
109
- const h = Math.floor(totalMin / 60)
110
- const m = totalMin % 60
111
- return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
106
+ function normalizePlanTier(raw: string | undefined | null): string | null {
107
+ if (!raw) return null
108
+ const trimmed = raw.trim()
109
+ if (!trimmed) return null
110
+ const lower = trimmed.toLowerCase()
111
+ if (lower === 'pro') return 'Pro'
112
+ if (lower === 'plus') return 'Plus'
113
+ if (lower === 'free') return 'Free'
114
+ if (lower === 'team') return 'Team'
115
+ if (lower === 'business') return 'Business'
116
+ if (lower === 'enterprise') return 'Enterprise'
117
+ if (lower === 'edu' || lower === 'education') return 'Edu'
118
+ if (lower === 'go') return 'Go'
119
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
120
+ }
121
+
122
+ function buildCodexWindow(
123
+ kind: UsageWindowKind,
124
+ label: string,
125
+ window: WindowSnapshot | null | undefined,
126
+ now: number
127
+ ): UsageWindow | null {
128
+ if (!window) return null
129
+ const percent =
130
+ typeof window.used_percent === 'number' ? Math.max(0, Math.min(100, window.used_percent)) : null
131
+ const resetAtMs = window.reset_at ? window.reset_at * 1000 : null
132
+ const resetAt = resetAtMs ? new Date(resetAtMs).toISOString() : null
133
+ const windowSeconds =
134
+ typeof window.limit_window_seconds === 'number' ? window.limit_window_seconds : null
135
+ if (percent === null && !resetAt) return null
136
+ return {
137
+ kind,
138
+ label,
139
+ pace: computePace({ now, percent, resetAtMs, windowSeconds }),
140
+ percent,
141
+ resetAt,
142
+ timeRemaining: formatTimeRemaining(resetAtMs, now),
143
+ windowSeconds,
144
+ }
112
145
  }
113
146
 
114
- function pickPrimaryWindow(rate: CodexUsageResponse['rate_limit']): WindowSnapshot | null {
115
- if (!rate) return null
116
- // Primary = 5h session window. Pick the one closest to 300 minutes (18000s),
117
- // falling back to whichever is defined.
118
- const windows = [rate.primary_window, rate.secondary_window].filter(
119
- (w): w is WindowSnapshot => !!w
120
- )
121
- if (windows.length === 0) return null
122
- const sessionWindow = windows.find((w) => w.limit_window_seconds === 18_000)
123
- return sessionWindow ?? windows[0] ?? null
147
+ function orderWindows(
148
+ primary: WindowSnapshot | null | undefined,
149
+ secondary: WindowSnapshot | null | undefined
150
+ ): {
151
+ session: WindowSnapshot | null | undefined
152
+ weekly: WindowSnapshot | null | undefined
153
+ } {
154
+ const SESSION_MIN_SECONDS = 3 * 3600
155
+ const SESSION_MAX_SECONDS = 8 * 3600
156
+ const isSession = (w: WindowSnapshot | null | undefined): boolean => {
157
+ const s = w?.limit_window_seconds
158
+ return typeof s === 'number' && s >= SESSION_MIN_SECONDS && s <= SESSION_MAX_SECONDS
159
+ }
160
+ if (isSession(primary)) return { session: primary, weekly: secondary }
161
+ if (isSession(secondary)) return { session: secondary, weekly: primary }
162
+ return { session: primary, weekly: secondary }
124
163
  }
125
164
 
126
165
  export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
127
- const now = new Date().toISOString()
166
+ const nowIso = new Date().toISOString()
128
167
  const base: UsageSnapshot = {
129
168
  burnRatePerHour: null,
130
169
  costUSD: null,
131
- lastUpdated: now,
170
+ lastUpdated: nowIso,
132
171
  percent: null,
172
+ planTier: null,
133
173
  resetAt: null,
134
174
  timeRemaining: null,
135
175
  tokens: { cache: 0, input: 0, output: 0, total: 0 },
136
176
  tool: 'codex',
177
+ windows: [],
137
178
  }
138
179
 
139
180
  try {
@@ -164,23 +205,33 @@ export async function fetchCodexUsage(_config: AIUsageToolConfig): Promise<Usage
164
205
  }
165
206
 
166
207
  const parsed = (await response.json()) as CodexUsageResponse
167
- const window = pickPrimaryWindow(parsed.rate_limit)
168
- if (!window) {
169
- return { ...base, error: 'no rate_limit data' }
208
+ const planTier = normalizePlanTier(parsed.plan_type)
209
+ const now = Date.now()
210
+ const { session, weekly } = orderWindows(
211
+ parsed.rate_limit?.primary_window,
212
+ parsed.rate_limit?.secondary_window
213
+ )
214
+
215
+ const windows: UsageWindow[] = []
216
+ const sessionWindow = buildCodexWindow('session', 'Session', session, now)
217
+ if (sessionWindow) windows.push(sessionWindow)
218
+ const weeklyWindow = buildCodexWindow('weekly', 'Weekly', weekly, now)
219
+ if (weeklyWindow) windows.push(weeklyWindow)
220
+
221
+ if (windows.length === 0) {
222
+ return { ...base, error: 'no rate_limit data', planTier }
170
223
  }
171
224
 
172
- const percent =
173
- typeof window.used_percent === 'number'
174
- ? Math.max(0, Math.min(100, window.used_percent))
175
- : null
176
- const resetAt = window.reset_at ? new Date(window.reset_at * 1000).toISOString() : null
225
+ const summary = sessionWindow ?? weeklyWindow
177
226
 
178
227
  return {
179
228
  ...base,
180
- percent,
181
- resetAt,
182
- timeRemaining: formatRemainingFromReset(window.reset_at),
229
+ percent: summary?.percent ?? null,
230
+ planTier,
231
+ resetAt: summary?.resetAt ?? null,
232
+ timeRemaining: summary?.timeRemaining ?? null,
183
233
  tool: 'codex',
234
+ windows,
184
235
  }
185
236
  } catch (error) {
186
237
  return {
@@ -0,0 +1,60 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+
3
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
4
+ import { homedir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ import type { UsageSnapshot } from './types'
8
+
9
+ const CACHE_DIR = join(homedir(), '.cache', 'aimux')
10
+ const CACHE_PATH = join(CACHE_DIR, 'ai-usage.json')
11
+
12
+ interface CacheEntry {
13
+ fetchedAt: number
14
+ snapshot: UsageSnapshot
15
+ }
16
+
17
+ type CacheFile = Partial<Record<AIUsageTool, CacheEntry>>
18
+
19
+ function readCacheFile(): CacheFile {
20
+ try {
21
+ if (!existsSync(CACHE_PATH)) return {}
22
+ const raw = readFileSync(CACHE_PATH, 'utf8')
23
+ const parsed = JSON.parse(raw) as unknown
24
+ if (typeof parsed !== 'object' || parsed === null) return {}
25
+ return parsed as CacheFile
26
+ } catch {
27
+ return {}
28
+ }
29
+ }
30
+
31
+ export interface CachedSnapshot {
32
+ snapshot: UsageSnapshot
33
+ ageMs: number
34
+ }
35
+
36
+ export function loadCachedSnapshot(tool: AIUsageTool, maxAgeMs: number): CachedSnapshot | null {
37
+ const cache = readCacheFile()
38
+ const entry = cache[tool]
39
+ if (!entry) return null
40
+ if (typeof entry.fetchedAt !== 'number') return null
41
+ const ageMs = Date.now() - entry.fetchedAt
42
+ if (ageMs > maxAgeMs) return null
43
+ if (!entry.snapshot || entry.snapshot.tool !== tool) return null
44
+ return { ageMs, snapshot: entry.snapshot }
45
+ }
46
+
47
+ export function saveCachedSnapshot(snapshot: UsageSnapshot): void {
48
+ if (snapshot.error) return
49
+ if (snapshot.percent === null) return
50
+ try {
51
+ mkdirSync(CACHE_DIR, { recursive: true })
52
+ const cache = readCacheFile()
53
+ cache[snapshot.tool] = { fetchedAt: Date.now(), snapshot }
54
+ const tmpPath = `${CACHE_PATH}.${process.pid}.tmp`
55
+ writeFileSync(tmpPath, `${JSON.stringify(cache, null, 2)}\n`)
56
+ renameSync(tmpPath, CACHE_PATH)
57
+ } catch {
58
+ // swallow — cache is best-effort
59
+ }
60
+ }
@@ -0,0 +1,79 @@
1
+ import type { UsagePace, UsagePaceStage } from './types'
2
+
3
+ function stageFor(delta: number): UsagePaceStage {
4
+ const abs = Math.abs(delta)
5
+ if (abs <= 2) return 'onTrack'
6
+ if (abs <= 6) return delta >= 0 ? 'slightlyBehind' : 'slightlyAhead'
7
+ if (abs <= 12) return delta >= 0 ? 'behind' : 'ahead'
8
+ return delta >= 0 ? 'farBehind' : 'farAhead'
9
+ }
10
+
11
+ function formatDuration(seconds: number): string {
12
+ const total = Math.max(0, Math.round(seconds))
13
+ const h = Math.floor(total / 3600)
14
+ const m = Math.floor((total % 3600) / 60)
15
+ if (h >= 24) {
16
+ const d = Math.floor(h / 24)
17
+ const hh = h % 24
18
+ return hh > 0 ? `${d}d ${hh}h` : `${d}d`
19
+ }
20
+ if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`
21
+ return `${m}m`
22
+ }
23
+
24
+ export function computePace(opts: {
25
+ percent: number | null
26
+ resetAtMs: number | null
27
+ windowSeconds: number | null
28
+ now?: number
29
+ }): UsagePace | null {
30
+ const { percent, resetAtMs, windowSeconds } = opts
31
+ const now = opts.now ?? Date.now()
32
+ if (percent === null || resetAtMs === null || !windowSeconds || windowSeconds <= 0) {
33
+ return null
34
+ }
35
+
36
+ const timeUntilReset = (resetAtMs - now) / 1000
37
+ if (timeUntilReset <= 0 || timeUntilReset > windowSeconds) return null
38
+
39
+ const elapsed = Math.max(0, Math.min(windowSeconds, windowSeconds - timeUntilReset))
40
+ const expected = Math.max(0, Math.min(100, (elapsed / windowSeconds) * 100))
41
+ const actual = Math.max(0, Math.min(100, percent))
42
+ const delta = actual - expected
43
+ const rounded = Math.round(delta)
44
+ const stage = stageFor(rounded)
45
+
46
+ let rightText: string | null = null
47
+ if (elapsed > 0 && actual > 0) {
48
+ const rate = actual / elapsed
49
+ if (rate > 0) {
50
+ const remaining = Math.max(0, 100 - actual)
51
+ const candidate = remaining / rate
52
+ if (candidate >= timeUntilReset) {
53
+ rightText = 'Lasts to reset'
54
+ } else {
55
+ rightText = `Runs out in ${formatDuration(candidate)}`
56
+ }
57
+ }
58
+ } else if (elapsed > 0 && actual === 0) {
59
+ rightText = 'Lasts to reset'
60
+ }
61
+
62
+ let label: string
63
+ if (stage === 'onTrack') {
64
+ label = 'On pace'
65
+ } else if (rounded > 0) {
66
+ label = `Behind (+${rounded}%)`
67
+ } else {
68
+ label = `Ahead (${rounded}%)`
69
+ }
70
+
71
+ return { delta, label, rightText, stage }
72
+ }
73
+
74
+ export function formatTimeRemaining(resetAtMs: number | null, now?: number): string | null {
75
+ if (resetAtMs === null) return null
76
+ const diff = resetAtMs - (now ?? Date.now())
77
+ if (diff <= 0) return null
78
+ return formatDuration(diff / 1000)
79
+ }
@@ -4,6 +4,7 @@ import type { UsageSnapshot } from './types'
4
4
 
5
5
  import { fetchClaudeUsage } from './adapters/claude'
6
6
  import { fetchCodexUsage } from './adapters/codex'
7
+ import { loadCachedSnapshot, saveCachedSnapshot } from './cache'
7
8
 
8
9
  const DEFAULT_POLL_SECONDS = 60
9
10
  const DEFAULT_TOOLS: AIUsageTool[] = ['claude', 'codex']
@@ -34,32 +35,54 @@ export function startAIUsageService(
34
35
 
35
36
  const tick = async (): Promise<void> => {
36
37
  if (stopped) return
37
- const results = await Promise.allSettled(tools.map((t) => fetchFor(t, config)))
38
- if (stopped) return
39
- for (let i = 0; i < results.length; i++) {
40
- const result = results[i]
41
- const tool = tools[i]
42
- if (!result || !tool) continue
43
- if (result.status === 'fulfilled') {
44
- onUpdate(result.value)
38
+
39
+ const toFetch: AIUsageTool[] = []
40
+ let maxCachedAgeMs = 0
41
+ for (const tool of tools) {
42
+ const cached = loadCachedSnapshot(tool, pollMs)
43
+ if (cached) {
44
+ onUpdate(cached.snapshot)
45
+ if (cached.ageMs > maxCachedAgeMs) maxCachedAgeMs = cached.ageMs
45
46
  } else {
46
- onUpdate({
47
- burnRatePerHour: null,
48
- costUSD: null,
49
- error: result.reason instanceof Error ? result.reason.message : String(result.reason),
50
- lastUpdated: new Date().toISOString(),
51
- percent: null,
52
- resetAt: null,
53
- timeRemaining: null,
54
- tokens: { cache: 0, input: 0, output: 0, total: 0 },
55
- tool,
56
- })
47
+ toFetch.push(tool)
48
+ }
49
+ }
50
+
51
+ if (toFetch.length > 0) {
52
+ const results = await Promise.allSettled(toFetch.map((t) => fetchFor(t, config)))
53
+ if (stopped) return
54
+ for (let i = 0; i < results.length; i++) {
55
+ const result = results[i]
56
+ const tool = toFetch[i]
57
+ if (!result || !tool) continue
58
+ if (result.status === 'fulfilled') {
59
+ saveCachedSnapshot(result.value)
60
+ onUpdate(result.value)
61
+ } else {
62
+ onUpdate({
63
+ burnRatePerHour: null,
64
+ costUSD: null,
65
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
66
+ lastUpdated: new Date().toISOString(),
67
+ percent: null,
68
+ planTier: null,
69
+ resetAt: null,
70
+ timeRemaining: null,
71
+ tokens: { cache: 0, input: 0, output: 0, total: 0 },
72
+ tool,
73
+ windows: [],
74
+ })
75
+ }
57
76
  }
58
77
  }
78
+
59
79
  if (!stopped) {
80
+ const minDelayMs = 5_000
81
+ const nextDelayMs =
82
+ toFetch.length > 0 ? pollMs : Math.max(minDelayMs, pollMs - maxCachedAgeMs)
60
83
  timer = setTimeout(() => {
61
84
  void tick()
62
- }, pollMs)
85
+ }, nextDelayMs)
63
86
  }
64
87
  }
65
88
 
@@ -2,6 +2,34 @@ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
2
 
3
3
  export type { AIUsageTool }
4
4
 
5
+ export type UsageWindowKind = 'session' | 'weekly' | 'sonnet' | 'opus' | 'primary' | 'secondary'
6
+
7
+ export type UsagePaceStage =
8
+ | 'farAhead'
9
+ | 'ahead'
10
+ | 'slightlyAhead'
11
+ | 'onTrack'
12
+ | 'slightlyBehind'
13
+ | 'behind'
14
+ | 'farBehind'
15
+
16
+ export interface UsagePace {
17
+ delta: number
18
+ stage: UsagePaceStage
19
+ label: string
20
+ rightText: string | null
21
+ }
22
+
23
+ export interface UsageWindow {
24
+ kind: UsageWindowKind
25
+ label: string
26
+ percent: number | null
27
+ resetAt: string | null
28
+ timeRemaining: string | null
29
+ windowSeconds: number | null
30
+ pace: UsagePace | null
31
+ }
32
+
5
33
  export interface UsageSnapshot {
6
34
  tool: AIUsageTool
7
35
  percent: number | null
@@ -16,5 +44,8 @@ export interface UsageSnapshot {
16
44
  timeRemaining: string | null
17
45
  burnRatePerHour: number | null
18
46
  lastUpdated: string
47
+ planTier: string | null
48
+ windows: UsageWindow[]
19
49
  error?: string
50
+ stale?: boolean
20
51
  }
@@ -18,9 +18,16 @@ export const aiUsageStore = createStore<AIUsageState>((set) => ({
18
18
  enabled: false,
19
19
  setEnabled: (enabled: boolean) => set({ enabled }),
20
20
  setSnapshot: (snap: UsageSnapshot) =>
21
- set((state) => ({
22
- snapshots: { ...state.snapshots, [snap.tool]: snap },
23
- })),
21
+ set((state) => {
22
+ const prev = state.snapshots[snap.tool]
23
+ const isFailure = Boolean(snap.error)
24
+ const hasPriorValue = prev && prev.percent !== null
25
+ const merged: UsageSnapshot =
26
+ isFailure && hasPriorValue && prev
27
+ ? { ...prev, error: snap.error, lastUpdated: snap.lastUpdated, stale: true }
28
+ : snap
29
+ return { snapshots: { ...state.snapshots, [snap.tool]: merged } }
30
+ }),
24
31
  snapshots: {},
25
32
  }))
26
33
 
@@ -54,6 +54,19 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
54
54
  },
55
55
  }
56
56
  }
57
+ case 'open-ai-usage-modal': {
58
+ return {
59
+ ...state,
60
+ focusMode: 'modal',
61
+ modal: {
62
+ cursorPos: 0,
63
+ editBuffer: '',
64
+ selectedIndex: 0,
65
+ sessionTargetId: null,
66
+ type: 'ai-usage',
67
+ },
68
+ }
69
+ }
57
70
  case 'open-help-modal': {
58
71
  const keymap = getActiveKeymap()
59
72
  const scope = action.scope ?? null
@@ -44,6 +44,7 @@ export type ModalType =
44
44
  | 'split-picker'
45
45
  | 'git-commit'
46
46
  | 'update-available'
47
+ | 'ai-usage'
47
48
  | null
48
49
 
49
50
  export interface TerminalSpan {
@@ -348,6 +349,10 @@ export interface ModalUpdateAvailable extends ModalBase {
348
349
  latestVersion: string
349
350
  }
350
351
 
352
+ export interface ModalAIUsage extends ModalBase {
353
+ type: 'ai-usage'
354
+ }
355
+
351
356
  export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
352
357
 
353
358
  export interface DirectoryResult {
@@ -369,6 +374,7 @@ export type ModalState =
369
374
  | ModalSnippetEditor
370
375
  | ModalGitCommit
371
376
  | ModalUpdateAvailable
377
+ | ModalAIUsage
372
378
 
373
379
  export interface LayoutState {
374
380
  terminalCols: number
@@ -434,6 +440,7 @@ export type ModalAction =
434
440
  | { type: 'open-theme-picker' }
435
441
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
436
442
  | { type: 'set-modal-selection-index'; index: number }
443
+ | { type: 'open-ai-usage-modal' }
437
444
 
438
445
  // -- Session actions --
439
446
  export type SessionAction =
@@ -1,8 +1,8 @@
1
1
  import type { AIUsageTool } from '@brimveyn/aimux-config'
2
2
 
3
3
  import { useAIUsageStore } from '../../state/ai-usage-store'
4
- import { toggleAIUsagePopover } from '../ai-usage/controller'
5
- import { useTokens } from '../theme'
4
+ import { dispatchGlobal } from '../../state/dispatch-ref'
5
+ import { useBg, useTokens } from '../theme'
6
6
 
7
7
  const TOOL_ICON: Record<AIUsageTool, string> = {
8
8
  claude: 'CC',
@@ -19,7 +19,7 @@ function formatTokens(total: number): string {
19
19
  return String(total)
20
20
  }
21
21
 
22
- function buildBar(percent: number): { filled: string; empty: string } {
22
+ function buildBar(percent: number): { empty: string; filled: string } {
23
23
  let filledCount = 0
24
24
  for (let i = 0; i < BAR_SEGMENTS; i++) {
25
25
  if (percent > i * (100 / BAR_SEGMENTS)) filledCount++
@@ -48,6 +48,7 @@ function formatResetIn(snap: {
48
48
 
49
49
  export function AIUsageIndicator() {
50
50
  const t = useTokens()
51
+ const bg = useBg('elevated')
51
52
  const enabled = useAIUsageStore((s) => s.enabled)
52
53
  const snapshots = useAIUsageStore((s) => s.snapshots)
53
54
 
@@ -58,35 +59,49 @@ export function AIUsageIndicator() {
58
59
  .map((tool) => ({ snap: snapshots[tool], tool }))
59
60
  .filter((entry) => entry.snap !== undefined)
60
61
 
62
+ const openModal = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
63
+ e.preventDefault()
64
+ e.stopPropagation()
65
+ dispatchGlobal({ type: 'open-ai-usage-modal' })
66
+ }
67
+
61
68
  if (entries.length === 0) {
62
69
  return (
63
- <box flexDirection="row" gap={1}>
70
+ <box
71
+ flexDirection="row"
72
+ paddingLeft={1}
73
+ paddingRight={1}
74
+ backgroundColor={bg}
75
+ onMouseDown={openModal}
76
+ >
64
77
  <text fg={t.muted}>…</text>
65
78
  </box>
66
79
  )
67
80
  }
68
81
 
69
82
  return (
70
- <box
71
- flexDirection="row"
72
- gap={2}
73
- onMouseDown={(e) => {
74
- e.preventDefault()
75
- e.stopPropagation()
76
- if (e.button !== 0) return
77
- toggleAIUsagePopover(e.x, e.y)
78
- }}
79
- >
83
+ <box flexDirection="row" gap={1}>
80
84
  {entries.map(({ snap, tool }) => {
81
85
  if (!snap) return null
82
86
  const icon = TOOL_ICON[tool]
83
- if (snap.error) {
87
+
88
+ if (snap.error && !snap.stale) {
84
89
  return (
85
- <text key={tool} fg={t.palette.error} selectable={false}>
86
- {icon}
87
- </text>
90
+ <box
91
+ key={tool}
92
+ flexDirection="row"
93
+ paddingLeft={1}
94
+ paddingRight={1}
95
+ backgroundColor={bg}
96
+ onMouseDown={openModal}
97
+ >
98
+ <text fg={t.palette.error} selectable={false}>
99
+ {`${icon} —`}
100
+ </text>
101
+ </box>
88
102
  )
89
103
  }
104
+
90
105
  if (snap.percent !== null) {
91
106
  const p = Math.round(snap.percent)
92
107
  let color = t.palette.success
@@ -99,9 +114,16 @@ export function AIUsageIndicator() {
99
114
  const reset = formatResetIn(snap)
100
115
  const pctText = `${String(p).padStart(2, ' ')}%`
101
116
  return (
102
- <box key={tool} flexDirection="row">
117
+ <box
118
+ key={tool}
119
+ flexDirection="row"
120
+ paddingLeft={1}
121
+ paddingRight={1}
122
+ backgroundColor={bg}
123
+ onMouseDown={openModal}
124
+ >
103
125
  <text fg={color} selectable={false}>
104
- {icon}{' '}
126
+ {`${icon} `}
105
127
  </text>
106
128
  <text fg={color} selectable={false}>
107
129
  {filled}
@@ -120,10 +142,20 @@ export function AIUsageIndicator() {
120
142
  </box>
121
143
  )
122
144
  }
145
+
123
146
  return (
124
- <text key={tool} fg={t.muted} selectable={false}>
125
- {icon} {formatTokens(snap.tokens.total)}
126
- </text>
147
+ <box
148
+ key={tool}
149
+ flexDirection="row"
150
+ paddingLeft={1}
151
+ paddingRight={1}
152
+ backgroundColor={bg}
153
+ onMouseDown={openModal}
154
+ >
155
+ <text fg={t.muted} selectable={false}>
156
+ {`${icon} ${formatTokens(snap.tokens.total)}`}
157
+ </text>
158
+ </box>
127
159
  )
128
160
  })}
129
161
  </box>
@@ -0,0 +1,186 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import type { UsagePaceStage, UsageSnapshot, UsageWindow } from '../../services/ai-usage/types'
5
+
6
+ import { useAIUsageStore } from '../../state/ai-usage-store'
7
+ import { useTokens } from '../theme'
8
+ import { uiTokens } from '../ui-tokens'
9
+ import { ModalShell } from './modal-shell'
10
+
11
+ const TOOL_TITLE: Record<AIUsageTool, string> = {
12
+ claude: 'Claude',
13
+ codex: 'Codex',
14
+ }
15
+
16
+ const BAR_SEGMENTS = 32
17
+ const BAR_FILLED_CHAR = '\u{2501}'
18
+ const BAR_EMPTY_CHAR = '\u{2500}'
19
+
20
+ function buildBar(percent: number | null): { empty: string; filled: string } {
21
+ const p = percent ?? 0
22
+ let filledCount = 0
23
+ for (let i = 0; i < BAR_SEGMENTS; i++) {
24
+ if (p > i * (100 / BAR_SEGMENTS)) filledCount++
25
+ }
26
+ return {
27
+ empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
28
+ filled: BAR_FILLED_CHAR.repeat(filledCount),
29
+ }
30
+ }
31
+
32
+ function formatRelative(iso: string, now: number = Date.now()): string {
33
+ const diffMs = now - new Date(iso).getTime()
34
+ if (!Number.isFinite(diffMs) || diffMs < 0) return 'just now'
35
+ const s = Math.floor(diffMs / 1000)
36
+ if (s < 10) return 'just now'
37
+ if (s < 60) return `${s}s ago`
38
+ const m = Math.floor(s / 60)
39
+ if (m < 60) return `${m}m ago`
40
+ const h = Math.floor(m / 60)
41
+ if (h < 24) return `${h}h ago`
42
+ const d = Math.floor(h / 24)
43
+ return `${d}d ago`
44
+ }
45
+
46
+ function paceStageIsAhead(stage: UsagePaceStage): boolean {
47
+ return stage === 'ahead' || stage === 'farAhead' || stage === 'slightlyAhead'
48
+ }
49
+
50
+ function paceStageIsBehind(stage: UsagePaceStage): boolean {
51
+ return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
52
+ }
53
+
54
+ export function AIUsageModal() {
55
+ const t = useTokens()
56
+ const snapshots = useAIUsageStore((s) => s.snapshots)
57
+
58
+ const tools: AIUsageTool[] = ['claude', 'codex']
59
+ const sections = tools
60
+ .map((tool) => ({ snap: snapshots[tool], tool }))
61
+ .filter((s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined)
62
+
63
+ return (
64
+ <ModalShell
65
+ title="AI usage"
66
+ keybindsModeId="modal.ai-usage"
67
+ width={uiTokens.modalWidth.md}
68
+ listGap={1}
69
+ >
70
+ {sections.length === 0 ? (
71
+ <text fg={t.muted} selectable={false}>
72
+ no data yet — collecting…
73
+ </text>
74
+ ) : (
75
+ <box flexDirection="column" gap={1}>
76
+ {sections.map(({ snap, tool }) => (
77
+ <ToolSection key={tool} snap={snap} tool={tool} />
78
+ ))}
79
+ </box>
80
+ )}
81
+ </ModalShell>
82
+ )
83
+ }
84
+
85
+ interface ToolSectionProps {
86
+ snap: UsageSnapshot
87
+ tool: AIUsageTool
88
+ }
89
+
90
+ function ToolSection({ snap, tool }: ToolSectionProps) {
91
+ const t = useTokens()
92
+ const isHardError = Boolean(snap.error) && !snap.stale
93
+ const relative = formatRelative(snap.lastUpdated)
94
+
95
+ let body: ReactNode
96
+ if (isHardError) {
97
+ body = (
98
+ <text fg={t.palette.error} selectable={false}>
99
+ {`error: ${snap.error ?? ''}`}
100
+ </text>
101
+ )
102
+ } else if (snap.windows.length === 0) {
103
+ body = (
104
+ <text fg={t.muted} selectable={false}>
105
+ no window data
106
+ </text>
107
+ )
108
+ } else {
109
+ body = snap.windows.map((window) => <WindowRow key={window.kind} window={window} />)
110
+ }
111
+
112
+ return (
113
+ <box flexDirection="column">
114
+ <box flexDirection="row" justifyContent="space-between">
115
+ <text fg={t.accent} selectable={false}>
116
+ {TOOL_TITLE[tool]}
117
+ </text>
118
+ {snap.planTier ? (
119
+ <text fg={t.muted} selectable={false}>
120
+ {snap.planTier}
121
+ </text>
122
+ ) : null}
123
+ </box>
124
+ <text fg={t.muted} selectable={false}>
125
+ {`Updated ${relative}`}
126
+ </text>
127
+ {body}
128
+ </box>
129
+ )
130
+ }
131
+
132
+ function WindowRow({ window }: { window: UsageWindow }) {
133
+ const t = useTokens()
134
+ const percent = window.percent
135
+ const { empty, filled } = buildBar(percent)
136
+
137
+ let barColor = t.palette.success
138
+ if (percent !== null) {
139
+ if (percent >= 85) barColor = t.palette.error
140
+ else if (percent >= 60) barColor = t.palette.warning
141
+ }
142
+
143
+ const pctText = percent === null ? '—' : `${Math.round(percent)}% used`
144
+ const resetText = window.timeRemaining ? `Resets in ${window.timeRemaining}` : null
145
+
146
+ return (
147
+ <box flexDirection="column" paddingTop={1}>
148
+ <text fg={t.palette.ink} selectable={false}>
149
+ {window.label}
150
+ </text>
151
+ <box flexDirection="row">
152
+ <text fg={barColor} selectable={false}>
153
+ {filled}
154
+ </text>
155
+ <text fg={t.muted} selectable={false}>
156
+ {empty}
157
+ </text>
158
+ </box>
159
+ <box flexDirection="row" justifyContent="space-between">
160
+ <text fg={t.muted} selectable={false}>
161
+ {pctText}
162
+ </text>
163
+ {resetText ? (
164
+ <text fg={t.muted} selectable={false}>
165
+ {resetText}
166
+ </text>
167
+ ) : null}
168
+ </box>
169
+ {window.pace ? <PaceLine pace={window.pace} /> : null}
170
+ </box>
171
+ )
172
+ }
173
+
174
+ function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
175
+ const t = useTokens()
176
+ let color = t.muted
177
+ if (paceStageIsBehind(pace.stage)) color = t.palette.warning
178
+ else if (paceStageIsAhead(pace.stage)) color = t.palette.success
179
+
180
+ const suffix = pace.rightText ? ` · ${pace.rightText}` : ''
181
+ return (
182
+ <text fg={color} selectable={false}>
183
+ {`Pace: ${pace.label}${suffix}`}
184
+ </text>
185
+ )
186
+ }
package/src/ui/root.tsx CHANGED
@@ -8,7 +8,7 @@ import { useAppStore } from '../state/app-store'
8
8
  import { dispatchGlobal } from '../state/dispatch-ref'
9
9
  import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
10
10
  import { getTreeForTab, PANE_BORDER, type SplitDirection } from '../state/layout-tree'
11
- import { AIUsagePopover } from './components/ai-usage-popover'
11
+ import { AIUsageModal } from './components/ai-usage-modal'
12
12
  import { ContextMenuBox } from './components/context-menu-box'
13
13
  import { ContextMenuOverlay } from './components/context-menu-overlay'
14
14
  import { CreateSessionModal } from './components/create-session-modal'
@@ -172,6 +172,8 @@ function renderModal(
172
172
  cursorPos={modal.cursorPos}
173
173
  />
174
174
  )
175
+ case 'ai-usage':
176
+ return <AIUsageModal />
175
177
  case 'git-commit': {
176
178
  const titleText =
177
179
  modal.activeField === 'title' ? (modal.editBuffer ?? '') : modal.contentBuffer
@@ -279,7 +281,6 @@ export function RootView({
279
281
  <StatusBar />
280
282
  <PendingChordOverlay />
281
283
  <ContextMenuOverlay />
282
- <AIUsagePopover />
283
284
  {renderModal(modal, {
284
285
  activeAssistant: activeTab?.assistant,
285
286
  createSessionFields,
@@ -1,35 +0,0 @@
1
- export interface AIUsagePopoverState {
2
- anchorX: number
3
- anchorY: number
4
- }
5
-
6
- type Listener = (state: AIUsagePopoverState | null) => void
7
-
8
- let current: AIUsagePopoverState | null = null
9
- const listeners = new Set<Listener>()
10
-
11
- export function openAIUsagePopover(anchorX: number, anchorY: number): void {
12
- current = { anchorX, anchorY }
13
- for (const l of listeners) l(current)
14
- }
15
-
16
- export function closeAIUsagePopover(): void {
17
- if (current === null) return
18
- current = null
19
- for (const l of listeners) l(null)
20
- }
21
-
22
- export function toggleAIUsagePopover(anchorX: number, anchorY: number): void {
23
- if (current) {
24
- closeAIUsagePopover()
25
- } else {
26
- openAIUsagePopover(anchorX, anchorY)
27
- }
28
- }
29
-
30
- export function subscribeAIUsagePopover(listener: Listener): () => void {
31
- listeners.add(listener)
32
- return () => {
33
- listeners.delete(listener)
34
- }
35
- }
@@ -1,152 +0,0 @@
1
- import type { AIUsageTool } from '@brimveyn/aimux-config'
2
-
3
- import { useKeyboard } from '@opentui/react'
4
- import { useEffect, useState } from 'react'
5
-
6
- import type { UsageSnapshot } from '../../services/ai-usage/types'
7
-
8
- import { useAIUsageStore } from '../../state/ai-usage-store'
9
- import { useAppStore } from '../../state/app-store'
10
- import {
11
- type AIUsagePopoverState,
12
- closeAIUsagePopover,
13
- subscribeAIUsagePopover,
14
- } from '../ai-usage/controller'
15
- import { useTokens } from '../theme'
16
-
17
- const TOOL_TITLE: Record<AIUsageTool, string> = {
18
- claude: 'Claude Code',
19
- codex: 'Codex',
20
- }
21
-
22
- const POPOVER_WIDTH = 38
23
-
24
- function fmt(n: number): string {
25
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`
26
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
27
- return String(n)
28
- }
29
-
30
- function buildLines(snap: UsageSnapshot): string[] {
31
- if (snap.error) {
32
- return [`error: ${snap.error.slice(0, 30)}`]
33
- }
34
- const lines: string[] = []
35
- if (snap.percent !== null) {
36
- lines.push(`usage ${snap.percent.toFixed(1)}%`)
37
- }
38
- lines.push(`tokens in ${fmt(snap.tokens.input)} / out ${fmt(snap.tokens.output)}`)
39
- if (snap.tokens.cache > 0) {
40
- lines.push(`cache ${fmt(snap.tokens.cache)}`)
41
- }
42
- lines.push(`total ${fmt(snap.tokens.total)}`)
43
- if (snap.costUSD !== null) {
44
- lines.push(`cost $${snap.costUSD.toFixed(2)}`)
45
- }
46
- if (snap.burnRatePerHour !== null) {
47
- lines.push(`burn ${fmt(Math.round(snap.burnRatePerHour))}/h`)
48
- }
49
- if (snap.timeRemaining) {
50
- lines.push(`resets ${snap.timeRemaining}`)
51
- } else if (snap.resetAt) {
52
- lines.push(`resets ${new Date(snap.resetAt).toLocaleTimeString()}`)
53
- }
54
- return lines
55
- }
56
-
57
- export function AIUsagePopover() {
58
- const [popover, setPopover] = useState<AIUsagePopoverState | null>(null)
59
- const t = useTokens()
60
- const enabled = useAIUsageStore((s) => s.enabled)
61
- const snapshots = useAIUsageStore((s) => s.snapshots)
62
- const terminalCols = useAppStore((s) => s.layout.terminalCols)
63
- const terminalRows = useAppStore((s) => s.layout.terminalRows)
64
-
65
- useEffect(() => subscribeAIUsagePopover(setPopover), [])
66
-
67
- useKeyboard((key) => {
68
- if (!popover) return
69
- if (key.name === 'escape') {
70
- key.preventDefault()
71
- closeAIUsagePopover()
72
- }
73
- })
74
-
75
- if (!enabled || !popover) return null
76
-
77
- const tools: AIUsageTool[] = ['claude', 'codex']
78
- const sections = tools
79
- .map((tool) => ({ snap: snapshots[tool], tool }))
80
- .filter((s) => s.snap !== undefined)
81
-
82
- let bodyLines = 0
83
- if (sections.length === 0) {
84
- bodyLines = 1
85
- } else {
86
- for (const s of sections) {
87
- if (!s.snap) continue
88
- bodyLines += buildLines(s.snap).length + 2
89
- }
90
- }
91
- const height = Math.min(terminalRows - 2, bodyLines + 2)
92
- const width = Math.min(terminalCols - 2, POPOVER_WIDTH)
93
-
94
- const left = Math.max(0, Math.min(popover.anchorX - width + 2, terminalCols - width))
95
- const top = Math.max(0, popover.anchorY - height)
96
-
97
- return (
98
- <box position="absolute" top={0} left={0} width="100%" height="100%">
99
- <box
100
- position="absolute"
101
- top={0}
102
- left={0}
103
- width="100%"
104
- height="100%"
105
- onMouseDown={(e) => {
106
- e.preventDefault()
107
- e.stopPropagation()
108
- closeAIUsagePopover()
109
- }}
110
- />
111
- <box
112
- position="absolute"
113
- top={top}
114
- left={left}
115
- width={width}
116
- flexDirection="column"
117
- border
118
- borderColor={t.palette.primary}
119
- backgroundColor={t.elevated}
120
- onMouseDown={(e) => {
121
- e.stopPropagation()
122
- }}
123
- >
124
- {sections.length === 0 ? (
125
- <box paddingLeft={1} paddingRight={1}>
126
- <text fg={t.muted} selectable={false}>
127
- no data yet — collecting…
128
- </text>
129
- </box>
130
- ) : (
131
- sections.map(({ snap, tool }, idx) => {
132
- if (!snap) return null
133
- const lines = buildLines(snap)
134
- return (
135
- <box key={tool} flexDirection="column" paddingLeft={1} paddingRight={1}>
136
- {idx > 0 ? <text fg={t.muted}> </text> : null}
137
- <text fg={t.accent} selectable={false}>
138
- {TOOL_TITLE[tool]}
139
- </text>
140
- {lines.map((line, i) => (
141
- <text key={`${tool}-${i}`} fg={t.palette.ink} selectable={false}>
142
- {line}
143
- </text>
144
- ))}
145
- </box>
146
- )
147
- })
148
- )}
149
- </box>
150
- </box>
151
- )
152
- }