@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 +2 -2
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +1 -0
- package/src/services/ai-usage/adapters/claude.ts +102 -26
- package/src/services/ai-usage/adapters/codex.ts +83 -32
- package/src/services/ai-usage/cache.ts +60 -0
- package/src/services/ai-usage/pace.ts +79 -0
- package/src/services/ai-usage/provider.ts +43 -20
- package/src/services/ai-usage/types.ts +31 -0
- package/src/state/ai-usage-store.ts +10 -3
- package/src/state/reducers/modal-state.ts +13 -0
- package/src/state/types.ts +7 -0
- package/src/ui/components/ai-usage-indicator.tsx +55 -23
- package/src/ui/components/ai-usage-modal.tsx +186 -0
- package/src/ui/root.tsx +3 -2
- package/src/ui/ai-usage/controller.ts +0 -35
- package/src/ui/components/ai-usage-popover.tsx +0 -152
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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",
|
|
@@ -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 {
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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
|
|
26
|
+
interface UsageWindowPayload {
|
|
21
27
|
utilization?: number
|
|
22
28
|
resets_at?: string
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
interface ClaudeUsageResponse {
|
|
26
|
-
five_hour?:
|
|
27
|
-
seven_day?:
|
|
28
|
-
seven_day_sonnet?:
|
|
29
|
-
seven_day_opus?:
|
|
30
|
-
extra_usage?:
|
|
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 = {
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
140
|
+
const nowIso = new Date().toISOString()
|
|
83
141
|
const base: UsageSnapshot = {
|
|
84
142
|
burnRatePerHour: null,
|
|
85
143
|
costUSD: null,
|
|
86
|
-
lastUpdated:
|
|
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 {
|
|
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
|
|
123
|
-
|
|
124
|
-
const
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
105
|
-
if (!
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
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
|
|
166
|
+
const nowIso = new Date().toISOString()
|
|
128
167
|
const base: UsageSnapshot = {
|
|
129
168
|
burnRatePerHour: null,
|
|
130
169
|
costUSD: null,
|
|
131
|
-
lastUpdated:
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
package/src/state/types.ts
CHANGED
|
@@ -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 {
|
|
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): {
|
|
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
|
|
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
|
-
|
|
87
|
+
|
|
88
|
+
if (snap.error && !snap.stale) {
|
|
84
89
|
return (
|
|
85
|
-
<
|
|
86
|
-
{
|
|
87
|
-
|
|
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
|
|
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
|
-
<
|
|
125
|
-
{
|
|
126
|
-
|
|
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 {
|
|
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
|
-
}
|