@brimveyn/aimux 1.7.4 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +99 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-terminal-resize.ts +20 -12
- package/src/app.tsx +59 -22
- 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 +24 -0
- 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 +7 -0
- package/src/input/modes/transitions.ts +6 -2
- package/src/input/modes/types.ts +5 -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 +215 -0
- package/src/services/ai-usage/adapters/codex.ts +242 -0
- 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 +107 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +51 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +36 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/modal-state.ts +119 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +24 -14
- package/src/state/types.ts +62 -2
- package/src/state/workspace-save.ts +4 -0
- package/src/ui/components/ai-usage-indicator.tsx +163 -0
- package/src/ui/components/ai-usage-modal.tsx +186 -0
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- 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 +3 -1
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +14 -2
- package/src/ui/status-bar-model.ts +1 -1
|
@@ -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
|
}
|
|
@@ -52,6 +54,10 @@ function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): G
|
|
|
52
54
|
return position === 'left' || position === 'right' ? position : 'left'
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function clampSidebarWidth(width: number): number {
|
|
58
|
+
return Math.min(DEFAULT_SIDEBAR_MAX_WIDTH, Math.max(DEFAULT_SIDEBAR_MIN_WIDTH, width))
|
|
59
|
+
}
|
|
60
|
+
|
|
55
61
|
export function createInitialState(
|
|
56
62
|
customCommands: Record<string, string> = {},
|
|
57
63
|
sessions: SessionRecord[] = [],
|
|
@@ -66,6 +72,7 @@ export function createInitialState(
|
|
|
66
72
|
)
|
|
67
73
|
return {
|
|
68
74
|
activeTabId: null,
|
|
75
|
+
autoCommit: EMPTY_AUTO_COMMIT_STATE,
|
|
69
76
|
currentSessionId: null,
|
|
70
77
|
customCommands,
|
|
71
78
|
focusMode: showSessionPicker ? 'command-edit' : 'navigation',
|
|
@@ -101,8 +108,8 @@ export function createInitialState(
|
|
|
101
108
|
sidebar: {
|
|
102
109
|
maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
|
|
103
110
|
minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
|
|
104
|
-
visible: true,
|
|
105
|
-
width: DEFAULT_SIDEBAR_WIDTH,
|
|
111
|
+
visible: overrides.sidebar?.visible ?? true,
|
|
112
|
+
width: clampSidebarWidth(overrides.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH),
|
|
106
113
|
},
|
|
107
114
|
snippets,
|
|
108
115
|
tabGroupMap: {},
|
|
@@ -129,6 +136,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
|
|
|
129
136
|
const gitModeState = reduceGitModeState(state, action)
|
|
130
137
|
if (gitModeState) return gitModeState
|
|
131
138
|
|
|
139
|
+
const autoCommitState = reduceAutoCommit(state, action)
|
|
140
|
+
if (autoCommitState) return autoCommitState
|
|
141
|
+
|
|
132
142
|
switch (action.type) {
|
|
133
143
|
case 'set-snippets':
|
|
134
144
|
return { ...state, snippets: action.snippets }
|
package/src/state/types.ts
CHANGED
|
@@ -44,6 +44,7 @@ export type ModalType =
|
|
|
44
44
|
| 'split-picker'
|
|
45
45
|
| 'git-commit'
|
|
46
46
|
| 'update-available'
|
|
47
|
+
| 'ai-usage'
|
|
47
48
|
| null
|
|
48
49
|
|
|
49
50
|
export interface TerminalSpan {
|
|
@@ -62,6 +63,7 @@ export interface TerminalLine {
|
|
|
62
63
|
|
|
63
64
|
export interface TerminalSnapshot {
|
|
64
65
|
lines: TerminalLine[]
|
|
66
|
+
tailLines?: TerminalLine[]
|
|
65
67
|
viewportY: number
|
|
66
68
|
baseY: number
|
|
67
69
|
cursorVisible: boolean
|
|
@@ -323,6 +325,7 @@ export interface ModalGitCommit extends ModalBase {
|
|
|
323
325
|
type: 'git-commit'
|
|
324
326
|
activeField: 'title' | 'body'
|
|
325
327
|
contentBuffer: string
|
|
328
|
+
stage: 'edit' | 'generating' | 'confirm'
|
|
326
329
|
}
|
|
327
330
|
|
|
328
331
|
export interface ModalCreateSession extends ModalBase {
|
|
@@ -346,6 +349,10 @@ export interface ModalUpdateAvailable extends ModalBase {
|
|
|
346
349
|
latestVersion: string
|
|
347
350
|
}
|
|
348
351
|
|
|
352
|
+
export interface ModalAIUsage extends ModalBase {
|
|
353
|
+
type: 'ai-usage'
|
|
354
|
+
}
|
|
355
|
+
|
|
349
356
|
export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
|
|
350
357
|
|
|
351
358
|
export interface DirectoryResult {
|
|
@@ -367,6 +374,7 @@ export type ModalState =
|
|
|
367
374
|
| ModalSnippetEditor
|
|
368
375
|
| ModalGitCommit
|
|
369
376
|
| ModalUpdateAvailable
|
|
377
|
+
| ModalAIUsage
|
|
370
378
|
|
|
371
379
|
export interface LayoutState {
|
|
372
380
|
terminalCols: number
|
|
@@ -397,6 +405,7 @@ export interface AppState {
|
|
|
397
405
|
customCommands: Record<AssistantId, string>
|
|
398
406
|
gitPanel: GitPanelState
|
|
399
407
|
gitMode: GitModeState
|
|
408
|
+
autoCommit: AutoCommitState
|
|
400
409
|
/** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
|
|
401
410
|
pendingChords: string[] | null
|
|
402
411
|
}
|
|
@@ -431,6 +440,7 @@ export type ModalAction =
|
|
|
431
440
|
| { type: 'open-theme-picker' }
|
|
432
441
|
| { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
|
|
433
442
|
| { type: 'set-modal-selection-index'; index: number }
|
|
443
|
+
| { type: 'open-ai-usage-modal' }
|
|
434
444
|
|
|
435
445
|
// -- Session actions --
|
|
436
446
|
export type SessionAction =
|
|
@@ -438,7 +448,7 @@ export type SessionAction =
|
|
|
438
448
|
| { type: 'set-sessions'; sessions: SessionRecord[] }
|
|
439
449
|
| { type: 'create-session-record'; session: SessionRecord }
|
|
440
450
|
| { type: 'rename-session-record'; sessionId: string; name: string }
|
|
441
|
-
| { type: 'delete-session-record'; sessionId: string }
|
|
451
|
+
| { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
|
|
442
452
|
| { type: 'reorder-sessions'; orderedIds: string[] }
|
|
443
453
|
| { type: 'set-session-status'; sessionId: string; status: SessionStatus }
|
|
444
454
|
|
|
@@ -527,6 +537,50 @@ export type GitPanelAction =
|
|
|
527
537
|
| { type: 'git-refresh-error'; kind: GitPanelError }
|
|
528
538
|
| { type: 'git-panel-reset' }
|
|
529
539
|
|
|
540
|
+
// -- Auto-commit state & actions --
|
|
541
|
+
export type AutoCommitSuggestion =
|
|
542
|
+
| { kind: 'idle' }
|
|
543
|
+
| {
|
|
544
|
+
kind: 'generating'
|
|
545
|
+
tabId: string
|
|
546
|
+
workingTreeHash: string
|
|
547
|
+
abortController: AbortController
|
|
548
|
+
startedAt: number
|
|
549
|
+
}
|
|
550
|
+
| {
|
|
551
|
+
kind: 'ready'
|
|
552
|
+
tabId: string
|
|
553
|
+
workingTreeHash: string
|
|
554
|
+
title: string
|
|
555
|
+
body: string
|
|
556
|
+
generatedAt: number
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export interface AutoCommitState {
|
|
560
|
+
bySession: Record<string, AutoCommitSuggestion>
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export const EMPTY_AUTO_COMMIT_STATE: AutoCommitState = { bySession: {} }
|
|
564
|
+
|
|
565
|
+
export type AutoCommitAction =
|
|
566
|
+
| {
|
|
567
|
+
type: 'auto-commit-generation-started'
|
|
568
|
+
sessionId: string
|
|
569
|
+
tabId: string
|
|
570
|
+
workingTreeHash: string
|
|
571
|
+
abortController: AbortController
|
|
572
|
+
startedAt: number
|
|
573
|
+
}
|
|
574
|
+
| {
|
|
575
|
+
type: 'auto-commit-generation-ready'
|
|
576
|
+
sessionId: string
|
|
577
|
+
workingTreeHash: string
|
|
578
|
+
title: string
|
|
579
|
+
body: string
|
|
580
|
+
generatedAt: number
|
|
581
|
+
}
|
|
582
|
+
| { type: 'auto-commit-clear'; sessionId: string }
|
|
583
|
+
|
|
530
584
|
export type GitModeAction =
|
|
531
585
|
| { type: 'enter-git-mode' }
|
|
532
586
|
| { type: 'exit-git-mode' }
|
|
@@ -576,7 +630,12 @@ export type GitModeAction =
|
|
|
576
630
|
fromSection: GitFileSection
|
|
577
631
|
toSection: GitFileSection | null
|
|
578
632
|
}
|
|
579
|
-
| { type: 'open-git-commit-modal' }
|
|
633
|
+
| { type: 'open-git-commit-modal'; sessionId?: string }
|
|
634
|
+
| { type: 'git-commit-enter-confirm' }
|
|
635
|
+
| { type: 'git-commit-leave-confirm' }
|
|
636
|
+
| { type: 'git-commit-enter-generating'; sessionId: string }
|
|
637
|
+
| { type: 'git-commit-leave-generating' }
|
|
638
|
+
| { type: 'git-commit-use-background-suggestion'; sessionId: string }
|
|
580
639
|
|
|
581
640
|
// -- Data actions --
|
|
582
641
|
export type DataAction =
|
|
@@ -593,3 +652,4 @@ export type AppAction =
|
|
|
593
652
|
| DataAction
|
|
594
653
|
| GitPanelAction
|
|
595
654
|
| GitModeAction
|
|
655
|
+
| AutoCommitAction
|
|
@@ -35,6 +35,10 @@ export function saveCurrentWorkspace(state: AppState): void {
|
|
|
35
35
|
},
|
|
36
36
|
sessionBarPosition: state.sessionBar.position,
|
|
37
37
|
sessionBarVisible: state.sessionBar.visible,
|
|
38
|
+
sidebar: {
|
|
39
|
+
visible: state.sidebar.visible,
|
|
40
|
+
width: state.sidebar.width,
|
|
41
|
+
},
|
|
38
42
|
})
|
|
39
43
|
saveSessionCatalog(
|
|
40
44
|
buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { useAIUsageStore } from '../../state/ai-usage-store'
|
|
4
|
+
import { dispatchGlobal } from '../../state/dispatch-ref'
|
|
5
|
+
import { useBg, 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): { empty: string; filled: 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 bg = useBg('elevated')
|
|
52
|
+
const enabled = useAIUsageStore((s) => s.enabled)
|
|
53
|
+
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
54
|
+
|
|
55
|
+
if (!enabled) return null
|
|
56
|
+
|
|
57
|
+
const ordered: AIUsageTool[] = ['claude', 'codex']
|
|
58
|
+
const entries = ordered
|
|
59
|
+
.map((tool) => ({ snap: snapshots[tool], tool }))
|
|
60
|
+
.filter((entry) => entry.snap !== undefined)
|
|
61
|
+
|
|
62
|
+
const openModal = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
e.stopPropagation()
|
|
65
|
+
dispatchGlobal({ type: 'open-ai-usage-modal' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (entries.length === 0) {
|
|
69
|
+
return (
|
|
70
|
+
<box
|
|
71
|
+
flexDirection="row"
|
|
72
|
+
paddingLeft={1}
|
|
73
|
+
paddingRight={1}
|
|
74
|
+
backgroundColor={bg}
|
|
75
|
+
onMouseDown={openModal}
|
|
76
|
+
>
|
|
77
|
+
<text fg={t.muted}>…</text>
|
|
78
|
+
</box>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<box flexDirection="row" gap={1}>
|
|
84
|
+
{entries.map(({ snap, tool }) => {
|
|
85
|
+
if (!snap) return null
|
|
86
|
+
const icon = TOOL_ICON[tool]
|
|
87
|
+
|
|
88
|
+
if (snap.error && !snap.stale) {
|
|
89
|
+
return (
|
|
90
|
+
<box
|
|
91
|
+
key={tool}
|
|
92
|
+
flexDirection="row"
|
|
93
|
+
paddingLeft={1}
|
|
94
|
+
paddingRight={1}
|
|
95
|
+
backgroundColor={bg}
|
|
96
|
+
onMouseDown={openModal}
|
|
97
|
+
>
|
|
98
|
+
<text fg={t.palette.error} selectable={false}>
|
|
99
|
+
{`${icon} —`}
|
|
100
|
+
</text>
|
|
101
|
+
</box>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (snap.percent !== null) {
|
|
106
|
+
const p = Math.round(snap.percent)
|
|
107
|
+
let color = t.palette.success
|
|
108
|
+
if (p >= 85) {
|
|
109
|
+
color = t.palette.error
|
|
110
|
+
} else if (p >= 60) {
|
|
111
|
+
color = t.palette.warning
|
|
112
|
+
}
|
|
113
|
+
const { empty, filled } = buildBar(snap.percent)
|
|
114
|
+
const reset = formatResetIn(snap)
|
|
115
|
+
const pctText = `${String(p).padStart(2, ' ')}%`
|
|
116
|
+
return (
|
|
117
|
+
<box
|
|
118
|
+
key={tool}
|
|
119
|
+
flexDirection="row"
|
|
120
|
+
paddingLeft={1}
|
|
121
|
+
paddingRight={1}
|
|
122
|
+
backgroundColor={bg}
|
|
123
|
+
onMouseDown={openModal}
|
|
124
|
+
>
|
|
125
|
+
<text fg={color} selectable={false}>
|
|
126
|
+
{`${icon} `}
|
|
127
|
+
</text>
|
|
128
|
+
<text fg={color} selectable={false}>
|
|
129
|
+
{filled}
|
|
130
|
+
</text>
|
|
131
|
+
<text fg={t.muted} selectable={false}>
|
|
132
|
+
{empty}
|
|
133
|
+
</text>
|
|
134
|
+
<text fg={t.palette.ink} selectable={false}>
|
|
135
|
+
{` ${pctText}`}
|
|
136
|
+
</text>
|
|
137
|
+
{reset ? (
|
|
138
|
+
<text fg={t.muted} selectable={false}>
|
|
139
|
+
{` · ${reset}`}
|
|
140
|
+
</text>
|
|
141
|
+
) : null}
|
|
142
|
+
</box>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<box
|
|
148
|
+
key={tool}
|
|
149
|
+
flexDirection="row"
|
|
150
|
+
paddingLeft={1}
|
|
151
|
+
paddingRight={1}
|
|
152
|
+
backgroundColor={bg}
|
|
153
|
+
onMouseDown={openModal}
|
|
154
|
+
>
|
|
155
|
+
<text fg={t.muted} selectable={false}>
|
|
156
|
+
{`${icon} ${formatTokens(snap.tokens.total)}`}
|
|
157
|
+
</text>
|
|
158
|
+
</box>
|
|
159
|
+
)
|
|
160
|
+
})}
|
|
161
|
+
</box>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import type { UsagePaceStage, UsageSnapshot, UsageWindow } from '../../services/ai-usage/types'
|
|
5
|
+
|
|
6
|
+
import { useAIUsageStore } from '../../state/ai-usage-store'
|
|
7
|
+
import { useTokens } from '../theme'
|
|
8
|
+
import { uiTokens } from '../ui-tokens'
|
|
9
|
+
import { ModalShell } from './modal-shell'
|
|
10
|
+
|
|
11
|
+
const TOOL_TITLE: Record<AIUsageTool, string> = {
|
|
12
|
+
claude: 'Claude',
|
|
13
|
+
codex: 'Codex',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BAR_SEGMENTS = 32
|
|
17
|
+
const BAR_FILLED_CHAR = '\u{2501}'
|
|
18
|
+
const BAR_EMPTY_CHAR = '\u{2500}'
|
|
19
|
+
|
|
20
|
+
function buildBar(percent: number | null): { empty: string; filled: string } {
|
|
21
|
+
const p = percent ?? 0
|
|
22
|
+
let filledCount = 0
|
|
23
|
+
for (let i = 0; i < BAR_SEGMENTS; i++) {
|
|
24
|
+
if (p > i * (100 / BAR_SEGMENTS)) filledCount++
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
|
|
28
|
+
filled: BAR_FILLED_CHAR.repeat(filledCount),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatRelative(iso: string, now: number = Date.now()): string {
|
|
33
|
+
const diffMs = now - new Date(iso).getTime()
|
|
34
|
+
if (!Number.isFinite(diffMs) || diffMs < 0) return 'just now'
|
|
35
|
+
const s = Math.floor(diffMs / 1000)
|
|
36
|
+
if (s < 10) return 'just now'
|
|
37
|
+
if (s < 60) return `${s}s ago`
|
|
38
|
+
const m = Math.floor(s / 60)
|
|
39
|
+
if (m < 60) return `${m}m ago`
|
|
40
|
+
const h = Math.floor(m / 60)
|
|
41
|
+
if (h < 24) return `${h}h ago`
|
|
42
|
+
const d = Math.floor(h / 24)
|
|
43
|
+
return `${d}d ago`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function paceStageIsAhead(stage: UsagePaceStage): boolean {
|
|
47
|
+
return stage === 'ahead' || stage === 'farAhead' || stage === 'slightlyAhead'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function paceStageIsBehind(stage: UsagePaceStage): boolean {
|
|
51
|
+
return stage === 'behind' || stage === 'farBehind' || stage === 'slightlyBehind'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function AIUsageModal() {
|
|
55
|
+
const t = useTokens()
|
|
56
|
+
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
57
|
+
|
|
58
|
+
const tools: AIUsageTool[] = ['claude', 'codex']
|
|
59
|
+
const sections = tools
|
|
60
|
+
.map((tool) => ({ snap: snapshots[tool], tool }))
|
|
61
|
+
.filter((s): s is { snap: UsageSnapshot; tool: AIUsageTool } => s.snap !== undefined)
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ModalShell
|
|
65
|
+
title="AI usage"
|
|
66
|
+
keybindsModeId="modal.ai-usage"
|
|
67
|
+
width={uiTokens.modalWidth.md}
|
|
68
|
+
listGap={1}
|
|
69
|
+
>
|
|
70
|
+
{sections.length === 0 ? (
|
|
71
|
+
<text fg={t.muted} selectable={false}>
|
|
72
|
+
no data yet — collecting…
|
|
73
|
+
</text>
|
|
74
|
+
) : (
|
|
75
|
+
<box flexDirection="column" gap={1}>
|
|
76
|
+
{sections.map(({ snap, tool }) => (
|
|
77
|
+
<ToolSection key={tool} snap={snap} tool={tool} />
|
|
78
|
+
))}
|
|
79
|
+
</box>
|
|
80
|
+
)}
|
|
81
|
+
</ModalShell>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ToolSectionProps {
|
|
86
|
+
snap: UsageSnapshot
|
|
87
|
+
tool: AIUsageTool
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ToolSection({ snap, tool }: ToolSectionProps) {
|
|
91
|
+
const t = useTokens()
|
|
92
|
+
const isHardError = Boolean(snap.error) && !snap.stale
|
|
93
|
+
const relative = formatRelative(snap.lastUpdated)
|
|
94
|
+
|
|
95
|
+
let body: ReactNode
|
|
96
|
+
if (isHardError) {
|
|
97
|
+
body = (
|
|
98
|
+
<text fg={t.palette.error} selectable={false}>
|
|
99
|
+
{`error: ${snap.error ?? ''}`}
|
|
100
|
+
</text>
|
|
101
|
+
)
|
|
102
|
+
} else if (snap.windows.length === 0) {
|
|
103
|
+
body = (
|
|
104
|
+
<text fg={t.muted} selectable={false}>
|
|
105
|
+
no window data
|
|
106
|
+
</text>
|
|
107
|
+
)
|
|
108
|
+
} else {
|
|
109
|
+
body = snap.windows.map((window) => <WindowRow key={window.kind} window={window} />)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<box flexDirection="column">
|
|
114
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
115
|
+
<text fg={t.accent} selectable={false}>
|
|
116
|
+
{TOOL_TITLE[tool]}
|
|
117
|
+
</text>
|
|
118
|
+
{snap.planTier ? (
|
|
119
|
+
<text fg={t.muted} selectable={false}>
|
|
120
|
+
{snap.planTier}
|
|
121
|
+
</text>
|
|
122
|
+
) : null}
|
|
123
|
+
</box>
|
|
124
|
+
<text fg={t.muted} selectable={false}>
|
|
125
|
+
{`Updated ${relative}`}
|
|
126
|
+
</text>
|
|
127
|
+
{body}
|
|
128
|
+
</box>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function WindowRow({ window }: { window: UsageWindow }) {
|
|
133
|
+
const t = useTokens()
|
|
134
|
+
const percent = window.percent
|
|
135
|
+
const { empty, filled } = buildBar(percent)
|
|
136
|
+
|
|
137
|
+
let barColor = t.palette.success
|
|
138
|
+
if (percent !== null) {
|
|
139
|
+
if (percent >= 85) barColor = t.palette.error
|
|
140
|
+
else if (percent >= 60) barColor = t.palette.warning
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pctText = percent === null ? '—' : `${Math.round(percent)}% used`
|
|
144
|
+
const resetText = window.timeRemaining ? `Resets in ${window.timeRemaining}` : null
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<box flexDirection="column" paddingTop={1}>
|
|
148
|
+
<text fg={t.palette.ink} selectable={false}>
|
|
149
|
+
{window.label}
|
|
150
|
+
</text>
|
|
151
|
+
<box flexDirection="row">
|
|
152
|
+
<text fg={barColor} selectable={false}>
|
|
153
|
+
{filled}
|
|
154
|
+
</text>
|
|
155
|
+
<text fg={t.muted} selectable={false}>
|
|
156
|
+
{empty}
|
|
157
|
+
</text>
|
|
158
|
+
</box>
|
|
159
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
160
|
+
<text fg={t.muted} selectable={false}>
|
|
161
|
+
{pctText}
|
|
162
|
+
</text>
|
|
163
|
+
{resetText ? (
|
|
164
|
+
<text fg={t.muted} selectable={false}>
|
|
165
|
+
{resetText}
|
|
166
|
+
</text>
|
|
167
|
+
) : null}
|
|
168
|
+
</box>
|
|
169
|
+
{window.pace ? <PaceLine pace={window.pace} /> : null}
|
|
170
|
+
</box>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function PaceLine({ pace }: { pace: NonNullable<UsageWindow['pace']> }) {
|
|
175
|
+
const t = useTokens()
|
|
176
|
+
let color = t.muted
|
|
177
|
+
if (paceStageIsBehind(pace.stage)) color = t.palette.warning
|
|
178
|
+
else if (paceStageIsAhead(pace.stage)) color = t.palette.success
|
|
179
|
+
|
|
180
|
+
const suffix = pace.rightText ? ` · ${pace.rightText}` : ''
|
|
181
|
+
return (
|
|
182
|
+
<text fg={color} selectable={false}>
|
|
183
|
+
{`Pace: ${pace.label}${suffix}`}
|
|
184
|
+
</text>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
@@ -48,7 +48,7 @@ export function CreateSessionModal({
|
|
|
48
48
|
|
|
49
49
|
return (
|
|
50
50
|
<ModalShell
|
|
51
|
-
title="Create
|
|
51
|
+
title="Create workspace"
|
|
52
52
|
keybindsModeId="modal.create-session"
|
|
53
53
|
width={uiTokens.modalWidth.xl}
|
|
54
54
|
>
|
|
@@ -88,7 +88,7 @@ export function CreateSessionModal({
|
|
|
88
88
|
</box>
|
|
89
89
|
|
|
90
90
|
<box flexDirection="column">
|
|
91
|
-
<text fg={nameActive ? t.palette.ink : t.muted}>
|
|
91
|
+
<text fg={nameActive ? t.palette.ink : t.muted}>Workspace name</text>
|
|
92
92
|
<InputField active={nameActive} value={sessionName} />
|
|
93
93
|
</box>
|
|
94
94
|
</ModalShell>
|