@brimveyn/aimux 1.7.3 → 1.8.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.
Files changed (62) hide show
  1. package/README.md +27 -15
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +286 -0
  4. package/src/app-runtime/auto-commit-ref.ts +20 -0
  5. package/src/app-runtime/backend-attach-runtime.ts +3 -1
  6. package/src/app-runtime/session-actions.ts +7 -2
  7. package/src/app-runtime/side-effects.ts +111 -4
  8. package/src/app-runtime/split-drag-controller.ts +31 -1
  9. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  10. package/src/app-runtime/use-mouse-handlers.ts +93 -5
  11. package/src/app-runtime/use-terminal-resize.ts +24 -16
  12. package/src/app.tsx +71 -19
  13. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  14. package/src/auto-commit/headless-commands.ts +40 -0
  15. package/src/auto-commit/output-parser.ts +21 -0
  16. package/src/auto-commit/prompt-loader.ts +33 -0
  17. package/src/auto-commit/staging-mode.ts +5 -0
  18. package/src/auto-commit/strip-ansi.ts +13 -0
  19. package/src/auto-commit/suggestion-runner.ts +55 -0
  20. package/src/auto-commit/working-tree-hash.ts +24 -0
  21. package/src/config.ts +45 -2
  22. package/src/daemon/session-registry.ts +1 -0
  23. package/src/index.tsx +1 -1
  24. package/src/input/keymap/help-entries.ts +4 -4
  25. package/src/input/modes/bridge.ts +6 -0
  26. package/src/input/modes/transitions.ts +3 -1
  27. package/src/input/modes/types.ts +4 -0
  28. package/src/ipc/manager-protocol.ts +2 -2
  29. package/src/ipc/protocol.ts +2 -8
  30. package/src/pty/assistant-status-detector.ts +1 -1
  31. package/src/pty/terminal-snapshot.ts +38 -4
  32. package/src/services/ai-usage/adapters/claude.ts +139 -0
  33. package/src/services/ai-usage/adapters/codex.ts +191 -0
  34. package/src/services/ai-usage/provider.ts +84 -0
  35. package/src/services/ai-usage/spawn.ts +49 -0
  36. package/src/services/ai-usage/types.ts +20 -0
  37. package/src/session-backend/local-session-backend.ts +10 -2
  38. package/src/state/ai-usage-store.ts +29 -0
  39. package/src/state/git-pane-sizing.ts +15 -0
  40. package/src/state/reducers/auto-commit-state.ts +59 -0
  41. package/src/state/reducers/git-panel-state.ts +12 -7
  42. package/src/state/reducers/modal-state.ts +106 -2
  43. package/src/state/reducers/session-state.ts +26 -14
  44. package/src/state/reducers/ui-state.ts +6 -0
  45. package/src/state/session-persistence.ts +14 -5
  46. package/src/state/store.ts +26 -15
  47. package/src/state/types.ts +59 -3
  48. package/src/state/workspace-save.ts +6 -1
  49. package/src/ui/ai-usage/controller.ts +35 -0
  50. package/src/ui/components/ai-usage-indicator.tsx +131 -0
  51. package/src/ui/components/ai-usage-popover.tsx +152 -0
  52. package/src/ui/components/context-menu-overlay.tsx +4 -2
  53. package/src/ui/components/create-session-modal.tsx +2 -2
  54. package/src/ui/components/git-commit-modal.tsx +167 -18
  55. package/src/ui/components/git-pane-context-menu.ts +26 -0
  56. package/src/ui/components/session-bar.tsx +2 -2
  57. package/src/ui/components/session-picker-modal.tsx +4 -4
  58. package/src/ui/components/sidebar.tsx +117 -31
  59. package/src/ui/components/status-bar.tsx +2 -0
  60. package/src/ui/components/terminal-pane.tsx +18 -3
  61. package/src/ui/root.tsx +114 -13
  62. package/src/ui/status-bar-model.ts +1 -1
@@ -53,27 +53,39 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
53
53
  ),
54
54
  }
55
55
  case 'delete-session-record': {
56
+ const deletingCurrent = action.sessionId === state.currentSessionId
56
57
  const newSessions = state.sessions.filter((session) => session.id !== action.sessionId)
57
- const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
58
- const maxIndex = filteredNew.length
59
- const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
60
58
  const nextStatuses = { ...state.sessionStatuses }
61
59
  delete nextStatuses[action.sessionId]
60
+ if (action.openSessionPicker) {
61
+ const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
62
+ const maxIndex = filteredNew.length
63
+ const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
64
+ return {
65
+ ...state,
66
+ activeTabId: deletingCurrent ? null : state.activeTabId,
67
+ currentSessionId: deletingCurrent ? null : state.currentSessionId,
68
+ focusMode: 'modal',
69
+ modal: {
70
+ editBuffer: null,
71
+ selectedIndex: clampedIndex,
72
+ sessionTargetId: null,
73
+ type: 'session-picker',
74
+ },
75
+ sessions: newSessions,
76
+ sessionStatuses: nextStatuses,
77
+ tabs: deletingCurrent ? [] : state.tabs,
78
+ }
79
+ }
62
80
  return {
63
81
  ...state,
64
- activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
65
- currentSessionId:
66
- action.sessionId === state.currentSessionId ? null : state.currentSessionId,
67
- focusMode: 'modal',
68
- modal: {
69
- editBuffer: null,
70
- selectedIndex: clampedIndex,
71
- sessionTargetId: null,
72
- type: 'session-picker',
73
- },
82
+ activeTabId: deletingCurrent ? null : state.activeTabId,
83
+ currentSessionId: deletingCurrent ? null : state.currentSessionId,
84
+ focusMode: deletingCurrent ? 'navigation' : state.focusMode,
85
+ modal: deletingCurrent ? CLOSED_MODAL : state.modal,
74
86
  sessions: newSessions,
75
87
  sessionStatuses: nextStatuses,
76
- tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
88
+ tabs: deletingCurrent ? [] : state.tabs,
77
89
  }
78
90
  }
79
91
  case 'reorder-sessions': {
@@ -9,6 +9,12 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
9
9
  state.sidebar.maxWidth,
10
10
  Math.max(state.sidebar.minWidth, state.sidebar.width + action.delta)
11
11
  )
12
+ if (width === state.sidebar.width) return state
13
+ return { ...state, sidebar: { ...state.sidebar, width } }
14
+ }
15
+ case 'set-sidebar-width': {
16
+ const width = Math.min(state.sidebar.maxWidth, Math.max(state.sidebar.minWidth, action.width))
17
+ if (width === state.sidebar.width) return state
12
18
  return { ...state, sidebar: { ...state.sidebar, width } }
13
19
  }
14
20
  case 'set-focus-mode':
@@ -2,6 +2,7 @@ import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './l
2
2
  import {
3
3
  type AppState,
4
4
  DEFAULT_SCROLL_INTENT,
5
+ type ScrollIntent,
5
6
  type TabSession,
6
7
  type TabStatus,
7
8
  type WorkspaceSnapshotV1,
@@ -82,6 +83,18 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
82
83
  }))
83
84
  }
84
85
 
86
+ export function getSnapshotScrollIntents(
87
+ snapshot: WorkspaceSnapshotV1 | undefined
88
+ ): Map<string, ScrollIntent> {
89
+ if (!snapshot || snapshot.version !== 1) {
90
+ return new Map()
91
+ }
92
+
93
+ return new Map(
94
+ snapshot.tabs.map((tab) => [tab.id, tab.scrollIntent ?? DEFAULT_SCROLL_INTENT] as const)
95
+ )
96
+ }
97
+
85
98
  export function restoreLayoutTrees(
86
99
  snapshot: WorkspaceSnapshotV1 | undefined,
87
100
  tabs: TabSession[]
@@ -192,11 +205,7 @@ export function restoreWorkspaceState(
192
205
  activeTabId,
193
206
  focusMode: 'navigation',
194
207
  layoutTrees,
195
- sidebar: {
196
- ...state.sidebar,
197
- visible: workspaceSnapshot?.sidebar.visible ?? state.sidebar.visible,
198
- width: workspaceSnapshot?.sidebar.width ?? state.sidebar.width,
199
- },
208
+ sidebar: state.sidebar,
200
209
  tabGroupMap,
201
210
  tabs: orderedTabs,
202
211
  }
@@ -1,15 +1,4 @@
1
- import type {
2
- AppAction,
3
- AppState,
4
- GitModeState,
5
- GitPaneMode,
6
- GitPanePosition,
7
- GitPaneState,
8
- SessionBarPosition,
9
- SessionRecord,
10
- SnippetRecord,
11
- } from './types'
12
-
1
+ import { reduceAutoCommit } from './reducers/auto-commit-state'
13
2
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
14
3
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
15
4
  import { emptyModal, reduceModalState } from './reducers/modal-state'
@@ -17,6 +6,18 @@ import { reduceSessionState } from './reducers/session-state'
17
6
  import { reduceTabState } from './reducers/tab-state'
18
7
  import { reduceUIState } from './reducers/ui-state'
19
8
  import { filterSnippets } from './selectors'
9
+ import {
10
+ type AppAction,
11
+ type AppState,
12
+ EMPTY_AUTO_COMMIT_STATE,
13
+ type GitModeState,
14
+ type GitPaneMode,
15
+ type GitPanePosition,
16
+ type GitPaneState,
17
+ type SessionBarPosition,
18
+ type SessionRecord,
19
+ type SnippetRecord,
20
+ } from './types'
20
21
 
21
22
  const DEFAULT_SIDEBAR_WIDTH = 28
22
23
  const DEFAULT_SIDEBAR_MIN_WIDTH = 18
@@ -27,6 +28,7 @@ const DEFAULT_TERMINAL_ROWS = 24
27
28
  export interface InitialStateOverrides {
28
29
  gitMode?: Partial<GitModeState>
29
30
  gitPane?: Partial<GitPaneState>
31
+ sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
30
32
  sessionBarVisible?: boolean
31
33
  sessionBarPosition?: SessionBarPosition
32
34
  }
@@ -34,12 +36,13 @@ export interface InitialStateOverrides {
34
36
  const DEFAULT_GIT_PANE: GitPaneState = {
35
37
  diffCount: { enabled: true },
36
38
  diffModeRatio: 0.35,
39
+ embeddedRatio: 0.5,
37
40
  fileListMode: 'tree',
38
41
  mode: 'embedded',
42
+ paneRatio: 0.5,
39
43
  path: { enabled: true },
40
44
  position: 'bottom',
41
45
  prefetchRadius: 5,
42
- ratio: 0.5,
43
46
  treeCompaction: true,
44
47
  visible: true,
45
48
  }
@@ -51,6 +54,10 @@ function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): G
51
54
  return position === 'left' || position === 'right' ? position : 'left'
52
55
  }
53
56
 
57
+ function clampSidebarWidth(width: number): number {
58
+ return Math.min(DEFAULT_SIDEBAR_MAX_WIDTH, Math.max(DEFAULT_SIDEBAR_MIN_WIDTH, width))
59
+ }
60
+
54
61
  export function createInitialState(
55
62
  customCommands: Record<string, string> = {},
56
63
  sessions: SessionRecord[] = [],
@@ -65,6 +72,7 @@ export function createInitialState(
65
72
  )
66
73
  return {
67
74
  activeTabId: null,
75
+ autoCommit: EMPTY_AUTO_COMMIT_STATE,
68
76
  currentSessionId: null,
69
77
  customCommands,
70
78
  focusMode: showSessionPicker ? 'command-edit' : 'navigation',
@@ -100,8 +108,8 @@ export function createInitialState(
100
108
  sidebar: {
101
109
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
102
110
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
103
- visible: true,
104
- width: DEFAULT_SIDEBAR_WIDTH,
111
+ visible: overrides.sidebar?.visible ?? true,
112
+ width: clampSidebarWidth(overrides.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH),
105
113
  },
106
114
  snippets,
107
115
  tabGroupMap: {},
@@ -128,6 +136,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
128
136
  const gitModeState = reduceGitModeState(state, action)
129
137
  if (gitModeState) return gitModeState
130
138
 
139
+ const autoCommitState = reduceAutoCommit(state, action)
140
+ if (autoCommitState) return autoCommitState
141
+
131
142
  switch (action.type) {
132
143
  case 'set-snippets':
133
144
  return { ...state, snippets: action.snippets }
@@ -62,6 +62,7 @@ export interface TerminalLine {
62
62
 
63
63
  export interface TerminalSnapshot {
64
64
  lines: TerminalLine[]
65
+ tailLines?: TerminalLine[]
65
66
  viewportY: number
66
67
  baseY: number
67
68
  cursorVisible: boolean
@@ -166,7 +167,8 @@ export interface GitPaneState {
166
167
  visible: boolean
167
168
  mode: GitPaneMode
168
169
  position: GitPanePosition
169
- ratio: number
170
+ paneRatio: number
171
+ embeddedRatio: number
170
172
  diffModeRatio: number
171
173
  fileListMode: GitFileListMode
172
174
  treeCompaction: boolean
@@ -322,6 +324,7 @@ export interface ModalGitCommit extends ModalBase {
322
324
  type: 'git-commit'
323
325
  activeField: 'title' | 'body'
324
326
  contentBuffer: string
327
+ stage: 'edit' | 'generating' | 'confirm'
325
328
  }
326
329
 
327
330
  export interface ModalCreateSession extends ModalBase {
@@ -396,6 +399,7 @@ export interface AppState {
396
399
  customCommands: Record<AssistantId, string>
397
400
  gitPanel: GitPanelState
398
401
  gitMode: GitModeState
402
+ autoCommit: AutoCommitState
399
403
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
400
404
  pendingChords: string[] | null
401
405
  }
@@ -437,7 +441,7 @@ export type SessionAction =
437
441
  | { type: 'set-sessions'; sessions: SessionRecord[] }
438
442
  | { type: 'create-session-record'; session: SessionRecord }
439
443
  | { type: 'rename-session-record'; sessionId: string; name: string }
440
- | { type: 'delete-session-record'; sessionId: string }
444
+ | { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
441
445
  | { type: 'reorder-sessions'; orderedIds: string[] }
442
446
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
443
447
 
@@ -500,10 +504,12 @@ export type LayoutAction =
500
504
  export type UIAction =
501
505
  | { type: 'toggle-sidebar' }
502
506
  | { type: 'resize-sidebar'; delta: number }
507
+ | { type: 'set-sidebar-width'; width: number }
503
508
  | { type: 'set-focus-mode'; focusMode: FocusMode }
504
509
  | { type: 'set-terminal-size'; cols: number; rows: number }
505
510
  | { type: 'toggle-git-pane' }
506
511
  | { type: 'resize-git-pane'; delta: number }
512
+ | { type: 'set-git-pane-ratio'; target: 'pane' | 'embedded'; ratio: number }
507
513
  | { type: 'resize-git-diff-pane'; delta: number }
508
514
  | { type: 'set-git-pane-mode'; mode: GitPaneMode }
509
515
  | { type: 'set-git-pane-position'; position: GitPanePosition }
@@ -524,6 +530,50 @@ export type GitPanelAction =
524
530
  | { type: 'git-refresh-error'; kind: GitPanelError }
525
531
  | { type: 'git-panel-reset' }
526
532
 
533
+ // -- Auto-commit state & actions --
534
+ export type AutoCommitSuggestion =
535
+ | { kind: 'idle' }
536
+ | {
537
+ kind: 'generating'
538
+ tabId: string
539
+ workingTreeHash: string
540
+ abortController: AbortController
541
+ startedAt: number
542
+ }
543
+ | {
544
+ kind: 'ready'
545
+ tabId: string
546
+ workingTreeHash: string
547
+ title: string
548
+ body: string
549
+ generatedAt: number
550
+ }
551
+
552
+ export interface AutoCommitState {
553
+ bySession: Record<string, AutoCommitSuggestion>
554
+ }
555
+
556
+ export const EMPTY_AUTO_COMMIT_STATE: AutoCommitState = { bySession: {} }
557
+
558
+ export type AutoCommitAction =
559
+ | {
560
+ type: 'auto-commit-generation-started'
561
+ sessionId: string
562
+ tabId: string
563
+ workingTreeHash: string
564
+ abortController: AbortController
565
+ startedAt: number
566
+ }
567
+ | {
568
+ type: 'auto-commit-generation-ready'
569
+ sessionId: string
570
+ workingTreeHash: string
571
+ title: string
572
+ body: string
573
+ generatedAt: number
574
+ }
575
+ | { type: 'auto-commit-clear'; sessionId: string }
576
+
527
577
  export type GitModeAction =
528
578
  | { type: 'enter-git-mode' }
529
579
  | { type: 'exit-git-mode' }
@@ -573,7 +623,12 @@ export type GitModeAction =
573
623
  fromSection: GitFileSection
574
624
  toSection: GitFileSection | null
575
625
  }
576
- | { type: 'open-git-commit-modal' }
626
+ | { type: 'open-git-commit-modal'; sessionId?: string }
627
+ | { type: 'git-commit-enter-confirm' }
628
+ | { type: 'git-commit-leave-confirm' }
629
+ | { type: 'git-commit-enter-generating'; sessionId: string }
630
+ | { type: 'git-commit-leave-generating' }
631
+ | { type: 'git-commit-use-background-suggestion'; sessionId: string }
577
632
 
578
633
  // -- Data actions --
579
634
  export type DataAction =
@@ -590,3 +645,4 @@ export type AppAction =
590
645
  | DataAction
591
646
  | GitPanelAction
592
647
  | GitModeAction
648
+ | AutoCommitAction
@@ -26,14 +26,19 @@ export function saveCurrentWorkspace(state: AppState): void {
26
26
  customCommands: state.customCommands,
27
27
  gitPane: {
28
28
  diffModeRatio: state.gitPane.diffModeRatio,
29
+ embeddedRatio: state.gitPane.embeddedRatio,
29
30
  fileListMode: state.gitPane.fileListMode,
30
31
  mode: state.gitPane.mode,
32
+ paneRatio: state.gitPane.paneRatio,
31
33
  position: state.gitPane.position,
32
- ratio: state.gitPane.ratio,
33
34
  visible: state.gitPane.visible,
34
35
  },
35
36
  sessionBarPosition: state.sessionBar.position,
36
37
  sessionBarVisible: state.sessionBar.visible,
38
+ sidebar: {
39
+ visible: state.sidebar.visible,
40
+ width: state.sidebar.width,
41
+ },
37
42
  })
38
43
  saveSessionCatalog(
39
44
  buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
@@ -0,0 +1,35 @@
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
+ }
@@ -0,0 +1,131 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+
3
+ import { useAIUsageStore } from '../../state/ai-usage-store'
4
+ import { toggleAIUsagePopover } from '../ai-usage/controller'
5
+ import { useTokens } from '../theme'
6
+
7
+ const TOOL_ICON: Record<AIUsageTool, string> = {
8
+ claude: 'CC',
9
+ codex: 'CO',
10
+ }
11
+
12
+ const BAR_SEGMENTS = 4
13
+ const BAR_FILLED_CHAR = '\u{2501}'
14
+ const BAR_EMPTY_CHAR = '\u{2500}'
15
+
16
+ function formatTokens(total: number): string {
17
+ if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`
18
+ if (total >= 1_000) return `${(total / 1_000).toFixed(1)}k`
19
+ return String(total)
20
+ }
21
+
22
+ function buildBar(percent: number): { filled: string; empty: string } {
23
+ let filledCount = 0
24
+ for (let i = 0; i < BAR_SEGMENTS; i++) {
25
+ if (percent > i * (100 / BAR_SEGMENTS)) filledCount++
26
+ }
27
+ return {
28
+ empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
29
+ filled: BAR_FILLED_CHAR.repeat(filledCount),
30
+ }
31
+ }
32
+
33
+ function formatResetIn(snap: {
34
+ resetAt: string | null
35
+ timeRemaining: string | null
36
+ }): string | null {
37
+ if (snap.resetAt) {
38
+ const diffMs = new Date(snap.resetAt).getTime() - Date.now()
39
+ if (diffMs > 0) {
40
+ const totalMin = Math.round(diffMs / 60_000)
41
+ const h = Math.floor(totalMin / 60)
42
+ const m = totalMin % 60
43
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
44
+ }
45
+ }
46
+ return snap.timeRemaining
47
+ }
48
+
49
+ export function AIUsageIndicator() {
50
+ const t = useTokens()
51
+ const enabled = useAIUsageStore((s) => s.enabled)
52
+ const snapshots = useAIUsageStore((s) => s.snapshots)
53
+
54
+ if (!enabled) return null
55
+
56
+ const ordered: AIUsageTool[] = ['claude', 'codex']
57
+ const entries = ordered
58
+ .map((tool) => ({ snap: snapshots[tool], tool }))
59
+ .filter((entry) => entry.snap !== undefined)
60
+
61
+ if (entries.length === 0) {
62
+ return (
63
+ <box flexDirection="row" gap={1}>
64
+ <text fg={t.muted}>…</text>
65
+ </box>
66
+ )
67
+ }
68
+
69
+ 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
+ >
80
+ {entries.map(({ snap, tool }) => {
81
+ if (!snap) return null
82
+ const icon = TOOL_ICON[tool]
83
+ if (snap.error) {
84
+ return (
85
+ <text key={tool} fg={t.palette.error} selectable={false}>
86
+ {icon} —
87
+ </text>
88
+ )
89
+ }
90
+ if (snap.percent !== null) {
91
+ const p = Math.round(snap.percent)
92
+ let color = t.palette.success
93
+ if (p >= 85) {
94
+ color = t.palette.error
95
+ } else if (p >= 60) {
96
+ color = t.palette.warning
97
+ }
98
+ const { empty, filled } = buildBar(snap.percent)
99
+ const reset = formatResetIn(snap)
100
+ const pctText = `${String(p).padStart(2, ' ')}%`
101
+ return (
102
+ <box key={tool} flexDirection="row">
103
+ <text fg={color} selectable={false}>
104
+ {icon}{' '}
105
+ </text>
106
+ <text fg={color} selectable={false}>
107
+ {filled}
108
+ </text>
109
+ <text fg={t.muted} selectable={false}>
110
+ {empty}
111
+ </text>
112
+ <text fg={t.palette.ink} selectable={false}>
113
+ {` ${pctText}`}
114
+ </text>
115
+ {reset ? (
116
+ <text fg={t.muted} selectable={false}>
117
+ {` · ${reset}`}
118
+ </text>
119
+ ) : null}
120
+ </box>
121
+ )
122
+ }
123
+ return (
124
+ <text key={tool} fg={t.muted} selectable={false}>
125
+ {icon} {formatTokens(snap.tokens.total)}
126
+ </text>
127
+ )
128
+ })}
129
+ </box>
130
+ )
131
+ }
@@ -0,0 +1,152 @@
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
+ }
@@ -53,8 +53,10 @@ export function ContextMenuOverlay() {
53
53
  const maxLabel = Math.max(...menu.items.map(([label]) => label.length))
54
54
  const width = maxLabel + 4
55
55
  const height = menu.items.length + 2
56
- const left = Math.max(0, Math.min(menu.anchorX, terminalCols - width))
57
- const top = Math.max(0, Math.min(menu.anchorY, terminalRows - height))
56
+ const left =
57
+ menu.anchorX + width <= terminalCols ? menu.anchorX : Math.max(0, menu.anchorX - width)
58
+ const top =
59
+ menu.anchorY + height <= terminalRows ? menu.anchorY : Math.max(0, menu.anchorY - height)
58
60
 
59
61
  return (
60
62
  <box position="absolute" top={0} left={0} width="100%" height="100%">