@brimveyn/aimux 1.14.1 → 1.14.2

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.2",
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
  () => {
@@ -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
  }