@brimveyn/aimux 1.12.2 → 1.13.0

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 (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/pty-write.ts +52 -0
  8. package/src/app-runtime/selection-scroll.ts +2 -2
  9. package/src/app-runtime/session-actions.ts +16 -4
  10. package/src/app-runtime/side-effects.ts +598 -102
  11. package/src/app-runtime/snippet-actions.ts +25 -11
  12. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  13. package/src/app-runtime/use-backend-runtime.ts +3 -3
  14. package/src/app-runtime/use-directory-search.ts +7 -5
  15. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  16. package/src/app-runtime/use-pane-size-report.ts +1 -1
  17. package/src/app-runtime/use-renderer-bindings.ts +143 -8
  18. package/src/app.tsx +25 -9
  19. package/src/auto-commit/headless-commands.ts +4 -6
  20. package/src/daemon/daemon.ts +3 -3
  21. package/src/daemon/runtime-paths.ts +1 -1
  22. package/src/daemon/session-manager.ts +1 -1
  23. package/src/daemon/session-registry.ts +10 -3
  24. package/src/diff-parser/parse-patch-files.ts +18 -18
  25. package/src/git/command-queue.ts +1 -1
  26. package/src/git/divergence.ts +46 -0
  27. package/src/git/git-diff.ts +12 -5
  28. package/src/git/git-poller.ts +33 -11
  29. package/src/git/git-status.ts +14 -3
  30. package/src/git/move-worktree.ts +96 -0
  31. package/src/git/use-repo-discovery.ts +2 -1
  32. package/src/git/worktree-divergence-poller.ts +59 -0
  33. package/src/git/worktree.ts +99 -0
  34. package/src/index.tsx +1 -1
  35. package/src/input/keymap/describe-bindings.ts +27 -9
  36. package/src/input/keymap/key-chord.ts +4 -4
  37. package/src/input/keymap/key-format.ts +2 -2
  38. package/src/input/keymap/sequence-resolver.ts +1 -1
  39. package/src/input/keymap/trie.ts +1 -1
  40. package/src/input/modes/bridge.ts +7 -0
  41. package/src/input/modes/transitions.ts +2 -1
  42. package/src/input/modes/types.ts +13 -1
  43. package/src/input/raw-input-handler.ts +34 -1
  44. package/src/input/terminal-text-extraction.ts +9 -5
  45. package/src/integrations/claude-syntax-overlay.ts +8 -8
  46. package/src/integrations/claude-theme-sync.ts +12 -12
  47. package/src/ipc/protocol.ts +3 -3
  48. package/src/platform/clipboard.ts +18 -2
  49. package/src/platform/worktree-deps.ts +22 -0
  50. package/src/platform/worktree-paths.ts +67 -0
  51. package/src/profile-paths.ts +3 -3
  52. package/src/pty/assistant-status-detection-loop.ts +2 -2
  53. package/src/pty/assistant-status-detector.ts +15 -6
  54. package/src/pty/command-registry.ts +8 -1
  55. package/src/pty/pty-manager.ts +1 -1
  56. package/src/services/ai-usage/adapters/claude.ts +5 -5
  57. package/src/services/ai-usage/adapters/codex.ts +8 -8
  58. package/src/services/ai-usage/cache.ts +2 -2
  59. package/src/services/ai-usage/pace.ts +3 -6
  60. package/src/services/ai-usage/provider.ts +1 -1
  61. package/src/session-backend/bootstrap.ts +2 -2
  62. package/src/session-backend/local-session-backend.ts +11 -11
  63. package/src/session-backend/remote-session-backend.ts +3 -3
  64. package/src/session-backend/types.ts +2 -2
  65. package/src/snippets/expand-variables.ts +112 -0
  66. package/src/snippets/run-shell-var.ts +93 -0
  67. package/src/snippets/trigger-detector.ts +105 -0
  68. package/src/state/ai-usage-store.ts +1 -1
  69. package/src/state/git-tree.ts +8 -4
  70. package/src/state/layout-tree.ts +8 -5
  71. package/src/state/reducers/git-mode-state.ts +44 -14
  72. package/src/state/reducers/git-panel-state.ts +38 -2
  73. package/src/state/reducers/modal-state.ts +385 -24
  74. package/src/state/reducers/session-state.ts +60 -1
  75. package/src/state/reducers/tab-state.ts +151 -85
  76. package/src/state/selectors.ts +6 -4
  77. package/src/state/session-catalog.ts +20 -5
  78. package/src/state/session-persistence.ts +8 -4
  79. package/src/state/session-worktrees.ts +227 -0
  80. package/src/state/snippet-catalog.ts +65 -4
  81. package/src/state/store.ts +1 -0
  82. package/src/state/toast-store.ts +95 -0
  83. package/src/state/types.ts +108 -16
  84. package/src/state/validation.ts +47 -3
  85. package/src/terminal-manager/manager-client.ts +15 -13
  86. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  87. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  88. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  89. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  90. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  91. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  92. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  93. package/src/ui/components/git/git-panel.tsx +17 -10
  94. package/src/ui/components/git/git-view.tsx +79 -19
  95. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  96. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  97. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  98. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  99. package/src/ui/components/layout/session-bar.tsx +3 -3
  100. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  101. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  102. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  103. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  104. package/src/ui/components/layout/split-layout.tsx +1 -1
  105. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  106. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  107. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  108. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  109. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  110. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  111. package/src/ui/components/modals/shared/form.tsx +4 -5
  112. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  113. package/src/ui/components/modals/shared/picker.tsx +28 -5
  114. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
  115. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
  116. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  117. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  118. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +3 -3
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +65 -14
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -17,7 +17,7 @@ import {
17
17
  type WorkspaceSnapshotV1,
18
18
  } from '../state/types'
19
19
 
20
- type SessionRegistryEvents = {
20
+ interface SessionRegistryEvents {
21
21
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
22
22
  exit: [tabId: string, exitCode: number]
23
23
  error: [tabId: string, message: string]
@@ -81,7 +81,9 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
81
81
  this.tabs.set(tab.id, tab)
82
82
  }
83
83
  this.activeTabId =
84
- snapshot.activeTabId && restoredTabs.some((tab) => tab.id === snapshot.activeTabId)
84
+ snapshot.activeTabId != null &&
85
+ snapshot.activeTabId !== '' &&
86
+ restoredTabs.some((tab) => tab.id === snapshot.activeTabId)
85
87
  ? snapshot.activeTabId
86
88
  : (restoredTabs[0]?.id ?? null)
87
89
  } else if (this.tabs.size > 0 && snapshot) {
@@ -90,9 +92,14 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
90
92
  if (existing) {
91
93
  existing.title = persisted.title
92
94
  existing.scrollIntent = persisted.scrollIntent ?? DEFAULT_SCROLL_INTENT
95
+ existing.worktreeId = persisted.worktreeId
93
96
  }
94
97
  }
95
- if (snapshot.activeTabId && this.tabs.has(snapshot.activeTabId)) {
98
+ if (
99
+ snapshot.activeTabId != null &&
100
+ snapshot.activeTabId !== '' &&
101
+ this.tabs.has(snapshot.activeTabId)
102
+ ) {
96
103
  this.activeTabId = snapshot.activeTabId
97
104
  }
98
105
  }
@@ -64,7 +64,7 @@ export function processFile(
64
64
  const lines = hunk.split(SPLIT_WITH_NEWLINES)
65
65
  const firstLine = lines.shift()
66
66
  if (firstLine == null) {
67
- if (throwOnError) throw Error('parsePatchContent: invalid hunk')
67
+ if (throwOnError) throw new Error('parsePatchContent: invalid hunk')
68
68
  else console.error('parsePatchContent: invalid hunk', hunk)
69
69
  continue
70
70
  }
@@ -73,7 +73,7 @@ export function processFile(
73
73
  let deletionLines = 0
74
74
  if (fileHeaderMatch == null || currentFile == null) {
75
75
  if (currentFile != null) {
76
- if (throwOnError) throw Error('parsePatchContent: Invalid hunk')
76
+ if (throwOnError) throw new Error('parsePatchContent: Invalid hunk')
77
77
  else console.error('parsePatchContent: Invalid hunk', hunk)
78
78
  continue
79
79
  }
@@ -139,8 +139,9 @@ export function processFile(
139
139
  currentFile.mode = line.replace('deleted file mode', '').trim()
140
140
  }
141
141
  if (line.startsWith('similarity index')) {
142
- if (line.startsWith('similarity index 100%')) currentFile.type = 'rename-pure'
143
- else currentFile.type = 'rename-changed'
142
+ currentFile.type = line.startsWith('similarity index 100%')
143
+ ? 'rename-pure'
144
+ : 'rename-changed'
144
145
  }
145
146
  if (line.startsWith('index ')) {
146
147
  const [, prevObjectId, newObjectId, mode] = line.trim().match(INDEX_LINE_METADATA) ?? []
@@ -162,24 +163,24 @@ export function processFile(
162
163
  let lastLineType: 'addition' | 'deletion' | 'context' | undefined
163
164
  while (
164
165
  lines.length > 0 &&
165
- (lines[lines.length - 1] === '\n' ||
166
- lines[lines.length - 1] === '\r' ||
167
- lines[lines.length - 1] === '\r\n' ||
168
- lines[lines.length - 1] === '')
166
+ (lines.at(-1) === '\n' ||
167
+ lines.at(-1) === '\r' ||
168
+ lines.at(-1) === '\r\n' ||
169
+ lines.at(-1) === '')
169
170
  ) {
170
171
  lines.pop()
171
172
  }
172
- const additionStart = parseInt(fileHeaderMatch[3] ?? '')
173
- const deletionStart = parseInt(fileHeaderMatch[1] ?? '')
173
+ const additionStart = parseInt(fileHeaderMatch[3] ?? '', 10)
174
+ const deletionStart = parseInt(fileHeaderMatch[1] ?? '', 10)
174
175
  deletionLineIndex = isPartial ? deletionLineIndex : deletionStart - 1
175
176
  additionLineIndex = isPartial ? additionLineIndex : additionStart - 1
176
177
  const hunkData = {
177
- additionCount: parseInt(fileHeaderMatch[4] ?? '1'),
178
+ additionCount: parseInt(fileHeaderMatch[4] ?? '1', 10),
178
179
  additionLineIndex,
179
180
  additionLines,
180
181
  additionStart,
181
182
  collapsedBefore: 0,
182
- deletionCount: parseInt(fileHeaderMatch[2] ?? '1'),
183
+ deletionCount: parseInt(fileHeaderMatch[2] ?? '1', 10),
183
184
  deletionLineIndex,
184
185
  deletionLines,
185
186
  deletionStart,
@@ -199,7 +200,7 @@ export function processFile(
199
200
  isNaN(hunkData.additionStart) ||
200
201
  isNaN(hunkData.deletionStart)
201
202
  ) {
202
- if (throwOnError) throw Error('parsePatchContent: invalid hunk metadata')
203
+ if (throwOnError) throw new Error('parsePatchContent: invalid hunk metadata')
203
204
  else console.error('parsePatchContent: invalid hunk metadata', hunkData)
204
205
  continue
205
206
  }
@@ -285,7 +286,7 @@ export function processFile(
285
286
  currentFile.unifiedLineCount += hunkData.collapsedBefore + hunkData.unifiedLineCount
286
287
  }
287
288
  if (currentFile == null) return
288
- const lastHunk = currentFile.hunks[currentFile.hunks.length - 1]
289
+ const lastHunk = currentFile.hunks.at(-1)
289
290
  if (
290
291
  lastHunk != null &&
291
292
  !isPartial &&
@@ -300,8 +301,7 @@ export function processFile(
300
301
  }
301
302
  if (!isGitDiff) {
302
303
  if (currentFile.prevName != null && currentFile.name !== currentFile.prevName) {
303
- if (currentFile.hunks.length > 0) currentFile.type = 'rename-changed'
304
- else currentFile.type = 'rename-pure'
304
+ currentFile.type = currentFile.hunks.length > 0 ? 'rename-changed' : 'rename-pure'
305
305
  } else if (newFile != null && newFile.contents === '') currentFile.type = 'deleted'
306
306
  else if (oldFile != null && oldFile.contents === '') currentFile.type = 'new'
307
307
  }
@@ -323,12 +323,12 @@ export function processPatch(
323
323
  for (const fileOrPatchMetadata of rawFiles) {
324
324
  if (isGitDiff && !GIT_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) {
325
325
  if (patchMetadata == null) patchMetadata = fileOrPatchMetadata
326
- else if (throwOnError) throw Error('parsePatchContent: unknown file blob')
326
+ else if (throwOnError) throw new Error('parsePatchContent: unknown file blob')
327
327
  else console.error('parsePatchContent: unknown file blob:', fileOrPatchMetadata)
328
328
  continue
329
329
  } else if (!isGitDiff && !UNIFIED_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) {
330
330
  if (patchMetadata == null) patchMetadata = fileOrPatchMetadata
331
- else if (throwOnError) throw Error('parsePatchContent: unknown file blob')
331
+ else if (throwOnError) throw new Error('parsePatchContent: unknown file blob')
332
332
  else console.error('parsePatchContent: unknown file blob:', fileOrPatchMetadata)
333
333
  continue
334
334
  }
@@ -1,6 +1,6 @@
1
1
  let tail: Promise<unknown> = Promise.resolve()
2
2
 
3
- export function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
3
+ export async function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
4
4
  const next = tail.then(op, op)
5
5
  tail = next.catch(() => {})
6
6
  return next
@@ -0,0 +1,46 @@
1
+ import { $ } from 'bun'
2
+
3
+ import type { BranchDivergence } from '../state/types'
4
+
5
+ // Parses `git rev-list --left-right --count <base>...<branch>`, which prints two
6
+ // whitespace-separated counts: "<left> <right>". With the `<base>...<branch>`
7
+ // range the left count is commits in base but not branch (how far the branch is
8
+ // *behind* its base) and the right count is commits in branch but not base (how
9
+ // far it is *ahead*).
10
+ export function parseDivergenceCount(output: string): BranchDivergence | undefined {
11
+ const match = /(\d+)\s+(\d+)/.exec(output.trim())
12
+ if (match == null) return undefined
13
+ return {
14
+ ahead: Number.parseInt(match[2] ?? '', 10),
15
+ behind: Number.parseInt(match[1] ?? '', 10),
16
+ }
17
+ }
18
+
19
+ // Commits a worktree branch is ahead/behind the ref it forked from. Returns
20
+ // undefined when the refs can't be compared (missing ref, not a repo, …) so
21
+ // callers can simply render nothing.
22
+ export async function getBranchDivergence(
23
+ repoPath: string,
24
+ baseRef: string,
25
+ branch: string
26
+ ): Promise<BranchDivergence | undefined> {
27
+ const range = `${baseRef}...${branch}`
28
+ const result = await $`git -C ${repoPath} rev-list --left-right --count ${range}`
29
+ .quiet()
30
+ .nothrow()
31
+ if (result.exitCode !== 0) return undefined
32
+ return parseDivergenceCount(result.text())
33
+ }
34
+
35
+ // Fork point: the most recent commit shared by baseRef and ref. Diffing the
36
+ // working tree against this shows everything the worktree changed since it
37
+ // branched off (commits + staged + unstaged) — i.e. what a squash-move lands.
38
+ export async function getMergeBase(
39
+ repoPath: string,
40
+ baseRef: string,
41
+ ref = 'HEAD'
42
+ ): Promise<string | undefined> {
43
+ const result = await $`git -C ${repoPath} merge-base ${baseRef} ${ref}`.quiet().nothrow()
44
+ if (result.exitCode !== 0) return undefined
45
+ return result.text().trim() || undefined
46
+ }
@@ -5,7 +5,8 @@ import type { DiffData, DiffFileStatus, GitFileEntry } from '../state/types'
5
5
  import { imageFormatLabel, imageMimeFromPath, isImagePath } from './image-detect'
6
6
 
7
7
  function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?: string } {
8
- if (entry.renamedFrom) return { oldPath: entry.renamedFrom, status: 'renamed' }
8
+ if (entry.renamedFrom != null && entry.renamedFrom !== '')
9
+ return { oldPath: entry.renamedFrom, status: 'renamed' }
9
10
  if (entry.section === 'untracked' || entry.status === '?') return { status: 'new' }
10
11
  if (entry.status === 'D') return { status: 'deleted' }
11
12
  if (entry.status === 'A' && (entry.section === 'staged' || entry.section === 'historical')) {
@@ -85,13 +86,19 @@ async function rawUnifiedDiff(
85
86
  return result.text()
86
87
  }
87
88
 
89
+ function resolveCompareRef(headOffset: number, compareRef: string | undefined): string {
90
+ if (compareRef != null && compareRef !== '') return compareRef
91
+ return headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
92
+ }
93
+
88
94
  export async function fetchDiff(
89
95
  cwd: string,
90
96
  file: GitFileEntry,
91
- headOffset: number = 0
97
+ headOffset: number = 0,
98
+ compareRef?: string
92
99
  ): Promise<DiffData> {
93
100
  const { oldPath, status } = resolveStatus(file)
94
- const ref = headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
101
+ const ref = resolveCompareRef(headOffset, compareRef)
95
102
 
96
103
  if (isImagePath(file.path)) {
97
104
  const headPath = oldPath ?? file.path
@@ -112,7 +119,7 @@ export async function fetchDiff(
112
119
  }
113
120
  if (imageBytesBefore) data.imageBytesBefore = imageBytesBefore
114
121
  if (imageBytesAfter) data.imageBytesAfter = imageBytesAfter
115
- if (oldPath) data.oldPath = oldPath
122
+ if (oldPath != null && oldPath !== '') data.oldPath = oldPath
116
123
  return data
117
124
  }
118
125
 
@@ -137,6 +144,6 @@ export async function fetchDiff(
137
144
  rawDiff,
138
145
  status,
139
146
  }
140
- if (oldPath) data.oldPath = oldPath
147
+ if (oldPath != null && oldPath !== '') data.oldPath = oldPath
141
148
  return data
142
149
  }
@@ -13,6 +13,9 @@ interface Options {
13
13
  enabled: boolean
14
14
  projectPath: string | undefined
15
15
  headOffset: number
16
+ // When set, diff the working tree against this ref (worktree fork point for
17
+ // review-vs-base) instead of HEAD/HEAD~N.
18
+ compareRef?: string
16
19
  }
17
20
 
18
21
  function tagFiles(files: GitFileEntry[], repoPath: string): GitFileEntry[] {
@@ -22,13 +25,23 @@ function tagFiles(files: GitFileEntry[], repoPath: string): GitFileEntry[] {
22
25
  async function collectAggregated(
23
26
  repos: DiscoveredRepo[],
24
27
  fallbackCwd: string,
25
- headOffset: number
28
+ headOffset: number,
29
+ compareRef: string | undefined
26
30
  ): 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 })
31
+ // Historical walking (HEAD~N) and review-vs-base are per-repo and don't
32
+ // compose; fall back to the root/fallback repo to preserve existing behaviour.
33
+ if ((compareRef != null && compareRef !== '') || headOffset > 0) {
34
+ const result = await collectGitStatus(fallbackCwd, { compareRef, headOffset })
35
+ if (result.kind !== 'ok') return result
36
+ return {
37
+ kind: 'ok',
38
+ payload: { ...result.payload, files: tagFiles(result.payload.files, fallbackCwd) },
39
+ }
40
+ }
30
41
 
31
- const results = await Promise.all(repos.map((r) => collectGitStatus(r.path, { headOffset: 0 })))
42
+ const results = await Promise.all(
43
+ repos.map(async (r) => collectGitStatus(r.path, { headOffset: 0 }))
44
+ )
32
45
  const files: GitFileEntry[] = []
33
46
  let branch: string | null = null
34
47
  let ahead = 0
@@ -58,13 +71,18 @@ async function collectAggregated(
58
71
  return { kind: 'ok', payload }
59
72
  }
60
73
 
61
- export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options): void {
74
+ export function useGitPanelPolling({
75
+ compareRef,
76
+ enabled,
77
+ headOffset,
78
+ projectPath,
79
+ }: Options): void {
62
80
  // Read repos from the store imperatively — changes trigger a fresh effect run
63
81
  // because the repos identity is stable across ticks until set-repos fires.
64
82
  const repos = useAppStore((s) => s.multiRepo.repos)
65
83
 
66
84
  useEffect(() => {
67
- if (!enabled || !projectPath) return undefined
85
+ if (!enabled || !(projectPath != null && projectPath !== '')) return
68
86
 
69
87
  dispatchGlobal({ type: 'git-panel-reset' })
70
88
 
@@ -80,11 +98,15 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
80
98
  const tick = async () => {
81
99
  const result =
82
100
  repos.length > 0
83
- ? await collectAggregated(repos, projectPath, headOffset)
84
- : await collectGitStatus(projectPath, { headOffset })
101
+ ? await collectAggregated(repos, projectPath, headOffset, compareRef)
102
+ : await collectGitStatus(projectPath, { compareRef, headOffset })
85
103
  if (cancelled) return
86
104
  if (result.kind === 'ok') {
87
- dispatchGlobal({ payload: result.payload, type: 'git-refresh-success' })
105
+ const payload =
106
+ repos.length === 0
107
+ ? { ...result.payload, files: tagFiles(result.payload.files, projectPath) }
108
+ : result.payload
109
+ dispatchGlobal({ payload, type: 'git-refresh-success' })
88
110
  delay = BASE_INTERVAL_MS
89
111
  } else if (result.kind === 'out-of-range') {
90
112
  dispatchGlobal({ offset: result.maxOffset, type: 'git-mode-set-head-offset' })
@@ -106,5 +128,5 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
106
128
  cancelled = true
107
129
  if (timer) clearTimeout(timer)
108
130
  }
109
- }, [enabled, projectPath, headOffset, repos])
131
+ }, [enabled, projectPath, headOffset, compareRef, repos])
110
132
  }
@@ -88,7 +88,7 @@ function buildEntry(
88
88
  removed = 0
89
89
  }
90
90
  const entry: GitFileEntry = { added, path, removed, section, status }
91
- if (renamedFrom) entry.renamedFrom = renamedFrom
91
+ if (renamedFrom != null && renamedFrom !== '') entry.renamedFrom = renamedFrom
92
92
  return entry
93
93
  }
94
94
 
@@ -179,7 +179,7 @@ async function countUntrackedLines(cwd: string, path: string): Promise<number |
179
179
  async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Promise<void> {
180
180
  const untracked = files.filter((f) => f.section === 'untracked')
181
181
  if (untracked.length === 0) return
182
- const counts = await Promise.all(untracked.map((f) => countUntrackedLines(cwd, f.path)))
182
+ const counts = await Promise.all(untracked.map(async (f) => countUntrackedLines(cwd, f.path)))
183
183
  for (let i = 0; i < untracked.length; i++) {
184
184
  const file = untracked[i]
185
185
  const count = counts[i]
@@ -192,6 +192,9 @@ async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Prom
192
192
 
193
193
  interface CollectOptions {
194
194
  headOffset?: number
195
+ // Explicit ref to diff the working tree against (e.g. a worktree's fork
196
+ // point). Takes precedence over headOffset when set.
197
+ compareRef?: string
195
198
  }
196
199
 
197
200
  function parseNameStatus(
@@ -208,7 +211,7 @@ function parseNameStatus(
208
211
  if (letter === 'R' || letter === 'C') {
209
212
  const from = parts[1]
210
213
  const to = parts[2]
211
- if (!from || !to) continue
214
+ if (from == null || from === '' || !(to != null && to !== '')) continue
212
215
  rows.push({ path: to, renamedFrom: from, status })
213
216
  } else {
214
217
  const path = parts.slice(1).join('\t')
@@ -252,7 +255,13 @@ async function collectAgainstHistorical(
252
255
  const maxOffset = Number.isFinite(count) && count > 0 ? count - 1 : 0
253
256
  return { kind: 'out-of-range', maxOffset }
254
257
  }
258
+ return collectAgainstRef(cwd, ref)
259
+ }
255
260
 
261
+ // Diffs the working tree against an arbitrary ref (a HEAD~N commit, or a
262
+ // worktree's fork point for review-vs-base), surfacing changed files as a
263
+ // single `historical` section plus any untracked files.
264
+ async function collectAgainstRef(cwd: string, ref: string): Promise<GitCollectResult> {
256
265
  const [statusResult, nameStatus, numstat, untrackedStatus] = await Promise.all([
257
266
  $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
258
267
  $`git -C ${cwd} -c core.quotePath=false diff ${ref} --name-status`.quiet().nothrow(),
@@ -286,7 +295,9 @@ export async function collectGitStatus(
286
295
  options: CollectOptions = {}
287
296
  ): Promise<GitCollectResult> {
288
297
  const headOffset = options.headOffset ?? 0
298
+ const compareRef = options.compareRef
289
299
  try {
300
+ if (compareRef != null && compareRef !== '') return await collectAgainstRef(cwd, compareRef)
290
301
  return headOffset > 0
291
302
  ? await collectAgainstHistorical(cwd, headOffset)
292
303
  : await collectAgainstHead(cwd)
@@ -0,0 +1,96 @@
1
+ import { $ } from 'bun'
2
+
3
+ export type MoveWorktreeResult =
4
+ | { kind: 'ok'; filesChanged: number }
5
+ | { kind: 'dirty-target' }
6
+ | { kind: 'conflict'; files: string[] }
7
+ | { kind: 'error'; message: string }
8
+
9
+ async function workingTreeDirty(repoPath: string): Promise<boolean> {
10
+ const result = await $`git -C ${repoPath} status --porcelain`.quiet().nothrow()
11
+ if (result.exitCode !== 0) return false
12
+ return result.text().trim() !== ''
13
+ }
14
+
15
+ // Files left with conflict markers by a failed squash (unmerged index entries).
16
+ async function conflictedFiles(repoPath: string): Promise<string[]> {
17
+ const result = await $`git -C ${repoPath} diff --name-only --diff-filter=U`.quiet().nothrow()
18
+ if (result.exitCode !== 0) return []
19
+ return result
20
+ .text()
21
+ .split('\n')
22
+ .map((line) => line.trim())
23
+ .filter((line) => line !== '')
24
+ }
25
+
26
+ async function countStaged(repoPath: string): Promise<number> {
27
+ const result = await $`git -C ${repoPath} diff --cached --name-only`.quiet().nothrow()
28
+ if (result.exitCode !== 0) return 0
29
+ return result
30
+ .text()
31
+ .split('\n')
32
+ .filter((line) => line.trim() !== '').length
33
+ }
34
+
35
+ // Squashes everything a source worktree changed since its fork point into the
36
+ // target worktree's working tree (staged, uncommitted) — committed work plus
37
+ // any uncommitted/untracked changes. The source is left exactly as it was; the
38
+ // caller decides whether to delete it. The target must be clean so two change
39
+ // sets are never silently fused. On conflict nothing is kept: the target is
40
+ // reset and the source restored.
41
+ export async function moveWorktree(opts: {
42
+ sourcePath: string
43
+ sourceBranch: string
44
+ targetPath: string
45
+ }): Promise<MoveWorktreeResult> {
46
+ const { sourceBranch, sourcePath, targetPath } = opts
47
+ try {
48
+ if (await workingTreeDirty(targetPath)) return { kind: 'dirty-target' }
49
+
50
+ const headResult = await $`git -C ${sourcePath} rev-parse HEAD`.quiet().nothrow()
51
+ if (headResult.exitCode !== 0) {
52
+ return { kind: 'error', message: 'could not resolve source HEAD' }
53
+ }
54
+ const sourceHead = headResult.text().trim()
55
+
56
+ // Capture uncommitted + untracked work in a throwaway commit so the squash
57
+ // includes everything; undone again before we return.
58
+ const tempCommitted = await workingTreeDirty(sourcePath)
59
+ if (tempCommitted) {
60
+ await $`git -C ${sourcePath} add -A`.quiet().nothrow()
61
+ const commit = await $`git -C ${sourcePath} commit -m ${'aimux: move WIP'} --no-verify`
62
+ .quiet()
63
+ .nothrow()
64
+ if (commit.exitCode !== 0) {
65
+ const message = commit.stderr.toString().trim()
66
+ return {
67
+ kind: 'error',
68
+ message: message !== '' ? message : 'failed to stage source changes',
69
+ }
70
+ }
71
+ }
72
+
73
+ const restoreSource = async (): Promise<void> => {
74
+ if (tempCommitted) {
75
+ await $`git -C ${sourcePath} reset --mixed ${sourceHead}`.quiet().nothrow()
76
+ }
77
+ }
78
+
79
+ const merge = await $`git -C ${targetPath} merge --squash ${sourceBranch}`.quiet().nothrow()
80
+ const conflicts = await conflictedFiles(targetPath)
81
+ if (merge.exitCode !== 0 || conflicts.length > 0) {
82
+ await $`git -C ${targetPath} merge --abort`.quiet().nothrow()
83
+ await $`git -C ${targetPath} reset --hard HEAD`.quiet().nothrow()
84
+ await restoreSource()
85
+ if (conflicts.length > 0) return { files: conflicts, kind: 'conflict' }
86
+ const message = merge.stderr.toString().trim()
87
+ return { kind: 'error', message: message !== '' ? message : 'merge failed' }
88
+ }
89
+
90
+ const filesChanged = await countStaged(targetPath)
91
+ await restoreSource()
92
+ return { filesChanged, kind: 'ok' }
93
+ } catch (error) {
94
+ return { kind: 'error', message: error instanceof Error ? error.message : String(error) }
95
+ }
96
+ }
@@ -11,10 +11,11 @@ import { discoverRepos } from './repo-discovery'
11
11
  export function useRepoDiscovery(projectPath: string | undefined): void {
12
12
  useEffect(() => {
13
13
  const cfg = getMultiRepoConfig()
14
- if (!cfg.enabled || !projectPath) {
14
+ if (!cfg.enabled || !(projectPath != null && projectPath !== '')) {
15
15
  dispatchGlobal({ type: 'multi-repo-clear' })
16
16
  return
17
17
  }
18
+ dispatchGlobal({ type: 'multi-repo-clear' })
18
19
  let cancelled = false
19
20
  void (async () => {
20
21
  const repos = await discoverRepos(projectPath, cfg.maxDepth)
@@ -0,0 +1,59 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import type { BranchDivergence } from '../state/types'
4
+
5
+ import { useAppStore } from '../state/app-store'
6
+ import { dispatchGlobal } from '../state/dispatch-ref'
7
+ import { getBranchDivergence } from './divergence'
8
+
9
+ const INTERVAL_MS = 4000
10
+
11
+ // Polls per-worktree base divergence for the current session while enabled and
12
+ // dispatches it into worktreeDivergence. Only worktrees that record a baseRef
13
+ // (the aimux-created ones) are measured; the primary and externally-discovered
14
+ // worktrees have no recorded base and are left out.
15
+ export function useWorktreeDivergencePolling(enabled: boolean): void {
16
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
17
+ const sessions = useAppStore((s) => s.sessions)
18
+
19
+ useEffect(() => {
20
+ if (!enabled) return
21
+ const session =
22
+ currentSessionId != null && currentSessionId !== ''
23
+ ? sessions.find((s) => s.id === currentSessionId)
24
+ : undefined
25
+ const targets = (session?.worktrees ?? []).filter(
26
+ (w) => w.baseRef != null && w.baseRef !== '' && w.branch != null && w.branch !== ''
27
+ )
28
+ if (targets.length === 0) return
29
+
30
+ let cancelled = false
31
+ let timer: ReturnType<typeof setTimeout> | null = null
32
+
33
+ const tick = async () => {
34
+ const entries = await Promise.all(
35
+ targets.map(async (worktree) => {
36
+ const base = worktree.baseRef
37
+ const branch = worktree.branch
38
+ if (base == null || branch == null) return null
39
+ const divergence = await getBranchDivergence(worktree.repoRoot, base, branch)
40
+ return divergence != null ? ([worktree.id, divergence] as const) : null
41
+ })
42
+ )
43
+ if (cancelled) return
44
+ const next: Record<string, BranchDivergence> = {}
45
+ for (const entry of entries) {
46
+ if (entry != null) next[entry[0]] = entry[1]
47
+ }
48
+ dispatchGlobal({ divergence: next, type: 'set-worktree-divergence' })
49
+ timer = setTimeout(() => void tick(), INTERVAL_MS)
50
+ }
51
+
52
+ void tick()
53
+
54
+ return () => {
55
+ cancelled = true
56
+ if (timer != null) clearTimeout(timer)
57
+ }
58
+ }, [enabled, currentSessionId, sessions])
59
+ }
@@ -0,0 +1,99 @@
1
+ import { $ } from 'bun'
2
+ import { existsSync } from 'node:fs'
3
+
4
+ import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
5
+
6
+ export interface GitWorktreeInfo {
7
+ path: string
8
+ head?: string
9
+ branch?: string
10
+ // True when git still tracks the worktree but its directory is gone
11
+ // (e.g. a temp dir cleared on reboot). Such entries are stale admin junk.
12
+ prunable?: boolean
13
+ }
14
+
15
+ // Returns the main worktree's path, i.e. the original repository checkout.
16
+ // `git worktree list` always reports the main worktree first, so this stays
17
+ // correct even when called from inside a linked worktree — unlike
18
+ // `rev-parse --show-toplevel`, which returns the *current* worktree's path.
19
+ export async function getMainWorktreeRoot(cwd: string): Promise<string | undefined> {
20
+ const worktrees = await listGitWorktrees(cwd)
21
+ return worktrees[0]?.path
22
+ }
23
+
24
+ export async function getCurrentBranch(cwd: string): Promise<string | undefined> {
25
+ const result = await $`git -C ${cwd} branch --show-current`.quiet().nothrow()
26
+ if (result.exitCode !== 0) return undefined
27
+ return result.text().trim() || undefined
28
+ }
29
+
30
+ export async function getHeadSha(cwd: string): Promise<string | undefined> {
31
+ const result = await $`git -C ${cwd} rev-parse HEAD`.quiet().nothrow()
32
+ if (result.exitCode !== 0) return undefined
33
+ return result.text().trim() || undefined
34
+ }
35
+
36
+ export async function createGitWorktree({
37
+ baseRef,
38
+ branchName,
39
+ repoPath,
40
+ targetPath,
41
+ }: {
42
+ repoPath: string
43
+ targetPath: string
44
+ branchName: string
45
+ baseRef: string
46
+ }): Promise<void> {
47
+ const result = await $`git -C ${repoPath} worktree add -b ${branchName} ${targetPath} ${baseRef}`
48
+ .quiet()
49
+ .nothrow()
50
+ if (result.exitCode !== 0) {
51
+ throw new Error(result.stderr.toString().trim() || 'failed to create git worktree')
52
+ }
53
+ }
54
+
55
+ export async function removeGitWorktree({
56
+ force,
57
+ repoPath,
58
+ targetPath,
59
+ }: {
60
+ repoPath: string
61
+ targetPath: string
62
+ force: boolean
63
+ }): Promise<void> {
64
+ if (!isInsideAimuxWorktreeRoot(targetPath)) {
65
+ throw new Error(`refusing to delete worktree outside Aimux temp root: ${targetPath}`)
66
+ }
67
+ const result = force
68
+ ? await $`git -C ${repoPath} worktree remove --force ${targetPath}`.quiet().nothrow()
69
+ : await $`git -C ${repoPath} worktree remove ${targetPath}`.quiet().nothrow()
70
+ if (result.exitCode === 0) return
71
+ // The worktree directory may already be gone, leaving only stale admin state
72
+ // in the main repo. Pruning clears it so deletion still reaches its goal.
73
+ if (!existsSync(targetPath)) {
74
+ await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
75
+ return
76
+ }
77
+ throw new Error(result.stderr.toString().trim() || 'failed to remove git worktree')
78
+ }
79
+
80
+ export async function listGitWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
81
+ const result = await $`git -C ${repoPath} worktree list --porcelain`.quiet().nothrow()
82
+ if (result.exitCode !== 0) return []
83
+ const worktrees: GitWorktreeInfo[] = []
84
+ let current: GitWorktreeInfo | null = null
85
+ for (const line of result.text().split('\n')) {
86
+ if (line.startsWith('worktree ')) {
87
+ if (current) worktrees.push(current)
88
+ current = { path: line.slice('worktree '.length) }
89
+ } else if (current && line.startsWith('HEAD ')) {
90
+ current.head = line.slice('HEAD '.length)
91
+ } else if (current && line.startsWith('branch ')) {
92
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
93
+ } else if (current && line.startsWith('prunable')) {
94
+ current.prunable = true
95
+ }
96
+ }
97
+ if (current) worktrees.push(current)
98
+ return worktrees
99
+ }
package/src/index.tsx CHANGED
@@ -77,7 +77,7 @@ logDebug('index.userConfigLoaded', {
77
77
  // plain code so we can re-tokenize on the snapshot. Set on process.env
78
78
  // before backend bootstrap so the spawned daemon (and its child PTYs)
79
79
  // inherit it.
80
- if (resolvedConfig.theme?.beta?.experimentalSyntaxHighlight) {
80
+ if (resolvedConfig.theme?.beta?.experimentalSyntaxHighlight === true) {
81
81
  process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT = 'false'
82
82
  }
83
83