@brimveyn/aimux 1.14.1 → 1.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app.tsx +2 -0
- package/src/ui/components/git/git-panel.tsx +13 -6
- package/src/ui/components/git/git-view.tsx +9 -28
- package/src/ui/components/git/pane/git-pane-header.tsx +127 -0
- package/src/ui/components/git/pane/git-pane-widget.tsx +16 -10
- package/src/ui/components/layout/status-bar.tsx +168 -33
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +22 -93
- package/src/ui/status-bar-model.ts +36 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.3",
|
|
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.6.
|
|
63
|
+
"@brimveyn/aimux-config": "0.6.5",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@resvg/resvg-wasm": "^2.6.2",
|
package/src/app.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
setAutoCommitEnabled,
|
|
4
4
|
setExternalEditorConfig,
|
|
5
5
|
setMultiRepoConfig,
|
|
6
|
+
setStatusBarSeparator,
|
|
6
7
|
} from '@brimveyn/aimux-config'
|
|
7
8
|
import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
|
|
8
9
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
@@ -68,6 +69,7 @@ export function App({
|
|
|
68
69
|
setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
|
|
69
70
|
setMultiRepoConfig(resolvedConfig.multiRepo)
|
|
70
71
|
setExternalEditorConfig(resolvedConfig.externalEditor)
|
|
72
|
+
setStatusBarSeparator(resolvedConfig.statusBar?.separator)
|
|
71
73
|
|
|
72
74
|
const keymapHandlers = useMemo(
|
|
73
75
|
() => {
|
|
@@ -33,6 +33,10 @@ interface GitPanelProps {
|
|
|
33
33
|
// ("vs <base>") rather than a HEAD~N history walk.
|
|
34
34
|
baseLabel?: string
|
|
35
35
|
compact?: boolean
|
|
36
|
+
// When false, suppress the inline tree|flat toggle on section headers and the
|
|
37
|
+
// top ↑n ↓m remote-tracking line so a wrapper can own them at panel level.
|
|
38
|
+
showFileListToggle?: boolean
|
|
39
|
+
showRemoteTracking?: boolean
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
function sectionTitle(section: GitFileSection, headOffset: number, baseLabel?: string): string {
|
|
@@ -389,6 +393,8 @@ export const GitPanel = memo(function GitPanel({
|
|
|
389
393
|
pathConfig = DEFAULT_PATH_CONFIG,
|
|
390
394
|
projectPath,
|
|
391
395
|
selectedEntryKey,
|
|
396
|
+
showFileListToggle = true,
|
|
397
|
+
showRemoteTracking = true,
|
|
392
398
|
}: GitPanelProps) {
|
|
393
399
|
const t = useTheme()
|
|
394
400
|
useTransparent()
|
|
@@ -407,12 +413,13 @@ export const GitPanel = memo(function GitPanel({
|
|
|
407
413
|
|
|
408
414
|
const statusNode = renderStatus(gitPanel, !!(projectPath != null && projectPath !== ''))
|
|
409
415
|
|
|
410
|
-
const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
|
|
411
|
-
const toggleSection =
|
|
412
|
-
tree.sections.find((section) => section.section === 'unstaged' && section.files.length > 0)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
+
const hasRemoteTracking = showRemoteTracking && (gitPanel.ahead > 0 || gitPanel.behind > 0)
|
|
417
|
+
const toggleSection = showFileListToggle
|
|
418
|
+
? (tree.sections.find((section) => section.section === 'unstaged' && section.files.length > 0)
|
|
419
|
+
?.section ??
|
|
420
|
+
tree.sections.find((section) => section.files.length > 0)?.section ??
|
|
421
|
+
null)
|
|
422
|
+
: null
|
|
416
423
|
|
|
417
424
|
useEffect(() => {
|
|
418
425
|
const scrollbox = scrollRef.current
|
|
@@ -21,6 +21,7 @@ import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
|
|
|
21
21
|
import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
|
|
22
22
|
import { GitPanel } from './git-panel'
|
|
23
23
|
import { ImageDiffView } from './image-diff'
|
|
24
|
+
import { GitPaneHeader } from './pane/git-pane-header'
|
|
24
25
|
|
|
25
26
|
interface DiffStageProps {
|
|
26
27
|
diff: DiffData | undefined
|
|
@@ -287,34 +288,12 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
287
288
|
gap={0}
|
|
288
289
|
overflow="hidden"
|
|
289
290
|
>
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
|
|
296
|
-
<text fg={t.text}>{'\u{e702}'} </text>
|
|
297
|
-
<text fg={t.text}>{gitPanel.branch}</text>
|
|
298
|
-
</box>
|
|
299
|
-
) : null}
|
|
300
|
-
{gitMode.headOffset > 0 ? (
|
|
301
|
-
<box flexDirection="row" gap={1}>
|
|
302
|
-
<text fg={t.warning}>
|
|
303
|
-
<strong>HEAD~{gitMode.headOffset}</strong>
|
|
304
|
-
</text>
|
|
305
|
-
<text fg={t.textMuted}>[ newer · ] older</text>
|
|
306
|
-
</box>
|
|
307
|
-
) : null}
|
|
308
|
-
{baseLabel != null ? (
|
|
309
|
-
<box flexDirection="row" gap={1}>
|
|
310
|
-
<text fg={t.primary}>
|
|
311
|
-
<strong>{baseLabel}</strong>
|
|
312
|
-
</text>
|
|
313
|
-
<text fg={t.textMuted}>b: back</text>
|
|
314
|
-
</box>
|
|
315
|
-
) : null}
|
|
316
|
-
<text fg={t.textMuted}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
|
|
317
|
-
</box>
|
|
291
|
+
<GitPaneHeader
|
|
292
|
+
baseLabel={baseLabel}
|
|
293
|
+
gitPanel={gitPanel}
|
|
294
|
+
headOffset={gitMode.headOffset}
|
|
295
|
+
projectPath={projectPath}
|
|
296
|
+
/>
|
|
318
297
|
<GitPanel
|
|
319
298
|
baseLabel={baseLabel}
|
|
320
299
|
collapsedFolders={gitMode.collapsedFolders}
|
|
@@ -324,6 +303,8 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
324
303
|
headOffset={gitMode.headOffset}
|
|
325
304
|
projectPath={projectPath}
|
|
326
305
|
selectedEntryKey={gitMode.selectedEntryKey}
|
|
306
|
+
showFileListToggle={false}
|
|
307
|
+
showRemoteTracking={false}
|
|
327
308
|
/>
|
|
328
309
|
</box>
|
|
329
310
|
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { memo, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { GitPanelState } from '../../../../state/types'
|
|
4
|
+
|
|
5
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
6
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
7
|
+
import { useTheme } from '../../../theme'
|
|
8
|
+
|
|
9
|
+
interface GitPaneHeaderProps {
|
|
10
|
+
gitPanel: GitPanelState
|
|
11
|
+
projectPath: string | undefined
|
|
12
|
+
headOffset?: number
|
|
13
|
+
baseLabel?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const GitPaneHeader = memo(function GitPaneHeader({
|
|
17
|
+
baseLabel,
|
|
18
|
+
gitPanel,
|
|
19
|
+
headOffset = 0,
|
|
20
|
+
projectPath,
|
|
21
|
+
}: GitPaneHeaderProps) {
|
|
22
|
+
const t = useTheme()
|
|
23
|
+
const fileListMode = useAppStore((s) => s.gitPane.fileListMode)
|
|
24
|
+
const nextFileListMode = fileListMode === 'tree' ? 'flat' : 'tree'
|
|
25
|
+
const toggleListMode = useCallback(() => {
|
|
26
|
+
dispatchGlobal({ type: 'git-mode-toggle-file-list-mode' })
|
|
27
|
+
runSideEffectGlobal({ mode: nextFileListMode, type: 'persist-git-file-list-mode' })
|
|
28
|
+
}, [nextFileListMode])
|
|
29
|
+
|
|
30
|
+
const hasProject = projectPath != null && projectPath !== ''
|
|
31
|
+
if (!hasProject) return null
|
|
32
|
+
if (gitPanel.error !== null) return null
|
|
33
|
+
|
|
34
|
+
const branch = gitPanel.branch
|
|
35
|
+
const branchLabel = branch != null && branch !== '' ? branch : 'detached'
|
|
36
|
+
const branchIsResolved = branch != null && branch !== ''
|
|
37
|
+
|
|
38
|
+
const ahead = gitPanel.ahead
|
|
39
|
+
const behind = gitPanel.behind
|
|
40
|
+
const showAhead = ahead > 0
|
|
41
|
+
const showBehind = behind > 0
|
|
42
|
+
const showTracking = showAhead || showBehind
|
|
43
|
+
|
|
44
|
+
const showToggle = gitPanel.files.length > 0
|
|
45
|
+
const showHistorical = headOffset > 0
|
|
46
|
+
const showReviewBase = baseLabel != null && baseLabel !== ''
|
|
47
|
+
const showScope = showHistorical || showReviewBase
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<box flexDirection="column" flexShrink={0} paddingBottom={1}>
|
|
51
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
52
|
+
<box flexDirection="row" flexShrink={1} overflow="hidden">
|
|
53
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
54
|
+
{'\u{e702}'}
|
|
55
|
+
</text>
|
|
56
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
57
|
+
{' '}
|
|
58
|
+
</text>
|
|
59
|
+
<text selectable={false} fg={branchIsResolved ? t.text : t.textMuted} wrapMode="none">
|
|
60
|
+
{branchIsResolved ? <strong>{branchLabel}</strong> : branchLabel}
|
|
61
|
+
</text>
|
|
62
|
+
</box>
|
|
63
|
+
<box flexDirection="row" flexShrink={0} gap={2}>
|
|
64
|
+
{showTracking ? (
|
|
65
|
+
<box flexDirection="row" gap={1}>
|
|
66
|
+
{showAhead ? (
|
|
67
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
68
|
+
{`↑${ahead}`}
|
|
69
|
+
</text>
|
|
70
|
+
) : null}
|
|
71
|
+
{showBehind ? (
|
|
72
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
73
|
+
{`↓${behind}`}
|
|
74
|
+
</text>
|
|
75
|
+
) : null}
|
|
76
|
+
</box>
|
|
77
|
+
) : null}
|
|
78
|
+
{showToggle ? (
|
|
79
|
+
<box flexDirection="row" gap={1} onMouseDown={toggleListMode}>
|
|
80
|
+
<text
|
|
81
|
+
selectable={false}
|
|
82
|
+
fg={fileListMode === 'tree' ? t.primary : t.textMuted}
|
|
83
|
+
wrapMode="none"
|
|
84
|
+
>
|
|
85
|
+
tree
|
|
86
|
+
</text>
|
|
87
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
88
|
+
|
|
|
89
|
+
</text>
|
|
90
|
+
<text
|
|
91
|
+
selectable={false}
|
|
92
|
+
fg={fileListMode === 'flat' ? t.primary : t.textMuted}
|
|
93
|
+
wrapMode="none"
|
|
94
|
+
>
|
|
95
|
+
flat
|
|
96
|
+
</text>
|
|
97
|
+
</box>
|
|
98
|
+
) : null}
|
|
99
|
+
</box>
|
|
100
|
+
</box>
|
|
101
|
+
{showScope ? (
|
|
102
|
+
<box flexDirection="row" gap={2}>
|
|
103
|
+
{showHistorical ? (
|
|
104
|
+
<text selectable={false} fg={t.warning} wrapMode="none">
|
|
105
|
+
<strong>HEAD~{headOffset}</strong>
|
|
106
|
+
</text>
|
|
107
|
+
) : null}
|
|
108
|
+
{showHistorical ? (
|
|
109
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
110
|
+
[ newer · ] older
|
|
111
|
+
</text>
|
|
112
|
+
) : null}
|
|
113
|
+
{showReviewBase ? (
|
|
114
|
+
<text selectable={false} fg={t.primary} wrapMode="none">
|
|
115
|
+
<strong>{baseLabel}</strong>
|
|
116
|
+
</text>
|
|
117
|
+
) : null}
|
|
118
|
+
{showReviewBase ? (
|
|
119
|
+
<text selectable={false} fg={t.textMuted} wrapMode="none">
|
|
120
|
+
b: back
|
|
121
|
+
</text>
|
|
122
|
+
) : null}
|
|
123
|
+
</box>
|
|
124
|
+
) : null}
|
|
125
|
+
</box>
|
|
126
|
+
)
|
|
127
|
+
})
|
|
@@ -7,6 +7,7 @@ import { useRepoDiscovery } from '../../../../git/use-repo-discovery'
|
|
|
7
7
|
import { useAppStore } from '../../../../state/app-store'
|
|
8
8
|
import { getSessionProjectPath } from '../../../../state/session-worktrees'
|
|
9
9
|
import { GitPanel } from '../git-panel'
|
|
10
|
+
import { GitPaneHeader } from './git-pane-header'
|
|
10
11
|
|
|
11
12
|
interface GitPaneWidgetProps {
|
|
12
13
|
pollingEnabled: boolean
|
|
@@ -43,15 +44,20 @@ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: Git
|
|
|
43
44
|
const display = lastGoodRef.current ?? gitPanel
|
|
44
45
|
|
|
45
46
|
return (
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
<box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
|
|
48
|
+
<GitPaneHeader gitPanel={display} projectPath={projectPath} />
|
|
49
|
+
<GitPanel
|
|
50
|
+
collapsedFolders={gitMode.collapsedFolders}
|
|
51
|
+
compact={treeCompaction}
|
|
52
|
+
diffCountConfig={diffCountConfig}
|
|
53
|
+
fileListMode={gitFileListMode}
|
|
54
|
+
gitPanel={display}
|
|
55
|
+
pathConfig={pathConfig}
|
|
56
|
+
projectPath={projectPath}
|
|
57
|
+
selectedEntryKey={gitMode.selectedEntryKey}
|
|
58
|
+
showFileListToggle={false}
|
|
59
|
+
showRemoteTracking={false}
|
|
60
|
+
/>
|
|
61
|
+
</box>
|
|
56
62
|
)
|
|
57
63
|
})
|
|
@@ -1,76 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getStatusBarSeparator,
|
|
3
|
+
type ResolvedTuiTheme,
|
|
4
|
+
type StatusBarSeparator,
|
|
5
|
+
} from '@brimveyn/aimux-config'
|
|
6
|
+
|
|
1
7
|
import type { AppState } from '../../../state/types'
|
|
2
8
|
|
|
3
9
|
import { version as APP_VERSION } from '../../../../package.json'
|
|
10
|
+
import { useAIUsageStore } from '../../../state/ai-usage-store'
|
|
4
11
|
import { useAppStore } from '../../../state/app-store'
|
|
5
12
|
import { useKeymap } from '../../keymap-context'
|
|
6
|
-
import { getStatusBarModel } from '../../status-bar-model'
|
|
7
|
-
import {
|
|
13
|
+
import { getStatusBarModel, type IdentitySegment } from '../../status-bar-model'
|
|
14
|
+
import { useTheme } from '../../theme'
|
|
8
15
|
import { AIUsageIndicator } from '../overlays/ai-usage/ai-usage-indicator'
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
// Powerline-style separator glyph pairs.
|
|
18
|
+
// `right` is rendered between left-anchored tiles (A→B, B→filler).
|
|
19
|
+
// `left` is rendered between right-anchored tiles (filler→Y, X→Y).
|
|
20
|
+
// `none` uses empty strings so bare background transitions remain visible.
|
|
21
|
+
const SEPARATOR_GLYPHS: Record<StatusBarSeparator, { left: string; right: string }> = {
|
|
22
|
+
arrow: { left: '\u{E0B2}', right: '\u{E0B0}' },
|
|
23
|
+
flame: { left: '\u{E0C2}', right: '\u{E0C0}' },
|
|
24
|
+
none: { left: '', right: '' },
|
|
25
|
+
round: { left: '\u{E0B6}', right: '\u{E0B4}' },
|
|
26
|
+
slant: { left: '\u{E0BA}', right: '\u{E0BC}' },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Mode label is padded to a fixed width so the A tile never resizes
|
|
30
|
+
// when switching modes — keeps the rest of the bar visually stable.
|
|
31
|
+
const MODE_LABEL_WIDTH = 6
|
|
32
|
+
// A tile width = padded label (6) + paddingLeft (1) + paddingRight (1).
|
|
33
|
+
// Row 2 indents past A + separator glyph + B's paddingLeft so the
|
|
34
|
+
// hints line up with the start of B's content.
|
|
35
|
+
const ROW2_INDENT = MODE_LABEL_WIDTH + 2 + 1 + 1
|
|
36
|
+
|
|
37
|
+
function getModeLabel(focusMode: AppState['focusMode']): string {
|
|
12
38
|
switch (focusMode) {
|
|
13
39
|
case 'terminal-input':
|
|
14
|
-
return
|
|
40
|
+
return 'INSERT'
|
|
15
41
|
case 'modal':
|
|
42
|
+
return 'MODAL'
|
|
16
43
|
case 'command-edit':
|
|
17
|
-
return
|
|
44
|
+
return 'EDIT'
|
|
18
45
|
case 'git':
|
|
19
|
-
return
|
|
46
|
+
return 'GIT'
|
|
20
47
|
case 'navigation':
|
|
21
48
|
default:
|
|
22
|
-
return
|
|
49
|
+
return 'NORMAL'
|
|
23
50
|
}
|
|
24
51
|
}
|
|
25
52
|
|
|
26
|
-
function
|
|
53
|
+
function getModeBadge(focusMode: AppState['focusMode']): string {
|
|
54
|
+
const label = getModeLabel(focusMode)
|
|
55
|
+
const totalPad = Math.max(0, MODE_LABEL_WIDTH - label.length)
|
|
56
|
+
const left = Math.floor(totalPad / 2)
|
|
57
|
+
const right = totalPad - left
|
|
58
|
+
return ' '.repeat(left) + label + ' '.repeat(right)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getModeColor(focusMode: AppState['focusMode'], t: ResolvedTuiTheme): string {
|
|
27
62
|
switch (focusMode) {
|
|
28
63
|
case 'terminal-input':
|
|
29
|
-
return
|
|
64
|
+
return t.primary
|
|
30
65
|
case 'modal':
|
|
31
|
-
return 'modal'
|
|
32
66
|
case 'command-edit':
|
|
33
|
-
return
|
|
67
|
+
return t.warning
|
|
34
68
|
case 'git':
|
|
35
|
-
return
|
|
69
|
+
return t.success
|
|
36
70
|
case 'navigation':
|
|
37
71
|
default:
|
|
38
|
-
return
|
|
72
|
+
return t.text
|
|
39
73
|
}
|
|
40
74
|
}
|
|
41
75
|
|
|
76
|
+
function composeAmbient(right: string, help: string): string {
|
|
77
|
+
if (right === '' && help === '') return ''
|
|
78
|
+
if (right === '') return help
|
|
79
|
+
if (help === '') return right
|
|
80
|
+
return `${right} · ${help}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function Segments({ segments, t }: { segments: IdentitySegment[]; t: ResolvedTuiTheme }) {
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
{segments.map((seg) => (
|
|
87
|
+
<text
|
|
88
|
+
key={seg.id}
|
|
89
|
+
fg={seg.tone === 'primary' ? t.text : t.textMuted}
|
|
90
|
+
wrapMode="none"
|
|
91
|
+
selectable={false}
|
|
92
|
+
>
|
|
93
|
+
{seg.text}
|
|
94
|
+
</text>
|
|
95
|
+
))}
|
|
96
|
+
</>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Separator({ bg, fg, glyph }: { bg: string; fg: string; glyph: string }) {
|
|
101
|
+
if (glyph === '') return null
|
|
102
|
+
return (
|
|
103
|
+
<box backgroundColor={bg}>
|
|
104
|
+
<text fg={fg} selectable={false}>
|
|
105
|
+
{glyph}
|
|
106
|
+
</text>
|
|
107
|
+
</box>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
42
111
|
export function StatusBar() {
|
|
43
112
|
const t = useTheme()
|
|
44
|
-
const headerBg = t.backgroundPanel
|
|
45
113
|
const state = useAppStore((s) => s)
|
|
46
|
-
const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
|
|
47
114
|
const config = useKeymap()
|
|
48
|
-
const model = getStatusBarModel(state,
|
|
115
|
+
const model = getStatusBarModel(state, config)
|
|
116
|
+
const modeColor = getModeColor(state.focusMode, t)
|
|
117
|
+
const ambient = composeAmbient(model.right, model.help)
|
|
118
|
+
const aiEnabled = useAIUsageStore((s) => s.enabled)
|
|
119
|
+
|
|
120
|
+
const glyphs = SEPARATOR_GLYPHS[getStatusBarSeparator()]
|
|
121
|
+
|
|
122
|
+
const tileB = t.backgroundElement
|
|
123
|
+
const tileFiller = t.backgroundPanel
|
|
124
|
+
const tileX = t.backgroundElement
|
|
125
|
+
const tileY = modeColor
|
|
126
|
+
|
|
127
|
+
const hasB = model.sessionSegments.length > 0
|
|
128
|
+
const hasX = aiEnabled
|
|
49
129
|
|
|
50
130
|
return (
|
|
51
131
|
<box
|
|
52
132
|
height={2}
|
|
53
133
|
flexShrink={0}
|
|
54
134
|
overflow="hidden"
|
|
55
|
-
paddingLeft={1}
|
|
56
|
-
paddingRight={1}
|
|
57
|
-
paddingTop={0}
|
|
58
|
-
paddingBottom={0}
|
|
59
135
|
flexDirection="column"
|
|
60
|
-
backgroundColor={
|
|
136
|
+
backgroundColor={tileFiller}
|
|
61
137
|
>
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<box flexDirection="row" gap={2}>
|
|
70
|
-
{model.help ? <text fg={t.textMuted}>{model.help}</text> : null}
|
|
71
|
-
<AIUsageIndicator />
|
|
72
|
-
<text fg={t.textMuted}>v{APP_VERSION}</text>
|
|
138
|
+
{/* Row 1 — lualine tiles */}
|
|
139
|
+
<box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
|
|
140
|
+
{/* A: mode */}
|
|
141
|
+
<box backgroundColor={modeColor} paddingLeft={1} paddingRight={1}>
|
|
142
|
+
<text fg={t.background} selectable={false}>
|
|
143
|
+
{getModeBadge(state.focusMode)}
|
|
144
|
+
</text>
|
|
73
145
|
</box>
|
|
146
|
+
|
|
147
|
+
{/* A → B (or A → filler if B empty) */}
|
|
148
|
+
<Separator glyph={glyphs.right} bg={hasB ? tileB : tileFiller} fg={modeColor} />
|
|
149
|
+
|
|
150
|
+
{hasB ? (
|
|
151
|
+
<>
|
|
152
|
+
<box
|
|
153
|
+
backgroundColor={tileB}
|
|
154
|
+
paddingLeft={1}
|
|
155
|
+
paddingRight={1}
|
|
156
|
+
flexDirection="row"
|
|
157
|
+
flexShrink={1}
|
|
158
|
+
overflow="hidden"
|
|
159
|
+
>
|
|
160
|
+
<Segments segments={model.sessionSegments} t={t} />
|
|
161
|
+
</box>
|
|
162
|
+
<Separator glyph={glyphs.right} bg={tileFiller} fg={tileB} />
|
|
163
|
+
</>
|
|
164
|
+
) : null}
|
|
165
|
+
|
|
166
|
+
{/* Filler */}
|
|
167
|
+
<box flexGrow={1} flexShrink={1} backgroundColor={tileFiller} />
|
|
168
|
+
|
|
169
|
+
{hasX ? (
|
|
170
|
+
<>
|
|
171
|
+
<Separator glyph={glyphs.left} bg={tileFiller} fg={tileX} />
|
|
172
|
+
<box
|
|
173
|
+
backgroundColor={tileX}
|
|
174
|
+
paddingLeft={1}
|
|
175
|
+
paddingRight={1}
|
|
176
|
+
flexDirection="row"
|
|
177
|
+
flexShrink={0}
|
|
178
|
+
>
|
|
179
|
+
<AIUsageIndicator />
|
|
180
|
+
</box>
|
|
181
|
+
<Separator glyph={glyphs.left} bg={tileX} fg={tileY} />
|
|
182
|
+
</>
|
|
183
|
+
) : (
|
|
184
|
+
<Separator glyph={glyphs.left} bg={tileFiller} fg={tileY} />
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{/* Y: version */}
|
|
188
|
+
<box backgroundColor={tileY} paddingLeft={1} paddingRight={1} flexShrink={0}>
|
|
189
|
+
<text fg={t.background} selectable={false}>
|
|
190
|
+
v{APP_VERSION}
|
|
191
|
+
</text>
|
|
192
|
+
</box>
|
|
193
|
+
</box>
|
|
194
|
+
|
|
195
|
+
{/* Row 2 — ambient hints */}
|
|
196
|
+
<box
|
|
197
|
+
height={1}
|
|
198
|
+
flexShrink={0}
|
|
199
|
+
flexDirection="row"
|
|
200
|
+
paddingLeft={ROW2_INDENT}
|
|
201
|
+
paddingRight={1}
|
|
202
|
+
overflow="hidden"
|
|
203
|
+
>
|
|
204
|
+
{ambient !== '' ? (
|
|
205
|
+
<text fg={t.textMuted} wrapMode="none" selectable={false}>
|
|
206
|
+
{ambient}
|
|
207
|
+
</text>
|
|
208
|
+
) : null}
|
|
74
209
|
</box>
|
|
75
210
|
</box>
|
|
76
211
|
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AIUsageTool } from '@brimveyn/aimux-config'
|
|
1
|
+
import type { AIUsageTool, ResolvedTuiTheme } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
3
|
import { useCallback } from 'react'
|
|
4
4
|
|
|
@@ -6,14 +6,7 @@ import { useAIUsageStore } from '../../../../state/ai-usage-store'
|
|
|
6
6
|
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
7
7
|
import { useTheme } from '../../../theme'
|
|
8
8
|
|
|
9
|
-
const
|
|
10
|
-
claude: 'CC',
|
|
11
|
-
codex: 'CO',
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const BAR_SEGMENTS = 4
|
|
15
|
-
const BAR_FILLED_CHAR = '\u{2501}'
|
|
16
|
-
const BAR_EMPTY_CHAR = '\u{2500}'
|
|
9
|
+
const DOT = '●'
|
|
17
10
|
|
|
18
11
|
function formatTokens(total: number): string {
|
|
19
12
|
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`
|
|
@@ -21,36 +14,14 @@ function formatTokens(total: number): string {
|
|
|
21
14
|
return String(total)
|
|
22
15
|
}
|
|
23
16
|
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
empty: BAR_EMPTY_CHAR.repeat(BAR_SEGMENTS - filledCount),
|
|
31
|
-
filled: BAR_FILLED_CHAR.repeat(filledCount),
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function formatResetIn(snap: {
|
|
36
|
-
resetAt: string | null
|
|
37
|
-
timeRemaining: string | null
|
|
38
|
-
}): string | null {
|
|
39
|
-
if (snap.resetAt != null && snap.resetAt !== '') {
|
|
40
|
-
const diffMs = new Date(snap.resetAt).getTime() - Date.now()
|
|
41
|
-
if (diffMs > 0) {
|
|
42
|
-
const totalMin = Math.round(diffMs / 60_000)
|
|
43
|
-
const h = Math.floor(totalMin / 60)
|
|
44
|
-
const m = totalMin % 60
|
|
45
|
-
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return snap.timeRemaining
|
|
17
|
+
function pickDotColor(t: ResolvedTuiTheme, percent: number): string {
|
|
18
|
+
if (percent >= 85) return t.error
|
|
19
|
+
if (percent >= 60) return t.warning
|
|
20
|
+
return t.success
|
|
49
21
|
}
|
|
50
22
|
|
|
51
23
|
export function AIUsageIndicator() {
|
|
52
24
|
const t = useTheme()
|
|
53
|
-
const bg = t.backgroundPanel
|
|
54
25
|
const enabled = useAIUsageStore((s) => s.enabled)
|
|
55
26
|
const snapshots = useAIUsageStore((s) => s.snapshots)
|
|
56
27
|
|
|
@@ -72,36 +43,24 @@ export function AIUsageIndicator() {
|
|
|
72
43
|
|
|
73
44
|
if (entries.length === 0) {
|
|
74
45
|
return (
|
|
75
|
-
<box
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
backgroundColor={bg}
|
|
80
|
-
onMouseDown={openModal}
|
|
81
|
-
>
|
|
82
|
-
<text fg={t.textMuted}>…</text>
|
|
46
|
+
<box flexDirection="row" onMouseDown={openModal}>
|
|
47
|
+
<text fg={t.textMuted} selectable={false}>
|
|
48
|
+
…
|
|
49
|
+
</text>
|
|
83
50
|
</box>
|
|
84
51
|
)
|
|
85
52
|
}
|
|
86
53
|
|
|
87
54
|
return (
|
|
88
|
-
<box flexDirection="row" gap={
|
|
55
|
+
<box flexDirection="row" gap={2} onMouseDown={openModal}>
|
|
89
56
|
{entries.map(({ snap, tool }) => {
|
|
90
57
|
if (!snap) return null
|
|
91
|
-
const icon = TOOL_ICON[tool]
|
|
92
58
|
|
|
93
59
|
if (snap.error != null && snap.error !== '' && !(snap.stale === true)) {
|
|
94
60
|
return (
|
|
95
|
-
<box
|
|
96
|
-
key={tool}
|
|
97
|
-
flexDirection="row"
|
|
98
|
-
paddingLeft={1}
|
|
99
|
-
paddingRight={1}
|
|
100
|
-
backgroundColor={bg}
|
|
101
|
-
onMouseDown={openModal}
|
|
102
|
-
>
|
|
61
|
+
<box key={tool} flexDirection="row">
|
|
103
62
|
<text fg={t.error} selectable={false}>
|
|
104
|
-
{
|
|
63
|
+
{DOT}
|
|
105
64
|
</text>
|
|
106
65
|
</box>
|
|
107
66
|
)
|
|
@@ -109,56 +68,26 @@ export function AIUsageIndicator() {
|
|
|
109
68
|
|
|
110
69
|
if (snap.percent !== null) {
|
|
111
70
|
const p = Math.round(snap.percent)
|
|
112
|
-
|
|
113
|
-
if (p >= 85) {
|
|
114
|
-
color = t.error
|
|
115
|
-
} else if (p >= 60) {
|
|
116
|
-
color = t.warning
|
|
117
|
-
}
|
|
118
|
-
const { empty, filled } = buildBar(snap.percent)
|
|
119
|
-
const reset = formatResetIn(snap)
|
|
120
|
-
const pctText = `${String(p).padStart(2, ' ')}%`
|
|
71
|
+
const color = pickDotColor(t, snap.percent)
|
|
121
72
|
return (
|
|
122
|
-
<box
|
|
123
|
-
key={tool}
|
|
124
|
-
flexDirection="row"
|
|
125
|
-
paddingLeft={1}
|
|
126
|
-
paddingRight={1}
|
|
127
|
-
backgroundColor={bg}
|
|
128
|
-
onMouseDown={openModal}
|
|
129
|
-
>
|
|
130
|
-
<text fg={color} selectable={false}>
|
|
131
|
-
{`${icon} `}
|
|
132
|
-
</text>
|
|
73
|
+
<box key={tool} flexDirection="row">
|
|
133
74
|
<text fg={color} selectable={false}>
|
|
134
|
-
{
|
|
135
|
-
</text>
|
|
136
|
-
<text fg={t.textMuted} selectable={false}>
|
|
137
|
-
{empty}
|
|
75
|
+
{DOT}
|
|
138
76
|
</text>
|
|
139
77
|
<text fg={t.text} selectable={false}>
|
|
140
|
-
{` ${
|
|
78
|
+
{` ${p}%`}
|
|
141
79
|
</text>
|
|
142
|
-
{reset != null && reset !== '' ? (
|
|
143
|
-
<text fg={t.textMuted} selectable={false}>
|
|
144
|
-
{` · ${reset}`}
|
|
145
|
-
</text>
|
|
146
|
-
) : null}
|
|
147
80
|
</box>
|
|
148
81
|
)
|
|
149
82
|
}
|
|
150
83
|
|
|
151
84
|
return (
|
|
152
|
-
<box
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
paddingRight={1}
|
|
157
|
-
backgroundColor={bg}
|
|
158
|
-
onMouseDown={openModal}
|
|
159
|
-
>
|
|
85
|
+
<box key={tool} flexDirection="row">
|
|
86
|
+
<text fg={t.textMuted} selectable={false}>
|
|
87
|
+
{DOT}
|
|
88
|
+
</text>
|
|
160
89
|
<text fg={t.textMuted} selectable={false}>
|
|
161
|
-
{
|
|
90
|
+
{` ${formatTokens(snap.tokens.total)}`}
|
|
162
91
|
</text>
|
|
163
92
|
</box>
|
|
164
93
|
)
|
|
@@ -1,36 +1,38 @@
|
|
|
1
1
|
import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
|
-
import type { AppState
|
|
3
|
+
import type { AppState } from '../state/types'
|
|
4
4
|
|
|
5
5
|
import { describeBindings } from '../input/keymap/describe-bindings'
|
|
6
6
|
import { getSessionProjectPath } from '../state/session-worktrees'
|
|
7
7
|
import { buildHintText } from './keymap-context'
|
|
8
8
|
import { abbreviatePath } from './path-format'
|
|
9
9
|
|
|
10
|
+
export interface IdentitySegment {
|
|
11
|
+
id: string
|
|
12
|
+
text: string
|
|
13
|
+
tone: 'primary' | 'muted'
|
|
14
|
+
}
|
|
15
|
+
|
|
10
16
|
export interface StatusBarModel {
|
|
11
|
-
left: string
|
|
12
|
-
right: string
|
|
13
17
|
help: string
|
|
18
|
+
right: string
|
|
19
|
+
sessionSegments: IdentitySegment[]
|
|
14
20
|
}
|
|
15
21
|
|
|
16
|
-
const MAX_TAB_LABEL_LENGTH = 24
|
|
17
22
|
const HINT_LIMIT = 6
|
|
18
23
|
const HELP_DESCRIPTION = 'Help'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
const SEP = ' · '
|
|
25
|
+
|
|
26
|
+
function sessionSegments(
|
|
27
|
+
sessionName: string,
|
|
28
|
+
sessionPath: string | null | undefined
|
|
29
|
+
): IdentitySegment[] {
|
|
30
|
+
const segs: IdentitySegment[] = [{ id: 'session', text: sessionName, tone: 'primary' }]
|
|
31
|
+
if (sessionPath != null && sessionPath !== '') {
|
|
32
|
+
segs.push({ id: 'sep-session-path', text: SEP, tone: 'muted' })
|
|
33
|
+
segs.push({ id: 'path', text: abbreviatePath(sessionPath), tone: 'muted' })
|
|
23
34
|
}
|
|
24
|
-
|
|
25
|
-
return `${label.slice(0, MAX_TAB_LABEL_LENGTH - 3)}...`
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function getActiveTabLabel(tab?: TabSession): string {
|
|
29
|
-
if (!tab) {
|
|
30
|
-
return 'no tab'
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return `${truncateLabel(tab.title)} (${tab.status})`
|
|
35
|
+
return segs
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const STAGING_DESCRIPTIONS = ['Stage', 'Unstage/delete', 'Commit', 'Push']
|
|
@@ -54,64 +56,61 @@ function helpHintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
|
|
|
54
56
|
return `${helpBinding.keysDisplay} ${helpBinding.description ?? ''}`.trim()
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
export function getStatusBarModel(
|
|
58
|
-
state: AppState,
|
|
59
|
-
activeTab: TabSession | undefined,
|
|
60
|
-
config: ResolvedKeymapConfig
|
|
61
|
-
): StatusBarModel {
|
|
59
|
+
export function getStatusBarModel(state: AppState, config: ResolvedKeymapConfig): StatusBarModel {
|
|
62
60
|
const currentSession =
|
|
63
61
|
state.currentSessionId != null && state.currentSessionId !== ''
|
|
64
62
|
? state.sessions.find((session) => session.id === state.currentSessionId)
|
|
65
63
|
: undefined
|
|
66
64
|
const sessionName = currentSession?.name ?? 'no workspace'
|
|
67
65
|
const sessionPath = getSessionProjectPath(currentSession)
|
|
68
|
-
const
|
|
69
|
-
sessionPath != null && sessionPath !== ''
|
|
70
|
-
? `${sessionName} (${abbreviatePath(sessionPath)})`
|
|
71
|
-
: sessionName
|
|
72
|
-
|
|
73
|
-
// \u{f0b1} = nf-fa-briefcase (session icon)
|
|
74
|
-
const sessionIcon = '\u{f0b1}'
|
|
66
|
+
const sessionSegs = sessionSegments(sessionName, sessionPath)
|
|
75
67
|
|
|
76
68
|
switch (state.focusMode) {
|
|
77
69
|
case 'terminal-input':
|
|
78
70
|
return {
|
|
79
71
|
help: '',
|
|
80
|
-
left: `${getActiveTabLabel(activeTab)} ${sessionIcon} ${sessionLabel}`,
|
|
81
72
|
right: hintForMode(config, 'terminal-input'),
|
|
73
|
+
sessionSegments: sessionSegs,
|
|
82
74
|
}
|
|
83
75
|
case 'modal': {
|
|
84
76
|
const modalMode = deriveModalModeId(state.modal.type)
|
|
85
77
|
return {
|
|
86
78
|
help: '',
|
|
87
|
-
left: `${sessionIcon} ${sessionLabel}`,
|
|
88
79
|
right: modalMode ? hintForMode(config, modalMode) : '',
|
|
80
|
+
sessionSegments: sessionSegs,
|
|
89
81
|
}
|
|
90
82
|
}
|
|
91
83
|
case 'git': {
|
|
92
84
|
const headOffset = state.gitMode.headOffset
|
|
93
|
-
const
|
|
94
|
-
|
|
85
|
+
const extras: IdentitySegment[] = []
|
|
86
|
+
if (headOffset > 0) {
|
|
87
|
+
extras.push({ id: 'sep-head-offset', text: SEP, tone: 'muted' })
|
|
88
|
+
extras.push({ id: 'head-offset', text: `HEAD~${headOffset}`, tone: 'muted' })
|
|
89
|
+
}
|
|
90
|
+
if (state.gitMode.reviewBase) {
|
|
91
|
+
extras.push({ id: 'sep-review-base', text: SEP, tone: 'muted' })
|
|
92
|
+
extras.push({ id: 'review-base', text: 'vs base', tone: 'muted' })
|
|
93
|
+
}
|
|
95
94
|
return {
|
|
96
95
|
help: helpHintForMode(config, 'git-mode'),
|
|
97
|
-
left: `${sessionIcon} ${sessionLabel}${offsetTag}${reviewTag}`,
|
|
98
96
|
right: hintForGitMode(config, headOffset),
|
|
97
|
+
sessionSegments: [...sessionSegs, ...extras],
|
|
99
98
|
}
|
|
100
99
|
}
|
|
101
100
|
case 'command-edit': {
|
|
102
101
|
const commandEditMode = deriveCommandEditModeId(state.modal.type)
|
|
103
102
|
return {
|
|
104
103
|
help: '',
|
|
105
|
-
left: `${sessionIcon} ${sessionLabel}`,
|
|
106
104
|
right: commandEditMode ? hintForMode(config, commandEditMode) : '',
|
|
105
|
+
sessionSegments: sessionSegs,
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
case 'navigation':
|
|
110
109
|
default:
|
|
111
110
|
return {
|
|
112
111
|
help: helpHintForMode(config, 'navigation'),
|
|
113
|
-
left: `${sessionIcon} ${sessionLabel} ${getActiveTabLabel(activeTab)}`,
|
|
114
112
|
right: hintForMode(config, 'navigation'),
|
|
113
|
+
sessionSegments: sessionSegs,
|
|
115
114
|
}
|
|
116
115
|
}
|
|
117
116
|
}
|