@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.
Files changed (80) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/input/modes/bridge.ts +1 -0
  6. package/src/input/modes/transitions.ts +3 -1
  7. package/src/input/modes/types.ts +1 -0
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/services/ai-usage/adapters/claude.ts +102 -26
  10. package/src/services/ai-usage/adapters/codex.ts +83 -32
  11. package/src/services/ai-usage/cache.ts +60 -0
  12. package/src/services/ai-usage/pace.ts +79 -0
  13. package/src/services/ai-usage/provider.ts +43 -20
  14. package/src/services/ai-usage/types.ts +31 -0
  15. package/src/state/ai-usage-store.ts +10 -3
  16. package/src/state/reducers/git-mode-state.ts +58 -0
  17. package/src/state/reducers/modal-state.ts +13 -0
  18. package/src/state/types.ts +18 -6
  19. package/src/ui/breaking-update-screen.tsx +2 -2
  20. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  22. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  23. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  24. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  25. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  26. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  27. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  28. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  29. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  30. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  31. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  32. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  33. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  34. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  35. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  36. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  37. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  38. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  39. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  40. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  41. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  42. package/src/ui/components/modals/app/ai-usage-modal.tsx +190 -0
  43. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  44. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  45. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  46. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  47. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  48. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  49. package/src/ui/components/modals/shared/form.tsx +164 -0
  50. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  51. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  52. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  53. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  54. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  55. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  56. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  57. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +56 -24
  58. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  59. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  60. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  61. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  62. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  63. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  64. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  65. package/src/ui/root.tsx +26 -23
  66. package/src/ui/theme-store.ts +30 -26
  67. package/src/ui/themes.ts +1 -0
  68. package/src/ui/ai-usage/controller.ts +0 -35
  69. package/src/ui/components/ai-usage-popover.tsx +0 -152
  70. package/src/ui/components/create-session-modal.tsx +0 -96
  71. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  72. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  73. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  74. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  75. package/src/ui/components/session-name-modal.tsx +0 -11
  76. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  77. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  78. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  79. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  80. /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.8.0",
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.0",
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) return
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 { appReducer, createInitialState } from './state/store'
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
- const [state, dispatch] = useReducer(appReducer, undefined, () => {
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
- return createInitialState(
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
- useLayoutEffect(() => {
143
- appStore.setState(state)
144
- }, [state])
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: formatNotationForDisplay(binding.keys, leaderChord),
106
+ keysDisplay,
46
107
  })
47
108
  }
48
109
 
@@ -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 }
@@ -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 { getCurrentTheme } from '../ui/theme'
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 resolvedFg = fg ?? getCurrentTheme().palette.ink
119
- const resolvedBg = bg ?? getCurrentTheme().palette.neutral
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 resolvedFg = fg ?? getCurrentTheme().palette.ink
126
- const resolvedBg = bg ?? getCurrentTheme().palette.neutral
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 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 {