@brimveyn/aimux 1.9.0 → 1.9.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/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/pty/terminal-snapshot.ts +7 -5
  6. package/src/state/reducers/git-mode-state.ts +58 -0
  7. package/src/state/types.ts +11 -6
  8. package/src/ui/breaking-update-screen.tsx +2 -2
  9. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  10. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  11. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  12. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  13. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  14. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  15. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  17. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  18. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  19. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  20. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  21. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  22. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  23. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  24. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  25. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  26. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  27. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  28. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  29. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  30. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  31. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  32. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  33. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  34. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  35. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  36. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  37. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  38. package/src/ui/components/modals/shared/form.tsx +164 -0
  39. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  40. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  41. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  42. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  43. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  44. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  45. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  46. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  47. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  48. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  49. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  50. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  51. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  52. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  53. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  54. package/src/ui/root.tsx +24 -22
  55. package/src/ui/theme-store.ts +30 -26
  56. package/src/ui/themes.ts +1 -0
  57. package/src/ui/components/create-session-modal.tsx +0 -96
  58. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  59. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  60. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  61. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  62. package/src/ui/components/session-name-modal.tsx +0 -11
  63. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  64. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  65. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  66. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  67. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
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.5.1",
63
+ "@brimveyn/aimux-config": "0.5.2",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@xterm/headless": "^6.0.0",
@@ -605,7 +605,12 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
605
605
  logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
606
606
  return
607
607
  }
608
- if (target.id === state.currentSessionId) return
608
+ if (target.id === state.currentSessionId) {
609
+ if (state.focusMode === 'git') {
610
+ dispatch({ type: 'exit-git-mode' })
611
+ }
612
+ return
613
+ }
609
614
  handleSwitchSessionEffect(state, backend, dispatch, target)
610
615
  }
611
616
 
package/src/app.tsx CHANGED
@@ -1,14 +1,6 @@
1
1
  import { type ResolvedConfig, setAutoCommitEnabled } from '@brimveyn/aimux-config'
2
2
  import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
3
- import {
4
- useCallback,
5
- useEffect,
6
- useLayoutEffect,
7
- useMemo,
8
- useReducer,
9
- useRef,
10
- useState,
11
- } from 'react'
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
12
4
 
13
5
  import type { KeyChord } from './input/keymap/key-chord'
14
6
  import type { TrieBinding } from './input/keymap/trie'
@@ -32,11 +24,11 @@ import { type TerminalContentOrigin } from './input/raw-input-handler'
32
24
  import { getProfileConfigDir, getProfileName } from './profile-paths'
33
25
  import { startAIUsageService } from './services/ai-usage/provider'
34
26
  import { aiUsageStore } from './state/ai-usage-store'
35
- import { appStore } from './state/app-store'
27
+ import { appStore, useAppStore } from './state/app-store'
36
28
  import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
37
29
  import { loadSessionCatalog } from './state/session-catalog'
38
30
  import { loadSnippetCatalog } from './state/snippet-catalog'
39
- import { appReducer, createInitialState } from './state/store'
31
+ import { createInitialState } from './state/store'
40
32
  import { KeymapContext } from './ui/keymap-context'
41
33
  import { RootView } from './ui/root'
42
34
  import { applyTheme, setTransparent } from './ui/theme'
@@ -81,7 +73,10 @@ export function App({
81
73
  setTransparent(config.themeTransparent ?? false)
82
74
  return initial
83
75
  })
84
- const [state, dispatch] = useReducer(appReducer, undefined, () => {
76
+ // Initialize zustand store with computed initial state on first render. Using
77
+ // useState's lazy initializer guarantees this runs exactly once before any
78
+ // selector reads. We discard the value — the store is the source of truth.
79
+ useState(() => {
85
80
  const json = loadConfig()
86
81
  const sessionBarVisible =
87
82
  resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
@@ -125,7 +120,7 @@ export function App({
125
120
  ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
126
121
  }
127
122
 
128
- return createInitialState(
123
+ const initial = createInitialState(
129
124
  json.customCommands,
130
125
  loadSessionCatalog(),
131
126
  loadSnippetCatalog(),
@@ -137,11 +132,18 @@ export function App({
137
132
  sidebar: sidebarOverrides,
138
133
  }
139
134
  )
135
+ // Replace the module-level default with the fully-resolved initial state.
136
+ // Preserves the dispatch baked into the store by app-store.ts.
137
+ appStore.setState(initial)
138
+ return null
140
139
  })
141
140
 
142
- useLayoutEffect(() => {
143
- appStore.setState(state)
144
- }, [state])
141
+ // Single source of truth: read state from zustand. The selector returns the
142
+ // entire store object, so this component re-renders on every dispatch (same
143
+ // cadence as the previous useReducer setup), but selectors elsewhere only
144
+ // re-render on the slices they actually subscribe to.
145
+ const state = useAppStore((s) => s)
146
+ const dispatch = state.dispatch
145
147
 
146
148
  useLayoutEffect(() => {
147
149
  setActiveDispatch(dispatch)
@@ -191,6 +193,8 @@ export function App({
191
193
  return () => {
192
194
  cancelled = true
193
195
  }
196
+ // dispatch is stable (same fn reference from the store) — safe to omit
197
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
198
  }, [])
195
199
 
196
200
  const resizingRef = useRef(false)
@@ -1,4 +1,4 @@
1
- import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
1
+ import type { Action, ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
2
 
3
3
  import { parseKeyNotation } from './key-chord'
4
4
  import { formatNotationForDisplay } from './key-format'
@@ -18,6 +18,24 @@ export interface DescribedBindingGroup {
18
18
  interface DescribeOptions {
19
19
  withDescriptionOnly?: boolean
20
20
  dedupeByDescription?: boolean
21
+ mergeAlternativesByDescription?: boolean
22
+ }
23
+
24
+ const actionObjectIds = new WeakMap<object, number>()
25
+ let nextActionObjectId = 0
26
+
27
+ function getActionSignature(action: Action): string {
28
+ if (typeof action === 'function') {
29
+ let id = actionObjectIds.get(action)
30
+ if (id === undefined) {
31
+ id = nextActionObjectId
32
+ nextActionObjectId += 1
33
+ actionObjectIds.set(action, id)
34
+ }
35
+ return `fn:${id}`
36
+ }
37
+
38
+ return `json:${JSON.stringify(action)}`
21
39
  }
22
40
 
23
41
  export function describeBindings(
@@ -31,9 +49,52 @@ export function describeBindings(
31
49
  const leaderChord = parseKeyNotation(config.leader)[0]
32
50
  const seenDescriptions = new Set<string>()
33
51
  const result: DescribedBinding[] = []
52
+ const resultIndexByDescription = new Map<string, number>()
53
+ const resultIndexByAction = new Map<string, number>()
54
+ const seenKeysDisplayByIndex = new Map<number, Set<string>>()
34
55
 
35
56
  for (const binding of mode.bindings) {
57
+ const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
58
+
59
+ if (options.mergeAlternativesByDescription) {
60
+ const actionSignature = getActionSignature(binding.result)
61
+ const existingIndex = binding.description
62
+ ? resultIndexByDescription.get(binding.description)
63
+ : resultIndexByAction.get(actionSignature)
64
+
65
+ if (existingIndex === undefined) {
66
+ if (options.withDescriptionOnly && !binding.description) continue
67
+ const nextIndex = result.length
68
+ if (binding.description) resultIndexByDescription.set(binding.description, nextIndex)
69
+ resultIndexByAction.set(actionSignature, nextIndex)
70
+ seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
71
+ result.push({
72
+ description: binding.description,
73
+ group: binding.group,
74
+ keys: binding.keys,
75
+ keysDisplay,
76
+ })
77
+ continue
78
+ }
79
+
80
+ const seenKeysDisplay = seenKeysDisplayByIndex.get(existingIndex)
81
+ if (!seenKeysDisplay || seenKeysDisplay.has(keysDisplay)) continue
82
+
83
+ seenKeysDisplay.add(keysDisplay)
84
+ const existing = result[existingIndex]
85
+ if (!existing) continue
86
+ if (!existing.description && binding.description) {
87
+ existing.description = binding.description
88
+ existing.group = binding.group
89
+ resultIndexByDescription.set(binding.description, existingIndex)
90
+ }
91
+ existing.keys = `${existing.keys} / ${binding.keys}`
92
+ existing.keysDisplay = `${existing.keysDisplay} / ${keysDisplay}`
93
+ continue
94
+ }
95
+
36
96
  if (options.withDescriptionOnly && !binding.description) continue
97
+
37
98
  if (options.dedupeByDescription && binding.description) {
38
99
  if (seenDescriptions.has(binding.description)) continue
39
100
  seenDescriptions.add(binding.description)
@@ -42,7 +103,7 @@ export function describeBindings(
42
103
  description: binding.description,
43
104
  group: binding.group,
44
105
  keys: binding.keys,
45
- keysDisplay: formatNotationForDisplay(binding.keys, leaderChord),
106
+ keysDisplay,
46
107
  })
47
108
  }
48
109
 
@@ -2,7 +2,7 @@ import type { Terminal } from '@xterm/headless'
2
2
 
3
3
  import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
4
4
 
5
- import { getCurrentTheme } from '../ui/theme'
5
+ import { getCurrentTokens } from '../ui/theme'
6
6
 
7
7
  const SNAPSHOT_TAIL_LINE_COUNT = 10
8
8
 
@@ -115,15 +115,17 @@ function buildLine(
115
115
  let bg = getColorHex(current.getBgColor(), bgMode)
116
116
 
117
117
  if (current.isInverse()) {
118
- const resolvedFg = fg ?? getCurrentTheme().palette.ink
119
- const resolvedBg = bg ?? getCurrentTheme().palette.neutral
118
+ const tokens = getCurrentTokens()
119
+ const resolvedFg = fg ?? tokens.palette.ink
120
+ const resolvedBg = bg ?? tokens.bg
120
121
  ;[fg, bg] = [resolvedBg, resolvedFg]
121
122
  }
122
123
 
123
124
  const isCursorCell = cursorVisible && cursorColumn === column
124
125
  if (isCursorCell) {
125
- const resolvedFg = fg ?? getCurrentTheme().palette.ink
126
- const resolvedBg = bg ?? getCurrentTheme().palette.neutral
126
+ const tokens = getCurrentTokens()
127
+ const resolvedFg = fg ?? tokens.palette.ink
128
+ const resolvedBg = bg ?? tokens.bg
127
129
  ;[fg, bg] = [resolvedBg, resolvedFg]
128
130
  }
129
131
 
@@ -274,6 +274,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
274
274
  }
275
275
  }
276
276
  case 'git-mode-set-parsed': {
277
+ const prevParsed = state.gitMode.parsedFiles[action.key]
278
+ if (prevParsed && prevParsed.hash === action.hash) return state
277
279
  return {
278
280
  ...state,
279
281
  gitMode: {
@@ -303,6 +305,62 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
303
305
  },
304
306
  }
305
307
  }
308
+ case 'git-mode-merge-highlights': {
309
+ const k = `${action.key}|${action.themeId}`
310
+ const existing = state.gitMode.highlights[k]
311
+ const sameContent = existing && existing.hash === action.hash
312
+ if (sameContent) {
313
+ const existingAdd = existing.add as unknown[]
314
+ const existingDel = existing.del as unknown[]
315
+ let changesAnything = false
316
+ outer: for (const patch of action.add) {
317
+ const tokens = patch.tokens as unknown[]
318
+ for (let i = 0; i < tokens.length; i++) {
319
+ if (existingAdd[patch.start + i] === undefined) {
320
+ changesAnything = true
321
+ break outer
322
+ }
323
+ }
324
+ }
325
+ if (!changesAnything) {
326
+ outer: for (const patch of action.del) {
327
+ const tokens = patch.tokens as unknown[]
328
+ for (let i = 0; i < tokens.length; i++) {
329
+ if (existingDel[patch.start + i] === undefined) {
330
+ changesAnything = true
331
+ break outer
332
+ }
333
+ }
334
+ }
335
+ }
336
+ if (!changesAnything) return state
337
+ }
338
+ const nextAdd = sameContent ? ([...(existing.add as unknown[])] as unknown[]) : []
339
+ const nextDel = sameContent ? ([...(existing.del as unknown[])] as unknown[]) : []
340
+ for (const patch of action.add) {
341
+ const tokens = patch.tokens as unknown[]
342
+ for (let i = 0; i < tokens.length; i++) nextAdd[patch.start + i] = tokens[i]
343
+ }
344
+ for (const patch of action.del) {
345
+ const tokens = patch.tokens as unknown[]
346
+ for (let i = 0; i < tokens.length; i++) nextDel[patch.start + i] = tokens[i]
347
+ }
348
+ return {
349
+ ...state,
350
+ gitMode: {
351
+ ...state.gitMode,
352
+ highlights: {
353
+ ...state.gitMode.highlights,
354
+ [k]: {
355
+ add: nextAdd,
356
+ del: nextDel,
357
+ hash: action.hash,
358
+ themeId: action.themeId,
359
+ },
360
+ },
361
+ },
362
+ }
363
+ }
306
364
  case 'git-mode-invalidate-diffs': {
307
365
  if (action.paths.length === 0) return state
308
366
  return clearDiffCacheForPaths(state, new Set(action.paths))
@@ -1,8 +1,6 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
  import type { ThemedToken } from 'shiki'
3
3
 
4
- import type { FileDiffMetadata } from '../diff-parser'
5
-
6
4
  export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
7
5
 
8
6
  export type AssistantId = BuiltinAssistantId | (string & {})
@@ -224,8 +222,7 @@ export interface FoldState {
224
222
 
225
223
  export interface ParsedDiffEntry {
226
224
  hash: string
227
- // Stored as unknown at the state boundary; concrete type is FileDiffMetadata
228
- // | null, narrowed at read sites via getParsedFile().
225
+ // Stored as unknown at the state boundary and narrowed at read sites.
229
226
  file: unknown
230
227
  }
231
228
 
@@ -237,9 +234,9 @@ export interface HighlightsEntry {
237
234
  del: unknown
238
235
  }
239
236
 
240
- export function getParsedFile(entry: ParsedDiffEntry | undefined): FileDiffMetadata | null {
237
+ export function getParsedPayload<T>(entry: ParsedDiffEntry | undefined): T | null {
241
238
  if (!entry) return null
242
- return entry.file as FileDiffMetadata | null
239
+ return entry.file as T | null
243
240
  }
244
241
 
245
242
  export function getHighlightsTokens(entry: HighlightsEntry | undefined): {
@@ -608,6 +605,14 @@ export type GitModeAction =
608
605
  add: unknown
609
606
  del: unknown
610
607
  }
608
+ | {
609
+ type: 'git-mode-merge-highlights'
610
+ key: string
611
+ hash: string
612
+ themeId: string
613
+ add: Array<{ start: number; tokens: unknown }>
614
+ del: Array<{ start: number; tokens: unknown }>
615
+ }
611
616
  | { type: 'git-mode-invalidate-diffs'; paths: string[] }
612
617
  | { type: 'git-mode-set-loading'; key: string; loading: boolean }
613
618
  | { type: 'git-mode-set-pending-delete'; path: string | null }
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from '@opentui/react'
2
2
 
3
- import { ListItem } from './components/list-item'
4
- import { ModalShell } from './components/modal-shell'
3
+ import { ModalShell } from './components/modals/shared/modal-shell'
4
+ import { ListItem } from './components/primitives/list-item'
5
5
  import { uiTokens } from './ui-tokens'
6
6
 
7
7
  interface BreakingUpdateScreenProps {