@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.
- package/README.md +27 -15
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +286 -0
- package/src/app-runtime/auto-commit-ref.ts +20 -0
- package/src/app-runtime/backend-attach-runtime.ts +3 -1
- package/src/app-runtime/session-actions.ts +7 -2
- package/src/app-runtime/side-effects.ts +111 -4
- package/src/app-runtime/split-drag-controller.ts +31 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-mouse-handlers.ts +93 -5
- package/src/app-runtime/use-terminal-resize.ts +24 -16
- package/src/app.tsx +71 -19
- package/src/auto-commit/default-auto-commit-prompt.md +46 -0
- package/src/auto-commit/headless-commands.ts +40 -0
- package/src/auto-commit/output-parser.ts +21 -0
- package/src/auto-commit/prompt-loader.ts +33 -0
- package/src/auto-commit/staging-mode.ts +5 -0
- package/src/auto-commit/strip-ansi.ts +13 -0
- package/src/auto-commit/suggestion-runner.ts +55 -0
- package/src/auto-commit/working-tree-hash.ts +24 -0
- package/src/config.ts +45 -2
- package/src/daemon/session-registry.ts +1 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/help-entries.ts +4 -4
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +4 -0
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +2 -8
- package/src/pty/assistant-status-detector.ts +1 -1
- package/src/pty/terminal-snapshot.ts +38 -4
- package/src/services/ai-usage/adapters/claude.ts +139 -0
- package/src/services/ai-usage/adapters/codex.ts +191 -0
- package/src/services/ai-usage/provider.ts +84 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +20 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +29 -0
- package/src/state/git-pane-sizing.ts +15 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/git-panel-state.ts +12 -7
- package/src/state/reducers/modal-state.ts +106 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/reducers/ui-state.ts +6 -0
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +26 -15
- package/src/state/types.ts +59 -3
- package/src/state/workspace-save.ts +6 -1
- package/src/ui/ai-usage/controller.ts +35 -0
- package/src/ui/components/ai-usage-indicator.tsx +131 -0
- package/src/ui/components/ai-usage-popover.tsx +152 -0
- package/src/ui/components/context-menu-overlay.tsx +4 -2
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- package/src/ui/components/git-pane-context-menu.ts +26 -0
- package/src/ui/components/session-bar.tsx +2 -2
- package/src/ui/components/session-picker-modal.tsx +4 -4
- package/src/ui/components/sidebar.tsx +117 -31
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +114 -13
- 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:
|
|
65
|
-
currentSessionId:
|
|
66
|
-
|
|
67
|
-
|
|
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:
|
|
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
|
}
|
package/src/state/store.ts
CHANGED
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
import
|
|
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 }
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
57
|
-
|
|
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%">
|