@brimveyn/aimux 1.8.0 → 1.9.1
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/app-runtime/side-effects.ts +6 -1
- package/src/app.tsx +20 -16
- package/src/input/keymap/describe-bindings.ts +63 -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/pty/terminal-snapshot.ts +7 -5
- 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/git-mode-state.ts +58 -0
- package/src/state/reducers/modal-state.ts +13 -0
- package/src/state/types.ts +18 -6
- package/src/ui/breaking-update-screen.tsx +2 -2
- package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
- package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
- package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
- package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
- package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
- package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
- package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
- package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
- package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
- package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
- package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
- package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
- package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
- package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
- package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
- package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
- package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
- package/src/ui/components/modals/app/ai-usage-modal.tsx +190 -0
- package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
- package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
- package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
- package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
- package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
- package/src/ui/components/modals/shared/form.tsx +164 -0
- package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
- package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
- package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
- package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
- package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
- package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
- package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +56 -24
- package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
- package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
- package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
- package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
- package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
- package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
- package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
- package/src/ui/root.tsx +26 -23
- package/src/ui/theme-store.ts +30 -26
- package/src/ui/themes.ts +1 -0
- package/src/ui/ai-usage/controller.ts +0 -35
- package/src/ui/components/ai-usage-popover.tsx +0 -152
- package/src/ui/components/create-session-modal.tsx +0 -96
- package/src/ui/components/diff-renderer/build-rows.ts +0 -349
- package/src/ui/components/diff-renderer/highlight.ts +0 -44
- package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
- package/src/ui/components/session-name-modal.tsx +0 -11
- package/src/ui/components/snippet-editor-modal.tsx +0 -40
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
- /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
- /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
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.2",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@xterm/headless": "^6.0.0",
|
|
@@ -605,7 +605,12 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
|
|
|
605
605
|
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
606
606
|
return
|
|
607
607
|
}
|
|
608
|
-
if (target.id === state.currentSessionId)
|
|
608
|
+
if (target.id === state.currentSessionId) {
|
|
609
|
+
if (state.focusMode === 'git') {
|
|
610
|
+
dispatch({ type: 'exit-git-mode' })
|
|
611
|
+
}
|
|
612
|
+
return
|
|
613
|
+
}
|
|
609
614
|
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
610
615
|
}
|
|
611
616
|
|
package/src/app.tsx
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { type ResolvedConfig, setAutoCommitEnabled } from '@brimveyn/aimux-config'
|
|
2
2
|
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
3
|
-
import {
|
|
4
|
-
useCallback,
|
|
5
|
-
useEffect,
|
|
6
|
-
useLayoutEffect,
|
|
7
|
-
useMemo,
|
|
8
|
-
useReducer,
|
|
9
|
-
useRef,
|
|
10
|
-
useState,
|
|
11
|
-
} from 'react'
|
|
3
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
12
4
|
|
|
13
5
|
import type { KeyChord } from './input/keymap/key-chord'
|
|
14
6
|
import type { TrieBinding } from './input/keymap/trie'
|
|
@@ -32,11 +24,11 @@ import { type TerminalContentOrigin } from './input/raw-input-handler'
|
|
|
32
24
|
import { getProfileConfigDir, getProfileName } from './profile-paths'
|
|
33
25
|
import { startAIUsageService } from './services/ai-usage/provider'
|
|
34
26
|
import { aiUsageStore } from './state/ai-usage-store'
|
|
35
|
-
import { appStore } from './state/app-store'
|
|
27
|
+
import { appStore, useAppStore } from './state/app-store'
|
|
36
28
|
import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
|
|
37
29
|
import { loadSessionCatalog } from './state/session-catalog'
|
|
38
30
|
import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
39
|
-
import {
|
|
31
|
+
import { createInitialState } from './state/store'
|
|
40
32
|
import { KeymapContext } from './ui/keymap-context'
|
|
41
33
|
import { RootView } from './ui/root'
|
|
42
34
|
import { applyTheme, setTransparent } from './ui/theme'
|
|
@@ -81,7 +73,10 @@ export function App({
|
|
|
81
73
|
setTransparent(config.themeTransparent ?? false)
|
|
82
74
|
return initial
|
|
83
75
|
})
|
|
84
|
-
|
|
76
|
+
// Initialize zustand store with computed initial state on first render. Using
|
|
77
|
+
// useState's lazy initializer guarantees this runs exactly once before any
|
|
78
|
+
// selector reads. We discard the value — the store is the source of truth.
|
|
79
|
+
useState(() => {
|
|
85
80
|
const json = loadConfig()
|
|
86
81
|
const sessionBarVisible =
|
|
87
82
|
resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
|
|
@@ -125,7 +120,7 @@ export function App({
|
|
|
125
120
|
...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
|
|
126
121
|
}
|
|
127
122
|
|
|
128
|
-
|
|
123
|
+
const initial = createInitialState(
|
|
129
124
|
json.customCommands,
|
|
130
125
|
loadSessionCatalog(),
|
|
131
126
|
loadSnippetCatalog(),
|
|
@@ -137,11 +132,18 @@ export function App({
|
|
|
137
132
|
sidebar: sidebarOverrides,
|
|
138
133
|
}
|
|
139
134
|
)
|
|
135
|
+
// Replace the module-level default with the fully-resolved initial state.
|
|
136
|
+
// Preserves the dispatch baked into the store by app-store.ts.
|
|
137
|
+
appStore.setState(initial)
|
|
138
|
+
return null
|
|
140
139
|
})
|
|
141
140
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
// Single source of truth: read state from zustand. The selector returns the
|
|
142
|
+
// entire store object, so this component re-renders on every dispatch (same
|
|
143
|
+
// cadence as the previous useReducer setup), but selectors elsewhere only
|
|
144
|
+
// re-render on the slices they actually subscribe to.
|
|
145
|
+
const state = useAppStore((s) => s)
|
|
146
|
+
const dispatch = state.dispatch
|
|
145
147
|
|
|
146
148
|
useLayoutEffect(() => {
|
|
147
149
|
setActiveDispatch(dispatch)
|
|
@@ -191,6 +193,8 @@ export function App({
|
|
|
191
193
|
return () => {
|
|
192
194
|
cancelled = true
|
|
193
195
|
}
|
|
196
|
+
// dispatch is stable (same fn reference from the store) — safe to omit
|
|
197
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
194
198
|
}, [])
|
|
195
199
|
|
|
196
200
|
const resizingRef = useRef(false)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
1
|
+
import type { Action, ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { parseKeyNotation } from './key-chord'
|
|
4
4
|
import { formatNotationForDisplay } from './key-format'
|
|
@@ -18,6 +18,24 @@ export interface DescribedBindingGroup {
|
|
|
18
18
|
interface DescribeOptions {
|
|
19
19
|
withDescriptionOnly?: boolean
|
|
20
20
|
dedupeByDescription?: boolean
|
|
21
|
+
mergeAlternativesByDescription?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const actionObjectIds = new WeakMap<object, number>()
|
|
25
|
+
let nextActionObjectId = 0
|
|
26
|
+
|
|
27
|
+
function getActionSignature(action: Action): string {
|
|
28
|
+
if (typeof action === 'function') {
|
|
29
|
+
let id = actionObjectIds.get(action)
|
|
30
|
+
if (id === undefined) {
|
|
31
|
+
id = nextActionObjectId
|
|
32
|
+
nextActionObjectId += 1
|
|
33
|
+
actionObjectIds.set(action, id)
|
|
34
|
+
}
|
|
35
|
+
return `fn:${id}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `json:${JSON.stringify(action)}`
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
export function describeBindings(
|
|
@@ -31,9 +49,52 @@ export function describeBindings(
|
|
|
31
49
|
const leaderChord = parseKeyNotation(config.leader)[0]
|
|
32
50
|
const seenDescriptions = new Set<string>()
|
|
33
51
|
const result: DescribedBinding[] = []
|
|
52
|
+
const resultIndexByDescription = new Map<string, number>()
|
|
53
|
+
const resultIndexByAction = new Map<string, number>()
|
|
54
|
+
const seenKeysDisplayByIndex = new Map<number, Set<string>>()
|
|
34
55
|
|
|
35
56
|
for (const binding of mode.bindings) {
|
|
57
|
+
const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
|
|
58
|
+
|
|
59
|
+
if (options.mergeAlternativesByDescription) {
|
|
60
|
+
const actionSignature = getActionSignature(binding.result)
|
|
61
|
+
const existingIndex = binding.description
|
|
62
|
+
? resultIndexByDescription.get(binding.description)
|
|
63
|
+
: resultIndexByAction.get(actionSignature)
|
|
64
|
+
|
|
65
|
+
if (existingIndex === undefined) {
|
|
66
|
+
if (options.withDescriptionOnly && !binding.description) continue
|
|
67
|
+
const nextIndex = result.length
|
|
68
|
+
if (binding.description) resultIndexByDescription.set(binding.description, nextIndex)
|
|
69
|
+
resultIndexByAction.set(actionSignature, nextIndex)
|
|
70
|
+
seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
|
|
71
|
+
result.push({
|
|
72
|
+
description: binding.description,
|
|
73
|
+
group: binding.group,
|
|
74
|
+
keys: binding.keys,
|
|
75
|
+
keysDisplay,
|
|
76
|
+
})
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const seenKeysDisplay = seenKeysDisplayByIndex.get(existingIndex)
|
|
81
|
+
if (!seenKeysDisplay || seenKeysDisplay.has(keysDisplay)) continue
|
|
82
|
+
|
|
83
|
+
seenKeysDisplay.add(keysDisplay)
|
|
84
|
+
const existing = result[existingIndex]
|
|
85
|
+
if (!existing) continue
|
|
86
|
+
if (!existing.description && binding.description) {
|
|
87
|
+
existing.description = binding.description
|
|
88
|
+
existing.group = binding.group
|
|
89
|
+
resultIndexByDescription.set(binding.description, existingIndex)
|
|
90
|
+
}
|
|
91
|
+
existing.keys = `${existing.keys} / ${binding.keys}`
|
|
92
|
+
existing.keysDisplay = `${existing.keysDisplay} / ${keysDisplay}`
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
36
96
|
if (options.withDescriptionOnly && !binding.description) continue
|
|
97
|
+
|
|
37
98
|
if (options.dedupeByDescription && binding.description) {
|
|
38
99
|
if (seenDescriptions.has(binding.description)) continue
|
|
39
100
|
seenDescriptions.add(binding.description)
|
|
@@ -42,7 +103,7 @@ export function describeBindings(
|
|
|
42
103
|
description: binding.description,
|
|
43
104
|
group: binding.group,
|
|
44
105
|
keys: binding.keys,
|
|
45
|
-
keysDisplay
|
|
106
|
+
keysDisplay,
|
|
46
107
|
})
|
|
47
108
|
}
|
|
48
109
|
|
|
@@ -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
|
@@ -2,7 +2,7 @@ import type { Terminal } from '@xterm/headless'
|
|
|
2
2
|
|
|
3
3
|
import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { getCurrentTokens } from '../ui/theme'
|
|
6
6
|
|
|
7
7
|
const SNAPSHOT_TAIL_LINE_COUNT = 10
|
|
8
8
|
|
|
@@ -115,15 +115,17 @@ function buildLine(
|
|
|
115
115
|
let bg = getColorHex(current.getBgColor(), bgMode)
|
|
116
116
|
|
|
117
117
|
if (current.isInverse()) {
|
|
118
|
-
const
|
|
119
|
-
const
|
|
118
|
+
const tokens = getCurrentTokens()
|
|
119
|
+
const resolvedFg = fg ?? tokens.palette.ink
|
|
120
|
+
const resolvedBg = bg ?? tokens.bg
|
|
120
121
|
;[fg, bg] = [resolvedBg, resolvedFg]
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
const isCursorCell = cursorVisible && cursorColumn === column
|
|
124
125
|
if (isCursorCell) {
|
|
125
|
-
const
|
|
126
|
-
const
|
|
126
|
+
const tokens = getCurrentTokens()
|
|
127
|
+
const resolvedFg = fg ?? tokens.palette.ink
|
|
128
|
+
const resolvedBg = bg ?? tokens.bg
|
|
127
129
|
;[fg, bg] = [resolvedBg, resolvedFg]
|
|
128
130
|
}
|
|
129
131
|
|
|
@@ -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 {
|