@brimveyn/aimux 1.9.0 → 1.9.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.
Files changed (73) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +13 -3
  3. package/src/app.tsx +26 -17
  4. package/src/git/git-poller.ts +56 -3
  5. package/src/git/repo-discovery.ts +95 -0
  6. package/src/git/use-repo-discovery.ts +28 -0
  7. package/src/input/keymap/describe-bindings.ts +63 -2
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/state/reducers/git-mode-state.ts +58 -0
  10. package/src/state/reducers/git-panel-state.ts +3 -0
  11. package/src/state/reducers/multi-repo-state.ts +60 -0
  12. package/src/state/store.ts +6 -0
  13. package/src/state/types.ts +38 -6
  14. package/src/ui/breaking-update-screen.tsx +2 -2
  15. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  17. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  18. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  19. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  20. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  22. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
  23. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  24. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  25. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
  26. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
  27. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  28. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
  29. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  30. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  31. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  32. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  33. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  34. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  35. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  36. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  37. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  38. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  39. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  40. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  41. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  42. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  43. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  44. package/src/ui/components/modals/shared/form.tsx +164 -0
  45. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  46. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  47. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  48. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  49. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  50. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  51. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  52. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  53. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  54. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  55. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  56. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  57. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  58. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  59. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  60. package/src/ui/root.tsx +24 -22
  61. package/src/ui/theme-store.ts +30 -26
  62. package/src/ui/themes.ts +1 -0
  63. package/src/ui/components/create-session-modal.tsx +0 -96
  64. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  65. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  66. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  67. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  68. package/src/ui/components/session-name-modal.tsx +0 -11
  69. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  70. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  71. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  72. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  73. /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.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.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
 
@@ -647,7 +652,11 @@ async function runGitAction(
647
652
  args: string[],
648
653
  pathToInvalidate?: string
649
654
  ): Promise<void> {
650
- const cwd = ctx.getCurrentSessionProjectPath()
655
+ const fallback = ctx.getCurrentSessionProjectPath()
656
+ const repoPath = pathToInvalidate
657
+ ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
658
+ : undefined
659
+ const cwd = repoPath ?? fallback
651
660
  if (!cwd) return
652
661
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
653
662
  if (result.exitCode !== 0) {
@@ -662,7 +671,8 @@ async function runGitAction(
662
671
  }
663
672
 
664
673
  async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
665
- const cwd = ctx.getCurrentSessionProjectPath()
674
+ const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
675
+ const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
666
676
  if (!cwd) return
667
677
  const absolute = `${cwd}/${path}`
668
678
  try {
package/src/app.tsx CHANGED
@@ -1,14 +1,10 @@
1
- import { type ResolvedConfig, setAutoCommitEnabled } from '@brimveyn/aimux-config'
2
- import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
3
1
  import {
4
- useCallback,
5
- useEffect,
6
- useLayoutEffect,
7
- useMemo,
8
- useReducer,
9
- useRef,
10
- useState,
11
- } from 'react'
2
+ type ResolvedConfig,
3
+ setAutoCommitEnabled,
4
+ setMultiRepoConfig,
5
+ } from '@brimveyn/aimux-config'
6
+ import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
7
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
12
8
 
13
9
  import type { KeyChord } from './input/keymap/key-chord'
14
10
  import type { TrieBinding } from './input/keymap/trie'
@@ -32,11 +28,11 @@ import { type TerminalContentOrigin } from './input/raw-input-handler'
32
28
  import { getProfileConfigDir, getProfileName } from './profile-paths'
33
29
  import { startAIUsageService } from './services/ai-usage/provider'
34
30
  import { aiUsageStore } from './state/ai-usage-store'
35
- import { appStore } from './state/app-store'
31
+ import { appStore, useAppStore } from './state/app-store'
36
32
  import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
37
33
  import { loadSessionCatalog } from './state/session-catalog'
38
34
  import { loadSnippetCatalog } from './state/snippet-catalog'
39
- import { appReducer, createInitialState } from './state/store'
35
+ import { createInitialState } from './state/store'
40
36
  import { KeymapContext } from './ui/keymap-context'
41
37
  import { RootView } from './ui/root'
42
38
  import { applyTheme, setTransparent } from './ui/theme'
@@ -59,6 +55,7 @@ export function App({
59
55
  // Publish the auto-commit enabled flag before any children render so
60
56
  // actions (which live outside React) can read it synchronously.
61
57
  setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
58
+ setMultiRepoConfig(resolvedConfig.multiRepo)
62
59
 
63
60
  const keymapHandlers = useMemo(
64
61
  () => {
@@ -81,7 +78,10 @@ export function App({
81
78
  setTransparent(config.themeTransparent ?? false)
82
79
  return initial
83
80
  })
84
- const [state, dispatch] = useReducer(appReducer, undefined, () => {
81
+ // Initialize zustand store with computed initial state on first render. Using
82
+ // useState's lazy initializer guarantees this runs exactly once before any
83
+ // selector reads. We discard the value — the store is the source of truth.
84
+ useState(() => {
85
85
  const json = loadConfig()
86
86
  const sessionBarVisible =
87
87
  resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
@@ -125,7 +125,7 @@ export function App({
125
125
  ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
126
126
  }
127
127
 
128
- return createInitialState(
128
+ const initial = createInitialState(
129
129
  json.customCommands,
130
130
  loadSessionCatalog(),
131
131
  loadSnippetCatalog(),
@@ -137,11 +137,18 @@ export function App({
137
137
  sidebar: sidebarOverrides,
138
138
  }
139
139
  )
140
+ // Replace the module-level default with the fully-resolved initial state.
141
+ // Preserves the dispatch baked into the store by app-store.ts.
142
+ appStore.setState(initial)
143
+ return null
140
144
  })
141
145
 
142
- useLayoutEffect(() => {
143
- appStore.setState(state)
144
- }, [state])
146
+ // Single source of truth: read state from zustand. The selector returns the
147
+ // entire store object, so this component re-renders on every dispatch (same
148
+ // cadence as the previous useReducer setup), but selectors elsewhere only
149
+ // re-render on the slices they actually subscribe to.
150
+ const state = useAppStore((s) => s)
151
+ const dispatch = state.dispatch
145
152
 
146
153
  useLayoutEffect(() => {
147
154
  setActiveDispatch(dispatch)
@@ -191,6 +198,8 @@ export function App({
191
198
  return () => {
192
199
  cancelled = true
193
200
  }
201
+ // dispatch is stable (same fn reference from the store) — safe to omit
202
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
203
  }, [])
195
204
 
196
205
  const resizingRef = useRef(false)
@@ -1,7 +1,10 @@
1
1
  import { useEffect } from 'react'
2
2
 
3
+ import type { DiscoveredRepo, GitFileEntry, GitRefreshPayload } from '../state/types'
4
+
5
+ import { useAppStore } from '../state/app-store'
3
6
  import { dispatchGlobal } from '../state/dispatch-ref'
4
- import { collectGitStatus } from './git-status'
7
+ import { collectGitStatus, type GitCollectResult } from './git-status'
5
8
 
6
9
  const BASE_INTERVAL_MS = 1000
7
10
  const MAX_INTERVAL_MS = 30_000
@@ -12,7 +15,54 @@ interface Options {
12
15
  headOffset: number
13
16
  }
14
17
 
18
+ function tagFiles(files: GitFileEntry[], repoPath: string): GitFileEntry[] {
19
+ return files.map((f) => ({ ...f, repoPath }))
20
+ }
21
+
22
+ async function collectAggregated(
23
+ repos: DiscoveredRepo[],
24
+ fallbackCwd: string,
25
+ headOffset: number
26
+ ): Promise<GitCollectResult> {
27
+ // Historical walking (HEAD~N) is per-repo and doesn't compose; fall back to
28
+ // the root/fallback repo when offset > 0 to preserve existing behaviour.
29
+ if (headOffset > 0) return collectGitStatus(fallbackCwd, { headOffset })
30
+
31
+ const results = await Promise.all(repos.map((r) => collectGitStatus(r.path, { headOffset: 0 })))
32
+ const files: GitFileEntry[] = []
33
+ let branch: string | null = null
34
+ let ahead = 0
35
+ let behind = 0
36
+ let anyOk = false
37
+ for (let i = 0; i < repos.length; i++) {
38
+ const res = results[i]
39
+ const repo = repos[i]
40
+ if (res?.kind !== 'ok' || !repo) continue
41
+ anyOk = true
42
+ if (repo.isRoot) {
43
+ branch = res.payload.branch
44
+ ahead = res.payload.ahead
45
+ behind = res.payload.behind
46
+ }
47
+ files.push(...tagFiles(res.payload.files, repo.path))
48
+ }
49
+ if (!anyOk) return { error: 'not-a-repo', kind: 'error' }
50
+ // If there was no root repo, surface the first discovered repo's branch label.
51
+ if (branch === null) {
52
+ const firstOk = results.find(
53
+ (r): r is Extract<GitCollectResult, { kind: 'ok' }> => r?.kind === 'ok'
54
+ )
55
+ if (firstOk) branch = firstOk.payload.branch
56
+ }
57
+ const payload: GitRefreshPayload = { ahead, behind, branch, files }
58
+ return { kind: 'ok', payload }
59
+ }
60
+
15
61
  export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options): void {
62
+ // Read repos from the store imperatively — changes trigger a fresh effect run
63
+ // because the repos identity is stable across ticks until set-repos fires.
64
+ const repos = useAppStore((s) => s.multiRepo.repos)
65
+
16
66
  useEffect(() => {
17
67
  if (!enabled || !projectPath) return undefined
18
68
 
@@ -28,7 +78,10 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
28
78
  }
29
79
 
30
80
  const tick = async () => {
31
- const result = await collectGitStatus(projectPath, { headOffset })
81
+ const result =
82
+ repos.length > 0
83
+ ? await collectAggregated(repos, projectPath, headOffset)
84
+ : await collectGitStatus(projectPath, { headOffset })
32
85
  if (cancelled) return
33
86
  if (result.kind === 'ok') {
34
87
  dispatchGlobal({ payload: result.payload, type: 'git-refresh-success' })
@@ -53,5 +106,5 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
53
106
  cancelled = true
54
107
  if (timer) clearTimeout(timer)
55
108
  }
56
- }, [enabled, projectPath, headOffset])
109
+ }, [enabled, projectPath, headOffset, repos])
57
110
  }
@@ -0,0 +1,95 @@
1
+ import type { Dirent } from 'node:fs'
2
+
3
+ import { readdir, stat } from 'node:fs/promises'
4
+ import { basename, join, relative } from 'node:path'
5
+
6
+ import type { DiscoveredRepo } from '../state/types'
7
+
8
+ const IGNORED_DIRS = new Set(['node_modules', 'target', 'dist', 'build', '.git'])
9
+
10
+ async function isGitRepo(path: string): Promise<boolean> {
11
+ try {
12
+ const st = await stat(join(path, '.git'))
13
+ return st.isDirectory() || st.isFile()
14
+ } catch {
15
+ return false
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Recursively scan `projectPath` down to `maxDepth` levels for directories
21
+ * that are themselves git repos. `maxDepth = 1` means only direct children.
22
+ *
23
+ * Internal recursion is breadth-limited: once a `.git` is found at a given
24
+ * path, we don't descend into it further (nested git repos-in-repos are rare
25
+ * and would duplicate status noise).
26
+ */
27
+ async function scan(
28
+ root: string,
29
+ current: string,
30
+ depth: number,
31
+ maxDepth: number,
32
+ out: DiscoveredRepo[]
33
+ ): Promise<void> {
34
+ if (depth > maxDepth) return
35
+ let entries: Dirent[] = []
36
+ try {
37
+ entries = (await readdir(current, { withFileTypes: true })) as Dirent[]
38
+ } catch {
39
+ return
40
+ }
41
+ await Promise.all(
42
+ entries.map(async (entry) => {
43
+ if (!entry.isDirectory()) return
44
+ if (entry.name.startsWith('.')) return
45
+ if (IGNORED_DIRS.has(entry.name)) return
46
+ const childPath = join(current, entry.name)
47
+ if (await isGitRepo(childPath)) {
48
+ const rel = relative(root, childPath)
49
+ out.push({ isRoot: false, name: rel || entry.name, path: childPath })
50
+ return
51
+ }
52
+ await scan(root, childPath, depth + 1, maxDepth, out)
53
+ })
54
+ )
55
+ }
56
+
57
+ async function discoverReposUncached(
58
+ projectPath: string,
59
+ maxDepth: number
60
+ ): Promise<DiscoveredRepo[]> {
61
+ const repos: DiscoveredRepo[] = []
62
+ if (await isGitRepo(projectPath)) {
63
+ repos.push({ isRoot: true, name: basename(projectPath), path: projectPath })
64
+ }
65
+ const children: DiscoveredRepo[] = []
66
+ await scan(projectPath, projectPath, 1, maxDepth, children)
67
+ children.sort((a, b) => a.name.localeCompare(b.name))
68
+ repos.push(...children)
69
+ return repos
70
+ }
71
+
72
+ interface CacheEntry {
73
+ maxDepth: number
74
+ repos: DiscoveredRepo[]
75
+ }
76
+
77
+ const cache = new Map<string, CacheEntry>()
78
+
79
+ /**
80
+ * Cached variant. The cache key is `projectPath` + `maxDepth`: a depth change
81
+ * invalidates the entry. The cache persists until `invalidateRepoCache()` or
82
+ * process exit — discovery is one-shot per session per depth setting.
83
+ */
84
+ export async function discoverRepos(projectPath: string, maxDepth = 1): Promise<DiscoveredRepo[]> {
85
+ const existing = cache.get(projectPath)
86
+ if (existing && existing.maxDepth === maxDepth) return existing.repos
87
+ const repos = await discoverReposUncached(projectPath, maxDepth)
88
+ cache.set(projectPath, { maxDepth, repos })
89
+ return repos
90
+ }
91
+
92
+ export function invalidateRepoCache(projectPath?: string): void {
93
+ if (projectPath === undefined) cache.clear()
94
+ else cache.delete(projectPath)
95
+ }
@@ -0,0 +1,28 @@
1
+ import { getMultiRepoConfig } from '@brimveyn/aimux-config'
2
+ import { useEffect } from 'react'
3
+
4
+ import { dispatchGlobal } from '../state/dispatch-ref'
5
+ import { discoverRepos } from './repo-discovery'
6
+
7
+ /**
8
+ * Discover nested git repos inside `projectPath` once per session and push
9
+ * the result into state. No-op when the multi-repo config flag is off.
10
+ */
11
+ export function useRepoDiscovery(projectPath: string | undefined): void {
12
+ useEffect(() => {
13
+ const cfg = getMultiRepoConfig()
14
+ if (!cfg.enabled || !projectPath) {
15
+ dispatchGlobal({ type: 'multi-repo-clear' })
16
+ return
17
+ }
18
+ let cancelled = false
19
+ void (async () => {
20
+ const repos = await discoverRepos(projectPath, cfg.maxDepth)
21
+ if (cancelled) return
22
+ dispatchGlobal({ repos, type: 'multi-repo-set-repos' })
23
+ })()
24
+ return () => {
25
+ cancelled = true
26
+ }
27
+ }, [projectPath])
28
+ }
@@ -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))
@@ -16,6 +16,9 @@ export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
16
16
  const sa = SECTION_RANK[a.section]
17
17
  const sb = SECTION_RANK[b.section]
18
18
  if (sa !== sb) return sa - sb
19
+ const ra = a.repoPath ?? ''
20
+ const rb = b.repoPath ?? ''
21
+ if (ra !== rb) return ra.localeCompare(rb)
19
22
  return a.path.localeCompare(b.path)
20
23
  })
21
24
  }
@@ -0,0 +1,60 @@
1
+ import {
2
+ type AppAction,
3
+ type AppState,
4
+ type DiscoveredRepo,
5
+ EMPTY_MULTI_REPO_STATE,
6
+ } from '../types'
7
+
8
+ /**
9
+ * Compute the shortest distinguishing prefix for each non-root repo name.
10
+ *
11
+ * Rule: start at length 1, group by that prefix. Any group with a single name
12
+ * is done; groups with collisions retry at length+1 until distinct or the name
13
+ * is exhausted. Root repos receive an empty prefix (no tag needed).
14
+ */
15
+ export function computeRepoPrefixes(repos: DiscoveredRepo[]): Record<string, string> {
16
+ const out: Record<string, string> = {}
17
+ const nonRoot = repos.filter((r) => !r.isRoot)
18
+ for (const r of repos.filter((r) => r.isRoot)) out[r.path] = ''
19
+
20
+ const assign = (group: DiscoveredRepo[], len: number): void => {
21
+ if (group.length === 0) return
22
+ if (group.length === 1) {
23
+ const only = group[0]
24
+ if (!only) return
25
+ out[only.path] = only.name.slice(0, Math.max(1, len))
26
+ return
27
+ }
28
+ const byKey = new Map<string, DiscoveredRepo[]>()
29
+ for (const repo of group) {
30
+ const key = repo.name.slice(0, len)
31
+ const bucket = byKey.get(key)
32
+ if (bucket) bucket.push(repo)
33
+ else byKey.set(key, [repo])
34
+ }
35
+ for (const [key, bucket] of byKey) {
36
+ if (bucket.length === 1 || bucket.every((r) => r.name.length < len)) {
37
+ for (const r of bucket) out[r.path] = key || r.name
38
+ } else {
39
+ assign(bucket, len + 1)
40
+ }
41
+ }
42
+ }
43
+ assign(nonRoot, 1)
44
+ return out
45
+ }
46
+
47
+ export function reduceMultiRepoState(state: AppState, action: AppAction): AppState | null {
48
+ switch (action.type) {
49
+ case 'multi-repo-set-repos': {
50
+ const prefixes = computeRepoPrefixes(action.repos)
51
+ return { ...state, multiRepo: { prefixes, repos: action.repos } }
52
+ }
53
+ case 'multi-repo-clear': {
54
+ if (state.multiRepo.repos.length === 0) return state
55
+ return { ...state, multiRepo: EMPTY_MULTI_REPO_STATE }
56
+ }
57
+ default:
58
+ return null
59
+ }
60
+ }
@@ -2,6 +2,7 @@ import { reduceAutoCommit } from './reducers/auto-commit-state'
2
2
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
3
3
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
4
4
  import { emptyModal, reduceModalState } from './reducers/modal-state'
5
+ import { reduceMultiRepoState } from './reducers/multi-repo-state'
5
6
  import { reduceSessionState } from './reducers/session-state'
6
7
  import { reduceTabState } from './reducers/tab-state'
7
8
  import { reduceUIState } from './reducers/ui-state'
@@ -10,6 +11,7 @@ import {
10
11
  type AppAction,
11
12
  type AppState,
12
13
  EMPTY_AUTO_COMMIT_STATE,
14
+ EMPTY_MULTI_REPO_STATE,
13
15
  type GitModeState,
14
16
  type GitPaneMode,
15
17
  type GitPanePosition,
@@ -98,6 +100,7 @@ export function createInitialState(
98
100
  type: 'session-picker',
99
101
  }
100
102
  : emptyModal(),
103
+ multiRepo: EMPTY_MULTI_REPO_STATE,
101
104
  pendingChords: null,
102
105
  sessionBar: {
103
106
  position: overrides.sessionBarPosition ?? 'top',
@@ -139,6 +142,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
139
142
  const autoCommitState = reduceAutoCommit(state, action)
140
143
  if (autoCommitState) return autoCommitState
141
144
 
145
+ const multiRepoState = reduceMultiRepoState(state, action)
146
+ if (multiRepoState) return multiRepoState
147
+
142
148
  switch (action.type) {
143
149
  case 'set-snippets':
144
150
  return { ...state, snippets: action.snippets }