@brimveyn/aimux 1.5.4 → 1.6.1

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.
Files changed (67) hide show
  1. package/README.md +14 -2
  2. package/package.json +5 -3
  3. package/src/app-runtime/side-effects.ts +41 -6
  4. package/src/app.tsx +17 -5
  5. package/src/config.ts +29 -4
  6. package/src/diff-parser/clean-last-newline.ts +5 -0
  7. package/src/diff-parser/constants.ts +13 -0
  8. package/src/diff-parser/index.ts +12 -0
  9. package/src/diff-parser/parse-line-type.ts +29 -0
  10. package/src/diff-parser/parse-patch-files.ts +369 -0
  11. package/src/diff-parser/types.ts +75 -0
  12. package/src/git/git-diff.ts +24 -12
  13. package/src/git/git-poller.ts +11 -3
  14. package/src/git/git-status.ts +98 -17
  15. package/src/input/modes/bridge.ts +8 -0
  16. package/src/input/modes/transitions.ts +2 -1
  17. package/src/input/modes/types.ts +4 -1
  18. package/src/pty/terminal-snapshot.ts +5 -5
  19. package/src/state/git-tree.ts +220 -0
  20. package/src/state/reducers/git-mode-state.ts +276 -36
  21. package/src/state/reducers/git-panel-state.ts +35 -4
  22. package/src/state/reducers/modal-state.ts +43 -10
  23. package/src/state/store.ts +5 -1
  24. package/src/state/types.ts +46 -6
  25. package/src/state/workspace-save.ts +2 -0
  26. package/src/ui/components/create-session-modal.tsx +25 -8
  27. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  28. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  29. package/src/ui/components/diff-renderer/fold-strip.tsx +86 -0
  30. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  31. package/src/ui/components/diff-renderer/index.ts +1 -0
  32. package/src/ui/components/diff-renderer/pierre-diff.tsx +171 -0
  33. package/src/ui/components/diff-renderer/split-view.tsx +211 -0
  34. package/src/ui/components/diff-renderer/stacked-view.tsx +168 -0
  35. package/src/ui/components/git-commit-modal.tsx +16 -3
  36. package/src/ui/components/git-pane-widget.tsx +6 -1
  37. package/src/ui/components/git-panel.tsx +215 -86
  38. package/src/ui/components/git-view.tsx +103 -90
  39. package/src/ui/components/help-modal.tsx +45 -12
  40. package/src/ui/components/input-field.tsx +4 -3
  41. package/src/ui/components/list-item.tsx +11 -2
  42. package/src/ui/components/modal-filter-bar.tsx +3 -2
  43. package/src/ui/components/modal-keybinds-overlay.tsx +4 -3
  44. package/src/ui/components/modal-shell.tsx +5 -4
  45. package/src/ui/components/new-tab-modal.tsx +17 -4
  46. package/src/ui/components/pending-chord-overlay.tsx +5 -4
  47. package/src/ui/components/session-bar.tsx +13 -6
  48. package/src/ui/components/session-picker-modal.tsx +28 -10
  49. package/src/ui/components/sidebar.tsx +26 -11
  50. package/src/ui/components/snippet-editor-modal.tsx +18 -3
  51. package/src/ui/components/snippet-picker-modal.tsx +13 -4
  52. package/src/ui/components/split-layout.tsx +3 -2
  53. package/src/ui/components/status-bar.tsx +15 -10
  54. package/src/ui/components/surface.tsx +6 -6
  55. package/src/ui/components/tab-item.tsx +28 -17
  56. package/src/ui/components/terminal-pane.tsx +28 -16
  57. package/src/ui/components/theme-picker-modal.tsx +113 -17
  58. package/src/ui/components/update-available-modal.tsx +13 -2
  59. package/src/ui/filter-themes.ts +15 -0
  60. package/src/ui/keymap-context.ts +10 -2
  61. package/src/ui/root.tsx +29 -7
  62. package/src/ui/shiki.ts +49 -0
  63. package/src/ui/status-bar-model.ts +32 -4
  64. package/src/ui/theme-store.ts +36 -0
  65. package/src/ui/theme.ts +4 -17
  66. package/src/ui/themes.ts +23 -277
  67. package/src/ui/syntax.ts +0 -102
@@ -6,7 +6,7 @@ import type { TerminalContentOrigin } from '../../input/raw-input-handler'
6
6
  import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../state/types'
7
7
 
8
8
  import { logInputDebug } from '../../debug/input-log'
9
- import { theme } from '../theme'
9
+ import { getCurrentTheme, useTheme } from '../theme'
10
10
 
11
11
  interface TerminalPaneProps {
12
12
  tab?: TabSession
@@ -46,14 +46,14 @@ function getTitle(
46
46
 
47
47
  function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
48
48
  if (isActive && focusMode === 'terminal-input') {
49
- return theme.borderActive
49
+ return getCurrentTheme().colors['focusBorder']
50
50
  }
51
51
 
52
52
  if (isActive) {
53
- return theme.accentAlt
53
+ return getCurrentTheme().colors['terminal.ansiMagenta']
54
54
  }
55
55
 
56
- return theme.dim
56
+ return getCurrentTheme().colors['editor.lineHighlightBackground']
57
57
  }
58
58
 
59
59
  function renderSpan(span: TerminalSpan, key: string): ReactNode {
@@ -72,7 +72,7 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
72
72
  }
73
73
 
74
74
  return (
75
- <span key={key} fg={span.fg ?? theme.text} bg={span.bg}>
75
+ <span key={key} fg={span.fg ?? getCurrentTheme().colors['editor.foreground']} bg={span.bg}>
76
76
  {node}
77
77
  </span>
78
78
  )
@@ -87,10 +87,11 @@ const TerminalViewport = memo(function TerminalViewport({
87
87
  buffer,
88
88
  viewport,
89
89
  }: TerminalViewportProps) {
90
+ const theme = useTheme()
90
91
  if (viewport && viewport.lines.length > 0) {
91
92
  const lines = viewport.lines
92
93
  return (
93
- <text fg={theme.text}>
94
+ <text fg={theme.colors['editor.foreground']}>
94
95
  {lines.map((line, lineIndex) => (
95
96
  <span key={`line-${lineIndex}`}>
96
97
  {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`))}
@@ -101,7 +102,11 @@ const TerminalViewport = memo(function TerminalViewport({
101
102
  )
102
103
  }
103
104
 
104
- return <text fg={theme.text}>{buffer.length > 0 ? buffer : 'Waiting for session output...'}</text>
105
+ return (
106
+ <text fg={theme.colors['editor.foreground']}>
107
+ {buffer.length > 0 ? buffer : 'Waiting for session output...'}
108
+ </text>
109
+ )
105
110
  })
106
111
 
107
112
  export function TerminalPane({
@@ -119,6 +124,7 @@ export function TerminalPane({
119
124
  tab,
120
125
  tabId,
121
126
  }: TerminalPaneProps) {
127
+ const theme = useTheme()
122
128
  const paneIsActive = isActive ?? true
123
129
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
124
130
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
@@ -179,19 +185,19 @@ export function TerminalPane({
179
185
  padding={0}
180
186
  flexDirection="column"
181
187
  flexGrow={1}
182
- backgroundColor={theme.background}
188
+ backgroundColor={theme.colors['editor.background']}
183
189
  onMouseDrag={forwardMouseEvent}
184
190
  onMouseScroll={forwardScrollEvent}
185
191
  onMouseUp={forwardMouseEvent}
186
192
  >
187
193
  {!tab ? (
188
194
  <box flexGrow={1} justifyContent="center" alignItems="center" flexDirection="column">
189
- <text fg={theme.dim}>· · ·</text>
190
- <text fg={theme.textMuted}> </text>
195
+ <text fg={theme.colors['editor.lineHighlightBackground']}>· · ·</text>
196
+ <text fg={theme.colors['descriptionForeground']}> </text>
191
197
  <box flexDirection="row">
192
- <text fg={theme.textMuted}>Press </text>
193
- <text fg={theme.accent}>Ctrl+n</text>
194
- <text fg={theme.textMuted}> to launch an assistant</text>
198
+ <text fg={theme.colors['descriptionForeground']}>Press </text>
199
+ <text fg={theme.colors['textLink.foreground']}>Ctrl+n</text>
200
+ <text fg={theme.colors['descriptionForeground']}> to launch an assistant</text>
195
201
  </box>
196
202
  </box>
197
203
  ) : (
@@ -209,12 +215,18 @@ export function TerminalPane({
209
215
  )}
210
216
  </box>
211
217
  {tab?.status === 'exited' && tab.exitCode !== undefined ? (
212
- <text fg={theme.warning}>Process exited with code {tab.exitCode}</text>
218
+ <text fg={theme.colors['editorWarning.foreground']}>
219
+ Process exited with code {tab.exitCode}
220
+ </text>
213
221
  ) : null}
214
222
  {tab?.status === 'disconnected' ? (
215
- <text fg={theme.warning}>Restored snapshot. Press Ctrl+r to restart this session.</text>
223
+ <text fg={theme.colors['editorWarning.foreground']}>
224
+ Restored snapshot. Press Ctrl+r to restart this session.
225
+ </text>
226
+ ) : null}
227
+ {tab?.errorMessage ? (
228
+ <text fg={theme.colors['editorError.foreground']}>{tab.errorMessage}</text>
216
229
  ) : null}
217
- {tab?.errorMessage ? <text fg={theme.danger}>{tab.errorMessage}</text> : null}
218
230
  </box>
219
231
  )
220
232
  }
@@ -1,35 +1,131 @@
1
- import { theme } from '../theme'
2
- import { THEME_IDS, type ThemeId, THEMES } from '../themes'
1
+ import { useTerminalDimensions } from '@opentui/react'
2
+ import { useLayoutEffect, useMemo, useRef } from 'react'
3
+
4
+ import { dispatchGlobal } from '../../state/dispatch-ref'
5
+ import { filterThemeIds } from '../filter-themes'
6
+ import { useTheme } from '../theme'
7
+ import { type ThemeId, THEMES } from '../themes'
3
8
  import { uiTokens } from '../ui-tokens'
4
9
  import { ListItem } from './list-item'
10
+ import { ModalFilterBar } from './modal-filter-bar'
5
11
  import { ModalShell } from './modal-shell'
6
12
 
7
13
  interface ThemePickerModalProps {
8
- selectedIndex: number
9
14
  currentThemeId: ThemeId
15
+ filter: string | null
16
+ selectedIndex: number
10
17
  }
11
18
 
12
- export function ThemePickerModal({ currentThemeId, selectedIndex }: ThemePickerModalProps) {
19
+ const VIEWPORT_HEIGHT_RATIO = 0.6
20
+ const MODAL_CHROME_ROWS = 6
21
+
22
+ function clampSelection(index: number, count: number): number {
23
+ if (count === 0) return 0
24
+ return Math.max(0, Math.min(count - 1, index))
25
+ }
26
+
27
+ function computeWindowStart(
28
+ prevStart: number,
29
+ selectedRowIndex: number,
30
+ total: number,
31
+ windowSize: number
32
+ ): number {
33
+ if (total <= windowSize) return 0
34
+ const margin = 1
35
+ const maxStart = total - windowSize
36
+ let start = Math.max(0, Math.min(maxStart, prevStart))
37
+ const topThreshold = start + margin
38
+ const bottomThreshold = start + windowSize - 1 - margin
39
+ if (selectedRowIndex < topThreshold) {
40
+ start = Math.max(0, selectedRowIndex - margin)
41
+ } else if (selectedRowIndex > bottomThreshold) {
42
+ start = Math.min(maxStart, selectedRowIndex - windowSize + 1 + margin)
43
+ }
44
+ return start
45
+ }
46
+
47
+ export function ThemePickerModal({ currentThemeId, filter, selectedIndex }: ThemePickerModalProps) {
48
+ const theme = useTheme()
49
+ const dimensions = useTerminalDimensions()
50
+ const filtered = useMemo(() => filterThemeIds(filter), [filter])
51
+
52
+ useLayoutEffect(() => {
53
+ dispatchGlobal({ count: filtered.length, type: 'set-theme-entry-count' })
54
+ }, [filtered.length])
55
+
56
+ const effectiveIndex = clampSelection(selectedIndex, filtered.length)
57
+ const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
58
+ const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
59
+
60
+ const prevStartRef = useRef(0)
61
+ const prevFilterRef = useRef<string | null>(filter)
62
+ if (prevFilterRef.current !== filter) {
63
+ prevFilterRef.current = filter
64
+ prevStartRef.current = 0
65
+ }
66
+ const start = computeWindowStart(
67
+ prevStartRef.current,
68
+ effectiveIndex,
69
+ filtered.length,
70
+ listHeight
71
+ )
72
+ prevStartRef.current = start
73
+ const visible = filtered.slice(start, start + listHeight)
74
+
13
75
  return (
14
76
  <ModalShell
15
77
  title="Select theme"
16
78
  keybindsModeId="modal.theme-picker"
17
79
  width={uiTokens.modalWidth.md}
18
80
  listGap={0}
81
+ footer={
82
+ <box flexDirection="column" gap={0}>
83
+ <text fg={theme.colors['editor.lineHighlightBackground']}>
84
+ {filtered.length === 0
85
+ ? ''
86
+ : ` ${effectiveIndex + 1} / ${filtered.length}${filter ? '' : ' — type / to filter'}`}
87
+ </text>
88
+ <ModalFilterBar filter={filter} />
89
+ </box>
90
+ }
19
91
  >
20
- {THEME_IDS.map((id, index) => {
21
- const entry = THEMES[id]
22
- const active = index === selectedIndex
23
- const isCurrent = id === currentThemeId
24
- return (
25
- <ListItem
26
- key={id}
27
- active={active}
28
- title={<text fg={active ? theme.text : theme.textMuted}>{entry.name}</text>}
29
- trailing={isCurrent ? <text fg={theme.accent}>current</text> : undefined}
30
- />
31
- )
32
- })}
92
+ {filtered.length === 0 ? (
93
+ <text fg={theme.colors['descriptionForeground']}>
94
+ {filter ? 'No matching themes.' : 'No themes available.'}
95
+ </text>
96
+ ) : (
97
+ <box height={listHeight} flexDirection="column" overflow="hidden">
98
+ {visible.map((id, i) => {
99
+ const rowIndex = start + i
100
+ const entry = THEMES[id]
101
+ if (!entry) return null
102
+ const active = rowIndex === effectiveIndex
103
+ const isCurrent = id === currentThemeId
104
+ return (
105
+ <ListItem
106
+ key={id}
107
+ active={active}
108
+ title={
109
+ <text
110
+ fg={
111
+ active
112
+ ? theme.colors['editor.foreground']
113
+ : theme.colors['descriptionForeground']
114
+ }
115
+ >
116
+ {entry.name}
117
+ </text>
118
+ }
119
+ trailing={
120
+ isCurrent ? (
121
+ <text fg={theme.colors['textLink.foreground']}>current</text>
122
+ ) : undefined
123
+ }
124
+ />
125
+ )
126
+ })}
127
+ </box>
128
+ )}
33
129
  </ModalShell>
34
130
  )
35
131
  }
@@ -1,4 +1,4 @@
1
- import { theme } from '../theme'
1
+ import { useTheme } from '../theme'
2
2
  import { uiTokens } from '../ui-tokens'
3
3
  import { ListItem } from './list-item'
4
4
  import { ModalShell } from './modal-shell'
@@ -19,6 +19,7 @@ export function UpdateAvailableModal({
19
19
  latestVersion,
20
20
  selectedIndex,
21
21
  }: UpdateAvailableModalProps) {
22
+ const theme = useTheme()
22
23
  return (
23
24
  <ModalShell
24
25
  title="Update available"
@@ -35,7 +36,17 @@ export function UpdateAvailableModal({
35
36
  key={option.label}
36
37
  active={active}
37
38
  direction="row"
38
- title={<text fg={active ? theme.text : theme.textMuted}>{option.label}</text>}
39
+ title={
40
+ <text
41
+ fg={
42
+ active
43
+ ? theme.colors['editor.foreground']
44
+ : theme.colors['descriptionForeground']
45
+ }
46
+ >
47
+ {option.label}
48
+ </text>
49
+ }
39
50
  />
40
51
  )
41
52
  })}
@@ -0,0 +1,15 @@
1
+ import { THEME_IDS, type ThemeId, THEMES } from './themes'
2
+
3
+ export function filterThemeIds(filter: string | null): ThemeId[] {
4
+ if (!filter) return THEME_IDS
5
+ const needle = filter.toLowerCase()
6
+ return THEME_IDS.filter((id) => {
7
+ const entry = THEMES[id]
8
+ if (!entry) return false
9
+ return (
10
+ id.toLowerCase().includes(needle) ||
11
+ entry.displayName.toLowerCase().includes(needle) ||
12
+ entry.name.toLowerCase().includes(needle)
13
+ )
14
+ })
15
+ }
@@ -18,17 +18,25 @@ export function useKeymap(): ResolvedKeymapConfig {
18
18
  const HELP_JOINER = ' · '
19
19
  const MAX_HELP_ENTRIES = 5
20
20
 
21
+ interface BuildHintOptions {
22
+ excludeDescriptions?: readonly string[]
23
+ }
24
+
21
25
  /** Build a single-line help string for a mode (used in modal headers and status bar). */
22
26
  export function buildHintText(
23
27
  config: ResolvedKeymapConfig,
24
28
  modeId: ModeId,
25
- limit: number = MAX_HELP_ENTRIES
29
+ limit: number = MAX_HELP_ENTRIES,
30
+ options: BuildHintOptions = {}
26
31
  ): string {
27
32
  const bindings = describeBindings(config, modeId, {
28
33
  dedupeByDescription: true,
29
34
  withDescriptionOnly: true,
30
35
  })
31
36
  if (bindings.length === 0) return ''
32
- const trimmed = bindings.slice(0, limit)
37
+ const excluded = new Set(options.excludeDescriptions ?? [])
38
+ const kept =
39
+ excluded.size > 0 ? bindings.filter((b) => !excluded.has(b.description ?? '')) : bindings
40
+ const trimmed = kept.slice(0, limit)
33
41
  return trimmed.map((b) => `${b.keysDisplay} ${b.description ?? ''}`.trim()).join(HELP_JOINER)
34
42
  }
package/src/ui/root.tsx CHANGED
@@ -24,7 +24,7 @@ import { StatusBar } from './components/status-bar'
24
24
  import { TerminalPane } from './components/terminal-pane'
25
25
  import { ThemePickerModal } from './components/theme-picker-modal'
26
26
  import { UpdateAvailableModal } from './components/update-available-modal'
27
- import { theme } from './theme'
27
+ import { useTheme } from './theme'
28
28
 
29
29
  function getCreateSessionFields(modal: ModalState) {
30
30
  if (modal.type !== 'create-session') {
@@ -134,7 +134,11 @@ function renderModal(
134
134
  )
135
135
  case 'theme-picker':
136
136
  return (
137
- <ThemePickerModal selectedIndex={modal.selectedIndex} currentThemeId={options.themeId} />
137
+ <ThemePickerModal
138
+ selectedIndex={modal.selectedIndex}
139
+ currentThemeId={options.themeId}
140
+ filter={modal.editBuffer}
141
+ />
138
142
  )
139
143
  case 'update-available':
140
144
  return (
@@ -145,7 +149,13 @@ function renderModal(
145
149
  />
146
150
  )
147
151
  case 'help':
148
- return <HelpModal filter={modal.editBuffer} selectedIndex={modal.selectedIndex} />
152
+ return (
153
+ <HelpModal
154
+ filter={modal.editBuffer}
155
+ selectedIndex={modal.selectedIndex}
156
+ scope={modal.scope}
157
+ />
158
+ )
149
159
  case 'git-commit': {
150
160
  const titleText =
151
161
  modal.activeField === 'title' ? (modal.editBuffer ?? '') : modal.contentBuffer
@@ -205,6 +215,7 @@ export function RootView({
205
215
  terminalRows,
206
216
  themeId,
207
217
  }: RootViewProps) {
218
+ const theme = useTheme()
208
219
  const tabs = useAppStore((s) => s.tabs)
209
220
  const activeTabId = useAppStore((s) => s.activeTabId)
210
221
  const layoutTrees = useAppStore((s) => s.layoutTrees)
@@ -230,8 +241,13 @@ export function RootView({
230
241
  const inGitMode = focusMode === 'git' || modal.type === 'git-commit'
231
242
  if (inGitMode) {
232
243
  return (
233
- <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
234
- <GitView />
244
+ <box
245
+ flexDirection="column"
246
+ width="100%"
247
+ height="100%"
248
+ backgroundColor={theme.colors['editor.background']}
249
+ >
250
+ <GitView themeId={themeId} />
235
251
  <StatusBar />
236
252
  <PendingChordOverlay />
237
253
  {renderModal(modal, {
@@ -249,7 +265,12 @@ export function RootView({
249
265
  }
250
266
 
251
267
  return (
252
- <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
268
+ <box
269
+ flexDirection="column"
270
+ width="100%"
271
+ height="100%"
272
+ backgroundColor={theme.colors['editor.background']}
273
+ >
253
274
  {sessionBarPosition === 'top' && <SessionBar />}
254
275
  <box flexDirection="row" gap={0} padding={0} flexGrow={1}>
255
276
  <Sidebar onTabActivate={onPaneActivate} />
@@ -322,6 +343,7 @@ export function RootView({
322
343
  }
323
344
 
324
345
  function GitPaneInPaneMode({ ratio }: { ratio: number }) {
346
+ const theme = useTheme()
325
347
  // Ratio maps to a fixed column count (20..80), mirroring the reservation in
326
348
  // use-terminal-resize so the terminal-content area stays in sync.
327
349
  const width = Math.max(20, Math.min(80, Math.round(ratio * 80)))
@@ -330,7 +352,7 @@ function GitPaneInPaneMode({ ratio }: { ratio: number }) {
330
352
  flexDirection="column"
331
353
  width={width}
332
354
  flexShrink={0}
333
- backgroundColor={theme.panel}
355
+ backgroundColor={theme.colors['sideBar.background']}
334
356
  overflow="hidden"
335
357
  >
336
358
  <GitPaneWidget pollingEnabled />
@@ -0,0 +1,49 @@
1
+ import { type BundledLanguage, type BundledTheme, createHighlighter, type Highlighter } from 'shiki'
2
+
3
+ import { THEMES } from './themes'
4
+
5
+ // Shiki's highlighter is created with at least one real shiki-bundled theme so
6
+ // its worker can warm up. House and user themes load on demand.
7
+ const WARM_THEME: BundledTheme = 'catppuccin-mocha'
8
+
9
+ let highlighterPromise: Promise<Highlighter> | null = null
10
+
11
+ export async function getShikiHighlighter(): Promise<Highlighter> {
12
+ if (!highlighterPromise) {
13
+ highlighterPromise = createHighlighter({ langs: [], themes: [WARM_THEME] })
14
+ }
15
+ return highlighterPromise
16
+ }
17
+
18
+ const loadedLangs = new Set<string>()
19
+
20
+ export async function ensureShikiLang(h: Highlighter, lang: string): Promise<boolean> {
21
+ if (loadedLangs.has(lang)) return true
22
+ try {
23
+ await h.loadLanguage(lang as BundledLanguage)
24
+ loadedLangs.add(lang)
25
+ return true
26
+ } catch {
27
+ return false
28
+ }
29
+ }
30
+
31
+ const loadedThemes = new Set<string>([WARM_THEME])
32
+
33
+ /**
34
+ * Load the theme keyed by `id` into the shiki highlighter. The same object
35
+ * powers the UI palette and the code highlighter — no synthesis, no mapping.
36
+ */
37
+ export async function ensureShikiTheme(h: Highlighter, id: string): Promise<boolean> {
38
+ if (loadedThemes.has(id)) return true
39
+ const entry = THEMES[id]
40
+ if (!entry) return false
41
+ try {
42
+ // eslint-disable-next-line typescript/no-explicit-any
43
+ await h.loadTheme(entry as any)
44
+ loadedThemes.add(id)
45
+ return true
46
+ } catch {
47
+ return false
48
+ }
49
+ }
@@ -2,16 +2,19 @@ import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
2
 
3
3
  import type { AppState, TabSession } from '../state/types'
4
4
 
5
+ import { describeBindings } from '../input/keymap/describe-bindings'
5
6
  import { buildHintText } from './keymap-context'
6
7
  import { abbreviatePath } from './path-format'
7
8
 
8
9
  export interface StatusBarModel {
9
10
  left: string
10
11
  right: string
12
+ help: string
11
13
  }
12
14
 
13
15
  const MAX_TAB_LABEL_LENGTH = 24
14
16
  const HINT_LIMIT = 6
17
+ const HELP_DESCRIPTION = 'Help'
15
18
 
16
19
  function truncateLabel(label: string): string {
17
20
  if (label.length <= MAX_TAB_LABEL_LENGTH) {
@@ -29,8 +32,25 @@ function getActiveTabLabel(tab?: TabSession): string {
29
32
  return `${truncateLabel(tab.title)} (${tab.status})`
30
33
  }
31
34
 
35
+ const STAGING_DESCRIPTIONS = ['Stage', 'Unstage/delete', 'Commit', 'Push']
36
+
32
37
  function hintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
33
- return buildHintText(config, modeId, HINT_LIMIT)
38
+ return buildHintText(config, modeId, HINT_LIMIT, { excludeDescriptions: [HELP_DESCRIPTION] })
39
+ }
40
+
41
+ function hintForGitMode(config: ResolvedKeymapConfig, headOffset: number): string {
42
+ const exclude = headOffset > 0 ? [HELP_DESCRIPTION, ...STAGING_DESCRIPTIONS] : [HELP_DESCRIPTION]
43
+ return buildHintText(config, 'git-mode', HINT_LIMIT, { excludeDescriptions: exclude })
44
+ }
45
+
46
+ function helpHintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
47
+ const bindings = describeBindings(config, modeId, {
48
+ dedupeByDescription: true,
49
+ withDescriptionOnly: true,
50
+ })
51
+ const helpBinding = bindings.find((b) => b.description === HELP_DESCRIPTION)
52
+ if (!helpBinding) return ''
53
+ return `${helpBinding.keysDisplay} ${helpBinding.description ?? ''}`.trim()
34
54
  }
35
55
 
36
56
  export function getStatusBarModel(
@@ -52,24 +72,31 @@ export function getStatusBarModel(
52
72
  switch (state.focusMode) {
53
73
  case 'terminal-input':
54
74
  return {
75
+ help: '',
55
76
  left: `${getActiveTabLabel(activeTab)} ${sessionIcon} ${sessionLabel}`,
56
77
  right: hintForMode(config, 'terminal-input'),
57
78
  }
58
79
  case 'modal': {
59
80
  const modalMode = deriveModalModeId(state.modal.type)
60
81
  return {
82
+ help: '',
61
83
  left: `${sessionIcon} ${sessionLabel}`,
62
84
  right: modalMode ? hintForMode(config, modalMode) : '',
63
85
  }
64
86
  }
65
- case 'git':
87
+ case 'git': {
88
+ const headOffset = state.gitMode.headOffset
89
+ const offsetTag = headOffset > 0 ? ` HEAD~${headOffset}` : ''
66
90
  return {
67
- left: `${sessionIcon} ${sessionLabel}`,
68
- right: hintForMode(config, 'git-mode'),
91
+ help: helpHintForMode(config, 'git-mode'),
92
+ left: `${sessionIcon} ${sessionLabel}${offsetTag}`,
93
+ right: hintForGitMode(config, headOffset),
69
94
  }
95
+ }
70
96
  case 'command-edit': {
71
97
  const commandEditMode = deriveCommandEditModeId(state.modal.type)
72
98
  return {
99
+ help: '',
73
100
  left: `${sessionIcon} ${sessionLabel}`,
74
101
  right: commandEditMode ? hintForMode(config, commandEditMode) : '',
75
102
  }
@@ -77,6 +104,7 @@ export function getStatusBarModel(
77
104
  case 'navigation':
78
105
  default:
79
106
  return {
107
+ help: helpHintForMode(config, 'navigation'),
80
108
  left: `${sessionIcon} ${sessionLabel} ${getActiveTabLabel(activeTab)}`,
81
109
  right: hintForMode(config, 'navigation'),
82
110
  }
@@ -0,0 +1,36 @@
1
+ import { useStore } from 'zustand'
2
+ import { createStore } from 'zustand/vanilla'
3
+
4
+ import { type Theme, type ThemeId, THEMES } from './themes'
5
+
6
+ interface ThemeStore {
7
+ theme: Theme
8
+ }
9
+
10
+ const DEFAULT = THEMES['aimux']
11
+ if (!DEFAULT) throw new Error('default theme missing')
12
+
13
+ const themeStore = createStore<ThemeStore>(() => ({ theme: DEFAULT }))
14
+
15
+ /**
16
+ * Subscribe to the active theme. Pass a selector to subscribe to a narrower
17
+ * slice (`useTheme(t => t.colors['editor.foreground'])`) or call with no
18
+ * arguments to get the whole theme.
19
+ */
20
+ export function useTheme(): Theme
21
+ export function useTheme<T>(selector: (theme: Theme) => T): T
22
+ export function useTheme<T>(selector?: (theme: Theme) => T): T | Theme {
23
+ return useStore(themeStore, (s) => (selector ? selector(s.theme) : s.theme))
24
+ }
25
+
26
+ /** Synchronous snapshot for non-React callers (reducers, side effects). */
27
+ export function getCurrentTheme(): Theme {
28
+ return themeStore.getState().theme
29
+ }
30
+
31
+ /** Swap the active theme. Triggers re-renders on every `useTheme` subscriber. */
32
+ export function applyTheme(id: ThemeId): void {
33
+ const entry = THEMES[id]
34
+ if (!entry) return
35
+ themeStore.setState({ theme: entry })
36
+ }
package/src/ui/theme.ts CHANGED
@@ -1,18 +1,5 @@
1
- import { type ThemeColors, type ThemeId, THEMES } from './themes'
1
+ // Back-compat shim: the theme singleton has been replaced with a Zustand store.
2
+ // React components should import `useTheme` from `./theme-store`; non-React
3
+ // callers use `getCurrentTheme()`. `applyTheme` moved to the store module.
2
4
 
3
- export const theme: ThemeColors = { ...THEMES.aimux.colors }
4
-
5
- type ThemeListener = () => void
6
- const themeListeners = new Set<ThemeListener>()
7
-
8
- export function onThemeChange(listener: ThemeListener): () => void {
9
- themeListeners.add(listener)
10
- return () => themeListeners.delete(listener)
11
- }
12
-
13
- export function applyTheme(id: ThemeId): void {
14
- const entry = THEMES[id]
15
- if (!entry) return
16
- Object.assign(theme, entry.colors)
17
- for (const listener of themeListeners) listener()
18
- }
5
+ export { applyTheme, getCurrentTheme, useTheme } from './theme-store'