@brimveyn/aimux 1.12.6 → 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 (130) 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/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +538 -83
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +3 -3
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +1 -1
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +3 -3
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  91. package/src/ui/components/git/git-panel.tsx +17 -10
  92. package/src/ui/components/git/git-view.tsx +79 -19
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +3 -3
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +1 -1
  103. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  109. package/src/ui/components/modals/shared/form.tsx +4 -5
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +28 -5
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  114. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  115. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  116. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  117. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  118. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  119. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  120. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  121. package/src/ui/components/primitives/input-field.tsx +1 -1
  122. package/src/ui/components/primitives/list-item.tsx +3 -3
  123. package/src/ui/filter-themes.ts +1 -1
  124. package/src/ui/root.tsx +57 -3
  125. package/src/ui/session-ordering.ts +2 -2
  126. package/src/ui/status-bar-model.ts +14 -7
  127. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  128. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  129. package/src/ui/terminal-graphics/kitty.ts +2 -7
  130. package/src/update/version-check.ts +1 -1
@@ -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
 
@@ -56,16 +56,22 @@ export function describeBindings(
56
56
  for (const binding of mode.bindings) {
57
57
  const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
58
58
 
59
- if (options.mergeAlternativesByDescription) {
59
+ if (options.mergeAlternativesByDescription === true) {
60
60
  const actionSignature = getActionSignature(binding.result)
61
- const existingIndex = binding.description
62
- ? resultIndexByDescription.get(binding.description)
63
- : resultIndexByAction.get(actionSignature)
61
+ const existingIndex =
62
+ binding.description != null && binding.description !== ''
63
+ ? resultIndexByDescription.get(binding.description)
64
+ : resultIndexByAction.get(actionSignature)
64
65
 
65
66
  if (existingIndex === undefined) {
66
- if (options.withDescriptionOnly && !binding.description) continue
67
+ if (
68
+ options.withDescriptionOnly === true &&
69
+ !(binding.description != null && binding.description !== '')
70
+ )
71
+ continue
67
72
  const nextIndex = result.length
68
- if (binding.description) resultIndexByDescription.set(binding.description, nextIndex)
73
+ if (binding.description != null && binding.description !== '')
74
+ resultIndexByDescription.set(binding.description, nextIndex)
69
75
  resultIndexByAction.set(actionSignature, nextIndex)
70
76
  seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
71
77
  result.push({
@@ -83,7 +89,11 @@ export function describeBindings(
83
89
  seenKeysDisplay.add(keysDisplay)
84
90
  const existing = result[existingIndex]
85
91
  if (!existing) continue
86
- if (!existing.description && binding.description) {
92
+ if (
93
+ (existing.description == null || existing.description === '') &&
94
+ binding.description != null &&
95
+ binding.description !== ''
96
+ ) {
87
97
  existing.description = binding.description
88
98
  existing.group = binding.group
89
99
  resultIndexByDescription.set(binding.description, existingIndex)
@@ -93,9 +103,17 @@ export function describeBindings(
93
103
  continue
94
104
  }
95
105
 
96
- if (options.withDescriptionOnly && !binding.description) continue
106
+ if (
107
+ options.withDescriptionOnly === true &&
108
+ !(binding.description != null && binding.description !== '')
109
+ )
110
+ continue
97
111
 
98
- if (options.dedupeByDescription && binding.description) {
112
+ if (
113
+ options.dedupeByDescription === true &&
114
+ binding.description != null &&
115
+ binding.description !== ''
116
+ ) {
99
117
  if (seenDescriptions.has(binding.description)) continue
100
118
  seenDescriptions.add(binding.description)
101
119
  }
@@ -77,7 +77,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
77
77
 
78
78
  // Handle <leader>
79
79
  if (inner.toLowerCase() === 'leader') {
80
- if (!leader) {
80
+ if (!(leader != null && leader !== '')) {
81
81
  throw new Error('parseKeyNotation: <leader> used but no leader key configured')
82
82
  }
83
83
  chords.push(leader)
@@ -102,7 +102,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
102
102
  function parseAngleBracketToken(inner: string): KeyChord {
103
103
  // Check if it's a special name (no modifiers)
104
104
  const specialInner = SPECIAL_NAMES[inner]
105
- if (specialInner) {
105
+ if (specialInner != null && specialInner !== '') {
106
106
  return specialInner
107
107
  }
108
108
 
@@ -120,11 +120,11 @@ function parseAngleBracketToken(inner: string): KeyChord {
120
120
  else if (mod === 'S') shift = true
121
121
  }
122
122
 
123
- let keyName = parts[parts.length - 1] ?? ''
123
+ let keyName = parts.at(-1) ?? ''
124
124
 
125
125
  // Check if the key part is a special name
126
126
  const specialKey = SPECIAL_NAMES[keyName]
127
- if (specialKey) {
127
+ if (specialKey != null && specialKey !== '') {
128
128
  keyName = specialKey
129
129
  }
130
130
 
@@ -14,14 +14,14 @@ const SPECIAL_DISPLAY: Record<string, string> = {
14
14
 
15
15
  function formatModifierKey(baseName: string): string {
16
16
  const special = SPECIAL_DISPLAY[baseName]
17
- if (special) return special
17
+ if (special != null && special !== '') return special
18
18
  if (baseName.length === 1) return baseName.toUpperCase()
19
19
  return baseName.charAt(0).toUpperCase() + baseName.slice(1)
20
20
  }
21
21
 
22
22
  /** Convert a single canonical KeyChord back to a human-readable label. */
23
23
  export function formatChord(chord: KeyChord, leader?: KeyChord): string {
24
- if (leader && chord === leader) return '<leader>'
24
+ if (leader != null && leader !== '' && chord === leader) return '<leader>'
25
25
 
26
26
  let ctrl = false
27
27
  let meta = false
@@ -135,7 +135,7 @@ export class SequenceResolver {
135
135
  }
136
136
 
137
137
  private updateRepeatOnResolve(chord: KeyChord, binding: TrieBinding): void {
138
- if (binding.repeatable) {
138
+ if (binding.repeatable === true) {
139
139
  this.repeatTerminal = chord
140
140
  this.repeatBinding = binding
141
141
  } else {
@@ -52,7 +52,7 @@ export class KeyTrie {
52
52
  remove(sequence: KeyChord[]): boolean {
53
53
  if (sequence.length === 0) return false
54
54
 
55
- const path: Array<{ node: TrieNode; chord: KeyChord }> = []
55
+ const path: { node: TrieNode; chord: KeyChord }[] = []
56
56
  let node = this.root
57
57
 
58
58
  for (const chord of sequence) {
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
27
  'ai-usage': 'modal.ai-usage',
28
28
  'update-available': 'modal.update-available',
29
+ 'worktree-move': 'modal.worktree-move',
29
30
  }
30
31
 
31
32
  export function deriveModeId(state: AppState): ModeId {
@@ -35,6 +36,12 @@ export function deriveModeId(state: AppState): ModeId {
35
36
  return 'modal.help.filtering'
36
37
  }
37
38
 
39
+ // Overlay on top of git mode without flipping focusMode (like help) — route
40
+ // input to the picker while it's open even though focus stays 'git'.
41
+ if (state.modal.type === 'worktree-move') {
42
+ return 'modal.worktree-move'
43
+ }
44
+
38
45
  const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
39
46
  if (directMode) {
40
47
  return directMode
@@ -1,7 +1,7 @@
1
1
  import type { ModeId } from './types'
2
2
 
3
3
  const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
4
- 'git-mode': ['navigation', 'modal.git-commit'],
4
+ 'git-mode': ['navigation', 'modal.git-commit', 'modal.worktree-move'],
5
5
  'modal.ai-usage': ['navigation', 'terminal-input'],
6
6
  'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
7
7
  'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
@@ -18,6 +18,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
18
18
  'modal.split-picker': ['navigation', 'terminal-input'],
19
19
  'modal.theme-picker.filtering': ['navigation'],
20
20
  'modal.update-available': ['navigation'],
21
+ 'modal.worktree-move': ['git-mode', 'navigation'],
21
22
  'navigation': [
22
23
  'terminal-input',
23
24
  'modal.new-tab.command-edit',
@@ -1,5 +1,6 @@
1
1
  import type { KeyEvent } from '@opentui/core'
2
2
 
3
+ import type { SplitDirection } from '../../state/layout-tree'
3
4
  import type { AppAction, AppState, GitFileListMode, TabSession } from '../../state/types'
4
5
 
5
6
  export type ModeId =
@@ -21,6 +22,7 @@ export type ModeId =
21
22
  | 'modal.git-commit.confirm'
22
23
  | 'modal.git-commit.generating'
23
24
  | 'modal.update-available'
25
+ | 'modal.worktree-move'
24
26
  | 'modal.ai-usage'
25
27
 
26
28
  export type SideEffect =
@@ -46,7 +48,7 @@ export type SideEffect =
46
48
  | { type: 'rename-session'; sessionId: string; name: string }
47
49
  | {
48
50
  type: 'split-pane'
49
- direction: import('../../state/layout-tree').SplitDirection
51
+ direction: SplitDirection
50
52
  sourceTabId?: string
51
53
  }
52
54
  | { type: 'confirm-split' }
@@ -67,6 +69,14 @@ export type SideEffect =
67
69
  | { type: 'confirm-update-selection' }
68
70
  | { type: 'switch-session-by-index'; index: number }
69
71
  | { type: 'delete-session'; sessionId: string }
72
+ | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
73
+ | {
74
+ type: 'move-worktree'
75
+ sessionId: string
76
+ sourceWorktreeId: string
77
+ targetWorktreeId: string
78
+ deleteSource?: boolean
79
+ }
70
80
  | { type: 'toggle-transparent' }
71
81
  | { type: 'toggle-mode' }
72
82
  | { type: 'open-file-in-editor'; path: string }
@@ -224,7 +224,7 @@ export function createRawInputHandler(deps: {
224
224
  }
225
225
 
226
226
  const activeTabId = deps.getActiveTabId()
227
- if (!activeTabId) {
227
+ if (!(activeTabId != null && activeTabId !== '')) {
228
228
  return false
229
229
  }
230
230