@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
@@ -0,0 +1,227 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { existsSync } from 'node:fs'
4
+ import { basename } from 'node:path'
5
+
6
+ import type { SessionRecord, TabSession, WorktreeRecord } from './types'
7
+
8
+ import { createPrefixedId } from '../platform/id'
9
+ import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
+
11
+ const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
12
+
13
+ export function getWorktreeColor(worktreeId: string): string {
14
+ let hash = 0
15
+ for (let i = 0; i < worktreeId.length; i++) {
16
+ hash = (hash * 31 + worktreeId.charCodeAt(i)) >>> 0
17
+ }
18
+ return WORKTREE_COLORS[hash % WORKTREE_COLORS.length] ?? '#7dd3fc'
19
+ }
20
+
21
+ export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
22
+ const id = createPrefixedId('worktree')
23
+ return {
24
+ createdAt: now,
25
+ createdByAimux: false,
26
+ id,
27
+ name: basename(projectPath) || projectPath,
28
+ path: projectPath,
29
+ repoRoot: projectPath,
30
+ source: 'primary',
31
+ updatedAt: now,
32
+ }
33
+ }
34
+
35
+ export function ensureSessionWorktrees(
36
+ session: SessionRecord,
37
+ now = new Date().toISOString()
38
+ ): SessionRecord {
39
+ if (session.worktrees?.length != null && session.worktrees?.length !== 0) {
40
+ const worktrees = mergeExistingGitWorktrees(
41
+ pruneMissingAimuxTempWorktrees(session.worktrees),
42
+ now
43
+ )
44
+ if (worktrees.length === 0 && session.projectPath != null && session.projectPath !== '') {
45
+ const worktree = createPrimaryWorktree(session.projectPath, now)
46
+ return { ...session, activeWorktreeId: worktree.id, worktrees: [worktree] }
47
+ }
48
+ const activeWorktreeId = worktrees.some((w) => w.id === session.activeWorktreeId)
49
+ ? session.activeWorktreeId
50
+ : worktrees[0]?.id
51
+ const active = worktrees.find((w) => w.id === activeWorktreeId)
52
+ return {
53
+ ...session,
54
+ activeWorktreeId,
55
+ projectPath: active?.path ?? session.projectPath,
56
+ worktrees,
57
+ }
58
+ }
59
+
60
+ if (!(session.projectPath != null && session.projectPath !== '')) {
61
+ return session
62
+ }
63
+
64
+ const worktree = createPrimaryWorktree(session.projectPath, now)
65
+ const worktrees = mergeExistingGitWorktrees([worktree], now)
66
+ return {
67
+ ...session,
68
+ activeWorktreeId:
69
+ worktrees.find((entry) => entry.path === session.projectPath)?.id ?? worktree.id,
70
+ worktrees,
71
+ }
72
+ }
73
+
74
+ function stableWorktreeId(path: string): string {
75
+ return `worktree-${createHash('sha1').update(path).digest('hex').slice(0, 10)}`
76
+ }
77
+
78
+ function mergeExistingGitWorktrees(worktrees: WorktreeRecord[], now: string): WorktreeRecord[] {
79
+ const anchor = worktrees.find((worktree) => existsSync(worktree.path)) ?? worktrees[0]
80
+ if (!anchor) return worktrees
81
+ const discovered = listGitWorktreesSync(anchor.path)
82
+ if (discovered.length === 0) return worktrees
83
+
84
+ // Git keeps admin entries for worktrees whose directory is gone (e.g. a temp
85
+ // dir cleared on reboot). Drop that stale state so the entries stop
86
+ // resurfacing in `git worktree list`, and ignore them while reconciling.
87
+ if (discovered.some((entry) => entry.prunable === true)) {
88
+ pruneGitWorktreesSync(anchor.path)
89
+ }
90
+ const live = discovered.filter((entry) => entry.prunable !== true)
91
+
92
+ const byPath = new Map(worktrees.map((worktree) => [worktree.path, worktree]))
93
+ // `git worktree list` reports the main worktree first; prefer the primary
94
+ // record's repoRoot when it still exists, then git's main worktree.
95
+ const primaryRepoRoot = worktrees.find((worktree) => worktree.source === 'primary')?.repoRoot
96
+ const repoRoot =
97
+ primaryRepoRoot !== undefined && existsSync(primaryRepoRoot)
98
+ ? primaryRepoRoot
99
+ : (live[0]?.path ?? anchor.repoRoot)
100
+ for (const entry of live) {
101
+ if (byPath.has(entry.path)) continue
102
+ if (isInsideAimuxWorktreeRoot(entry.path)) continue
103
+ const worktree: WorktreeRecord = {
104
+ branch: entry.branch,
105
+ commitSha: entry.head,
106
+ createdAt: now,
107
+ createdByAimux: false,
108
+ id: stableWorktreeId(entry.path),
109
+ name: basename(entry.path) || entry.path,
110
+ path: entry.path,
111
+ repoRoot,
112
+ source: 'external',
113
+ updatedAt: now,
114
+ }
115
+ byPath.set(entry.path, worktree)
116
+ }
117
+ return (
118
+ [...byPath.values()]
119
+ // Drop records whose directory no longer exists (a deleted external
120
+ // worktree that git already pruned) so they don't reappear on restart.
121
+ .filter((worktree) => worktree.source === 'primary' || existsSync(worktree.path))
122
+ // Heal records whose repoRoot points at a since-deleted sibling worktree
123
+ // so later git operations (e.g. `git -C repoRoot`) don't fail.
124
+ .map((worktree) =>
125
+ existsSync(worktree.repoRoot) ? worktree : { ...worktree, repoRoot, updatedAt: now }
126
+ )
127
+ )
128
+ }
129
+
130
+ interface DiscoveredWorktree {
131
+ path: string
132
+ head?: string
133
+ branch?: string
134
+ prunable?: boolean
135
+ }
136
+
137
+ function listGitWorktreesSync(cwd: string): DiscoveredWorktree[] {
138
+ const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
139
+ encoding: 'utf8',
140
+ })
141
+ if (result.status !== 0 || !result.stdout) return []
142
+ const worktrees: DiscoveredWorktree[] = []
143
+ let current: DiscoveredWorktree | null = null
144
+ for (const line of result.stdout.split('\n')) {
145
+ if (line.startsWith('worktree ')) {
146
+ if (current) worktrees.push(current)
147
+ current = { path: line.slice('worktree '.length) }
148
+ } else if (current && line.startsWith('HEAD ')) {
149
+ current.head = line.slice('HEAD '.length)
150
+ } else if (current && line.startsWith('branch ')) {
151
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
152
+ } else if (current && line.startsWith('prunable')) {
153
+ current.prunable = true
154
+ }
155
+ }
156
+ if (current) worktrees.push(current)
157
+ return worktrees
158
+ }
159
+
160
+ function pruneGitWorktreesSync(cwd: string): void {
161
+ spawnSync('git', ['-C', cwd, 'worktree', 'prune'], { encoding: 'utf8' })
162
+ }
163
+
164
+ function pruneMissingAimuxTempWorktrees(worktrees: WorktreeRecord[]): WorktreeRecord[] {
165
+ return worktrees.filter((worktree) => {
166
+ if (
167
+ worktree.source !== 'aimux-temp' ||
168
+ !worktree.createdByAimux ||
169
+ !isInsideAimuxWorktreeRoot(worktree.path)
170
+ ) {
171
+ return true
172
+ }
173
+ return existsSync(worktree.path)
174
+ })
175
+ }
176
+
177
+ export function getActiveWorktree(session: SessionRecord | undefined): WorktreeRecord | undefined {
178
+ if (!(session?.worktrees?.length != null && session?.worktrees?.length !== 0)) return undefined
179
+ return (
180
+ session.worktrees.find((worktree) => worktree.id === session.activeWorktreeId) ??
181
+ session.worktrees[0]
182
+ )
183
+ }
184
+
185
+ export function getSessionProjectPath(session: SessionRecord | undefined): string | undefined {
186
+ return getActiveWorktree(session)?.path ?? session?.projectPath
187
+ }
188
+
189
+ export function withActiveWorktree(session: SessionRecord, worktreeId: string): SessionRecord {
190
+ const worktree = session.worktrees?.find((entry) => entry.id === worktreeId)
191
+ if (!worktree) return session
192
+ return {
193
+ ...session,
194
+ activeWorktreeId: worktreeId,
195
+ projectPath: worktree.path,
196
+ updatedAt: new Date().toISOString(),
197
+ }
198
+ }
199
+
200
+ export function getRenderedTabWorktreeId(
201
+ tab: { worktreeId?: string },
202
+ worktrees: { id: string }[]
203
+ ): string {
204
+ return tab.worktreeId ?? worktrees[0]?.id ?? '__main__'
205
+ }
206
+
207
+ export function orderTabsByWorktree(
208
+ tabs: TabSession[],
209
+ session: SessionRecord | undefined
210
+ ): TabSession[] {
211
+ const worktrees = session?.worktrees ?? []
212
+ const order = new Map<string, number>()
213
+ for (const [index, worktree] of worktrees.entries()) {
214
+ order.set(worktree.id, index)
215
+ }
216
+ order.set('__main__', order.size)
217
+
218
+ return tabs
219
+ .map((tab, index) => ({ index, tab }))
220
+ .sort((a, b) => {
221
+ const aw = order.get(getRenderedTabWorktreeId(a.tab, worktrees)) ?? Number.MAX_SAFE_INTEGER
222
+ const bw = order.get(getRenderedTabWorktreeId(b.tab, worktrees)) ?? Number.MAX_SAFE_INTEGER
223
+ if (aw !== bw) return aw - bw
224
+ return a.index - b.index
225
+ })
226
+ .map((entry) => entry.tab)
227
+ }
@@ -117,6 +117,7 @@ export function createInitialState(
117
117
  snippets,
118
118
  tabGroupMap: {},
119
119
  tabs: [],
120
+ worktreeDivergence: {},
120
121
  }
121
122
  }
122
123
 
@@ -0,0 +1,95 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { useStore } from 'zustand'
4
+ import { createStore } from 'zustand/vanilla'
5
+
6
+ import { createPrefixedId } from '../platform/id'
7
+
8
+ export type ToastVariant = 'info' | 'success' | 'warning' | 'error'
9
+ export type ToastPosition = `${'top' | 'bottom'}-${'left' | 'center' | 'right'}`
10
+
11
+ export interface ToastOptions {
12
+ title?: string
13
+ message?: string
14
+ /** Custom body — rendered inside the toast shell instead of title/message. */
15
+ content?: ReactNode
16
+ variant?: ToastVariant
17
+ /** Auto-dismiss after this many ms; 0 keeps it until dismissed. */
18
+ durationMs?: number
19
+ position?: ToastPosition
20
+ }
21
+
22
+ export interface Toast {
23
+ id: string
24
+ createdAt: number
25
+ content?: ReactNode
26
+ durationMs: number
27
+ message?: string
28
+ position: ToastPosition
29
+ title?: string
30
+ variant: ToastVariant
31
+ }
32
+
33
+ export const TOAST_CONFIG = {
34
+ defaultPosition: 'top-right' as ToastPosition,
35
+ // Default auto-dismiss per variant when `durationMs` isn't given. Errors (and
36
+ // warnings) linger longer than confirmations so they're not missed.
37
+ durations: { error: 7000, info: 3500, success: 3500, warning: 5000 } as Record<
38
+ ToastVariant,
39
+ number
40
+ >,
41
+ gap: 1,
42
+ /** Most recent toasts kept visible per position; older ones are evicted. */
43
+ maxVisible: 4,
44
+ /** Instant (no slide) — set in tests or for reduced motion. */
45
+ reduceMotion: false,
46
+ width: 40,
47
+ }
48
+
49
+ interface ToastStoreState {
50
+ toasts: Toast[]
51
+ show: (opts: ToastOptions) => string
52
+ dismiss: (id: string) => void
53
+ clear: () => void
54
+ }
55
+
56
+ export const toastStore = createStore<ToastStoreState>((set) => ({
57
+ clear: () => set({ toasts: [] }),
58
+ dismiss: (id) => set((state) => ({ toasts: state.toasts.filter((entry) => entry.id !== id) })),
59
+ show: (opts) => {
60
+ const id = createPrefixedId('toast')
61
+ const variant = opts.variant ?? 'info'
62
+ const next: Toast = {
63
+ content: opts.content,
64
+ createdAt: Date.now(),
65
+ durationMs: opts.durationMs ?? TOAST_CONFIG.durations[variant],
66
+ id,
67
+ message: opts.message,
68
+ position: opts.position ?? TOAST_CONFIG.defaultPosition,
69
+ title: opts.title,
70
+ variant,
71
+ }
72
+ set((state) => ({ toasts: [...state.toasts, next] }))
73
+ return id
74
+ },
75
+ toasts: [],
76
+ }))
77
+
78
+ export function useToastStore<T>(selector: (state: ToastStoreState) => T): T {
79
+ return useStore(toastStore, selector)
80
+ }
81
+
82
+ function variantShortcut(variant: ToastVariant) {
83
+ return (message: string, opts: Omit<ToastOptions, 'message' | 'variant'> = {}): string =>
84
+ toastStore.getState().show({ ...opts, message, variant })
85
+ }
86
+
87
+ // Imperative API usable anywhere (components, side effects) without React.
88
+ export const toast = {
89
+ dismiss: (id: string) => toastStore.getState().dismiss(id),
90
+ error: variantShortcut('error'),
91
+ info: variantShortcut('info'),
92
+ show: (opts: ToastOptions) => toastStore.getState().show(opts),
93
+ success: variantShortcut('success'),
94
+ warning: variantShortcut('warning'),
95
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ModeId, SnippetVar } from '@brimveyn/aimux-config'
2
2
  import type { ThemedToken } from 'shiki'
3
3
 
4
+ import type { LayoutNode, SplitDirection } from './layout-tree'
5
+
4
6
  export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal' | 'antigravity'
5
7
 
6
8
  export type AssistantId = BuiltinAssistantId | (string & {})
@@ -43,6 +45,7 @@ export type ModalType =
43
45
  | 'git-commit'
44
46
  | 'update-available'
45
47
  | 'ai-usage'
48
+ | 'worktree-move'
46
49
  | null
47
50
 
48
51
  export interface TerminalSpan {
@@ -97,6 +100,7 @@ export interface PersistedTabSnapshot {
97
100
  scrollIntent?: ScrollIntent
98
101
  errorMessage?: string
99
102
  exitCode?: number
103
+ worktreeId?: string
100
104
  }
101
105
 
102
106
  export interface WorkspaceSnapshotV1 {
@@ -108,11 +112,28 @@ export interface WorkspaceSnapshotV1 {
108
112
  width: number
109
113
  }
110
114
  tabs: PersistedTabSnapshot[]
111
- layoutTree?: import('./layout-tree').LayoutNode
112
- layoutTrees?: Record<string, import('./layout-tree').LayoutNode>
115
+ layoutTree?: LayoutNode
116
+ layoutTrees?: Record<string, LayoutNode>
113
117
  tabGroupMap?: Record<string, string>
114
118
  }
115
119
 
120
+ export type WorktreeSource = 'primary' | 'aimux-temp' | 'external'
121
+
122
+ export interface WorktreeRecord {
123
+ id: string
124
+ name: string
125
+ path: string
126
+ repoRoot: string
127
+ branch?: string
128
+ baseRef?: string
129
+ commitSha?: string
130
+ source: WorktreeSource
131
+ createdByAimux: boolean
132
+ color?: string
133
+ createdAt: string
134
+ updatedAt: string
135
+ }
136
+
116
137
  export interface SessionRecord {
117
138
  id: string
118
139
  name: string
@@ -122,6 +143,8 @@ export interface SessionRecord {
122
143
  lastOpenedAt: string
123
144
  order?: number
124
145
  workspaceSnapshot?: WorkspaceSnapshotV1
146
+ worktrees?: WorktreeRecord[]
147
+ activeWorktreeId?: string
125
148
  }
126
149
 
127
150
  export type SessionBarPosition = 'top' | 'bottom'
@@ -144,6 +167,7 @@ export interface TabSession {
144
167
  command: string
145
168
  errorMessage?: string
146
169
  exitCode?: number
170
+ worktreeId?: string
147
171
  }
148
172
 
149
173
  export interface SidebarState {
@@ -160,7 +184,9 @@ export type GitPanePathConfig =
160
184
  | { enabled: false }
161
185
  | { enabled: true; pathFn?: (path: string) => string }
162
186
 
163
- export type GitPaneDiffCountConfig = { enabled: boolean }
187
+ export interface GitPaneDiffCountConfig {
188
+ enabled: boolean
189
+ }
164
190
 
165
191
  export interface GitPaneState {
166
192
  visible: boolean
@@ -271,6 +297,8 @@ export interface GitModeState {
271
297
  folds: Record<string, Record<string, FoldState>>
272
298
  /** Working-tree-vs-HEAD~N offset. 0 = working tree vs HEAD (default). */
273
299
  headOffset: number
300
+ /** When true, diff the active worktree's working tree against its fork point. */
301
+ reviewBase: boolean
274
302
  }
275
303
 
276
304
  interface ModalBase {
@@ -289,6 +317,16 @@ export interface ModalClosed extends ModalBase {
289
317
  export interface ModalNewTab extends ModalBase {
290
318
  type: 'new-tab'
291
319
  editingCommand: AssistantId | null
320
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
321
+ branchError: string | null
322
+ branchName: string
323
+ createWorktree: boolean
324
+ selectedAssistantId: AssistantId | null
325
+ step: 'assistant' | 'worktree' | 'worktree-create'
326
+ targetWorktreeIndex: number
327
+ worktreeDeleteConfirmId: string | null
328
+ worktreeDeleteMessage: string | null
329
+ worktreeName: string
292
330
  }
293
331
 
294
332
  export interface ModalSessionPicker extends ModalBase {
@@ -326,7 +364,7 @@ export interface ModalHelp extends ModalBase {
326
364
 
327
365
  export interface ModalSplitPicker extends ModalBase {
328
366
  type: 'split-picker'
329
- splitDirection: import('./layout-tree').SplitDirection
367
+ splitDirection: SplitDirection
330
368
  }
331
369
 
332
370
  export interface ModalGitCommit extends ModalBase {
@@ -366,6 +404,13 @@ export interface ModalAIUsage extends ModalBase {
366
404
  type: 'ai-usage'
367
405
  }
368
406
 
407
+ export interface ModalWorktreeMove extends ModalBase {
408
+ type: 'worktree-move'
409
+ /** The worktree being moved (may differ from the active one, e.g. a tab menu). */
410
+ sourceWorktreeId: string
411
+ deleteSource: boolean
412
+ }
413
+
369
414
  export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
370
415
 
371
416
  export interface DirectoryResult {
@@ -388,6 +433,7 @@ export type ModalState =
388
433
  | ModalGitCommit
389
434
  | ModalUpdateAvailable
390
435
  | ModalAIUsage
436
+ | ModalWorktreeMove
391
437
 
392
438
  export interface LayoutState {
393
439
  terminalCols: number
@@ -423,7 +469,7 @@ export const EMPTY_MULTI_REPO_STATE: MultiRepoState = { prefixes: {}, repos: []
423
469
  export interface AppState {
424
470
  tabs: TabSession[]
425
471
  activeTabId: string | null
426
- layoutTrees: Record<string, import('./layout-tree').LayoutNode>
472
+ layoutTrees: Record<string, LayoutNode>
427
473
  tabGroupMap: Record<string, string>
428
474
  sessions: SessionRecord[]
429
475
  currentSessionId: string | null
@@ -440,6 +486,11 @@ export interface AppState {
440
486
  gitMode: GitModeState
441
487
  autoCommit: AutoCommitState
442
488
  multiRepo: MultiRepoState
489
+ /**
490
+ * Commits each worktree's branch is ahead/behind the ref it forked from,
491
+ * keyed by worktree id. Ephemeral (polled); not persisted to the catalog.
492
+ */
493
+ worktreeDivergence: Record<string, BranchDivergence>
443
494
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
444
495
  pendingChords: string[] | null
445
496
  }
@@ -448,9 +499,18 @@ export interface AppState {
448
499
  export type ModalAction =
449
500
  | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
450
501
  | { type: 'open-new-tab-modal' }
502
+ | { type: 'set-new-tab-branch-error'; message: string | null }
503
+ | {
504
+ type: 'set-new-tab-worktree-delete-state'
505
+ confirmWorktreeId?: string | null
506
+ message: string | null
507
+ }
508
+ | { type: 'enter-new-tab-worktree-create' }
509
+ | { type: 'select-new-tab-assistant'; assistantId?: AssistantId }
510
+ | { type: 'toggle-new-tab-worktree'; assistantId?: AssistantId }
451
511
  | { type: 'open-edit-custom-command'; assistantId: AssistantId }
452
512
  | { type: 'open-help-modal'; scope?: ModeId }
453
- | { type: 'open-split-picker'; direction: import('./layout-tree').SplitDirection }
513
+ | { type: 'open-split-picker'; direction: SplitDirection }
454
514
  | { type: 'open-session-picker' }
455
515
  | {
456
516
  type: 'open-session-name-modal'
@@ -475,6 +535,8 @@ export type ModalAction =
475
535
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
476
536
  | { type: 'set-modal-selection-index'; index: number }
477
537
  | { type: 'open-ai-usage-modal' }
538
+ | { type: 'open-worktree-move-modal'; sourceWorktreeId: string }
539
+ | { type: 'toggle-worktree-move-delete' }
478
540
 
479
541
  // -- Session actions --
480
542
  export type SessionAction =
@@ -485,6 +547,15 @@ export type SessionAction =
485
547
  | { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
486
548
  | { type: 'reorder-sessions'; orderedIds: string[] }
487
549
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
550
+ | { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
551
+ | { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
552
+ | { type: 'set-active-worktree'; sessionId: string; worktreeId: string }
553
+ | {
554
+ type: 'update-worktree-record'
555
+ sessionId: string
556
+ worktreeId: string
557
+ patch: Partial<WorktreeRecord>
558
+ }
488
559
 
489
560
  // -- Tab actions --
490
561
  export type TabAction =
@@ -493,8 +564,8 @@ export type TabAction =
493
564
  type: 'hydrate-workspace'
494
565
  tabs: TabSession[]
495
566
  activeTabId: string | null
496
- layoutTree?: import('./layout-tree').LayoutNode | null
497
- layoutTrees?: Record<string, import('./layout-tree').LayoutNode>
567
+ layoutTree?: LayoutNode | null
568
+ layoutTrees?: Record<string, LayoutNode>
498
569
  tabGroupMap?: Record<string, string>
499
570
  }
500
571
  | { type: 'close-tab'; tabId: string }
@@ -520,7 +591,7 @@ export type TabAction =
520
591
  export type LayoutAction =
521
592
  | {
522
593
  type: 'split-pane'
523
- direction: import('./layout-tree').SplitDirection
594
+ direction: SplitDirection
524
595
  newTab: TabSession
525
596
  }
526
597
  | { type: 'close-pane'; tabId: string }
@@ -532,13 +603,13 @@ export type LayoutAction =
532
603
  type: 'resize-pane'
533
604
  tabId: string
534
605
  delta: number
535
- axis?: import('./layout-tree').SplitDirection
606
+ axis?: SplitDirection
536
607
  }
537
608
  | {
538
609
  type: 'set-split-ratio'
539
610
  tabId: string
540
611
  ratio: number
541
- axis?: import('./layout-tree').SplitDirection
612
+ axis?: SplitDirection
542
613
  }
543
614
 
544
615
  // -- UI actions --
@@ -566,10 +637,17 @@ export interface GitRefreshPayload {
566
637
  files: GitFileEntry[]
567
638
  }
568
639
 
640
+ /** Commits a worktree branch is ahead/behind the ref it forked from. */
641
+ export interface BranchDivergence {
642
+ ahead: number
643
+ behind: number
644
+ }
645
+
569
646
  export type GitPanelAction =
570
647
  | { type: 'git-refresh-success'; payload: GitRefreshPayload }
571
648
  | { type: 'git-refresh-error'; kind: GitPanelError }
572
649
  | { type: 'git-panel-reset' }
650
+ | { type: 'set-worktree-divergence'; divergence: Record<string, BranchDivergence> }
573
651
 
574
652
  // -- Auto-commit state & actions --
575
653
  export type AutoCommitSuggestion =
@@ -647,8 +725,8 @@ export type GitModeAction =
647
725
  key: string
648
726
  hash: string
649
727
  themeId: string
650
- add: Array<{ start: number; tokens: unknown }>
651
- del: Array<{ start: number; tokens: unknown }>
728
+ add: { start: number; tokens: unknown }[]
729
+ del: { start: number; tokens: unknown }[]
652
730
  }
653
731
  | { type: 'git-mode-invalidate-diffs'; paths: string[] }
654
732
  | { type: 'git-mode-set-loading'; key: string; loading: boolean }
@@ -657,6 +735,7 @@ export type GitModeAction =
657
735
  | { type: 'git-mode-set-message'; message: string | null }
658
736
  | { type: 'snippet-picker-set-message'; message: string | null }
659
737
  | { type: 'git-mode-toggle-diff-view' }
738
+ | { type: 'git-mode-toggle-review-base' }
660
739
  | { type: 'git-mode-shift-head-offset'; delta: number }
661
740
  | { type: 'git-mode-set-head-offset'; offset: number }
662
741
  | {
@@ -5,6 +5,7 @@ import type {
5
5
  TerminalModeState,
6
6
  TerminalSnapshot,
7
7
  WorkspaceSnapshotV1,
8
+ WorktreeRecord,
8
9
  } from './types'
9
10
 
10
11
  function isObjectRecord(value: unknown): value is Record<string, unknown> {
@@ -105,6 +106,24 @@ function isStringRecord(value: unknown): boolean {
105
106
  return Object.values(value).every(isString)
106
107
  }
107
108
 
109
+ function isWorktreeRecord(value: unknown): value is WorktreeRecord {
110
+ return (
111
+ isObjectRecord(value) &&
112
+ isString(value.id) &&
113
+ isString(value.name) &&
114
+ isString(value.path) &&
115
+ isString(value.repoRoot) &&
116
+ (value.branch === undefined || isString(value.branch)) &&
117
+ (value.baseRef === undefined || isString(value.baseRef)) &&
118
+ (value.commitSha === undefined || isString(value.commitSha)) &&
119
+ (value.source === 'primary' || value.source === 'aimux-temp' || value.source === 'external') &&
120
+ isBoolean(value.createdByAimux) &&
121
+ (value.color === undefined || isString(value.color)) &&
122
+ isString(value.createdAt) &&
123
+ isString(value.updatedAt)
124
+ )
125
+ }
126
+
108
127
  export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapshotV1 {
109
128
  return (
110
129
  isObjectRecord(value) &&
@@ -132,7 +151,8 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
132
151
  (tab.viewport === undefined || isTerminalSnapshot(tab.viewport)) &&
133
152
  (tab.scrollIntent === undefined || isScrollIntent(tab.scrollIntent)) &&
134
153
  (tab.errorMessage === undefined || isString(tab.errorMessage)) &&
135
- (tab.exitCode === undefined || isFiniteNumber(tab.exitCode))
154
+ (tab.exitCode === undefined || isFiniteNumber(tab.exitCode)) &&
155
+ (tab.worktreeId === undefined || isString(tab.worktreeId))
136
156
  ) &&
137
157
  (value.layoutTree === undefined || isLayoutNode(value.layoutTree)) &&
138
158
  (value.layoutTrees === undefined || isLayoutTreesMap(value.layoutTrees)) &&
@@ -151,7 +171,10 @@ export function isSessionRecord(value: unknown): value is SessionRecord {
151
171
  isString(value.lastOpenedAt) &&
152
172
  (value.order === undefined ||
153
173
  (typeof value.order === 'number' && Number.isFinite(value.order))) &&
154
- (value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot))
174
+ (value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot)) &&
175
+ (value.worktrees === undefined ||
176
+ (Array.isArray(value.worktrees) && value.worktrees.every(isWorktreeRecord))) &&
177
+ (value.activeWorktreeId === undefined || isString(value.activeWorktreeId))
155
178
  )
156
179
  }
157
180