@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.1",
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.4",
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
- ?.section ??
414
- tree.sections.find((section) => section.files.length > 0)?.section ??
415
- null
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
- <box flexDirection="column" flexShrink={0}>
291
- <text fg={t.text}>
292
- <strong>aimux · git</strong>
293
- </text>
294
- {gitPanel.branch != null && gitPanel.branch !== '' ? (
295
- <box flexDirection="row">
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
- <GitPanel
47
- collapsedFolders={gitMode.collapsedFolders}
48
- compact={treeCompaction}
49
- diffCountConfig={diffCountConfig}
50
- fileListMode={gitFileListMode}
51
- gitPanel={display}
52
- pathConfig={pathConfig}
53
- projectPath={projectPath}
54
- selectedEntryKey={gitMode.selectedEntryKey}
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 { getCurrentTheme, useTheme } from '../../theme'
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
- function getModeColor(focusMode: AppState['focusMode']): string {
11
- const t = getCurrentTheme()
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 t.primary
40
+ return 'INSERT'
15
41
  case 'modal':
42
+ return 'MODAL'
16
43
  case 'command-edit':
17
- return t.warning
44
+ return 'EDIT'
18
45
  case 'git':
19
- return t.success
46
+ return 'GIT'
20
47
  case 'navigation':
21
48
  default:
22
- return t.text
49
+ return 'NORMAL'
23
50
  }
24
51
  }
25
52
 
26
- function getModeLabel(focusMode: AppState['focusMode']): string {
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 'input'
64
+ return t.primary
30
65
  case 'modal':
31
- return 'modal'
32
66
  case 'command-edit':
33
- return 'edit'
67
+ return t.warning
34
68
  case 'git':
35
- return 'git'
69
+ return t.success
36
70
  case 'navigation':
37
71
  default:
38
- return 'nav'
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, activeTab, config)
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={headerBg}
136
+ backgroundColor={tileFiller}
61
137
  >
62
- <box width="100%" flexDirection="row">
63
- <text fg={getModeColor(state.focusMode)}>[{getModeLabel(state.focusMode)}]</text>
64
- <text> </text>
65
- <text fg={t.text}>{model.left}</text>
66
- </box>
67
- <box width="100%" flexDirection="row" justifyContent="space-between">
68
- <text fg={t.textMuted}>{model.right}</text>
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 TOOL_ICON: Record<AIUsageTool, string> = {
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 buildBar(percent: number): { empty: string; filled: string } {
25
- let filledCount = 0
26
- for (let i = 0; i < BAR_SEGMENTS; i++) {
27
- if (percent > i * (100 / BAR_SEGMENTS)) filledCount++
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
- flexDirection="row"
77
- paddingLeft={1}
78
- paddingRight={1}
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={1}>
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
- {`${icon} —`}
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
- let color = t.success
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
- {filled}
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
- {` ${pctText}`}
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
- key={tool}
154
- flexDirection="row"
155
- paddingLeft={1}
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
- {`${icon} ${formatTokens(snap.tokens.total)}`}
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, TabSession } from '../state/types'
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
- function truncateLabel(label: string): string {
21
- if (label.length <= MAX_TAB_LABEL_LENGTH) {
22
- return label
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 sessionLabel =
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 offsetTag = headOffset > 0 ? ` HEAD~${headOffset}` : ''
94
- const reviewTag = state.gitMode.reviewBase ? ' vs base' : ''
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
  }