@brimveyn/aimux 1.21.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -0,0 +1,392 @@
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 {
7
+ BranchDivergence,
8
+ ProjectRecord,
9
+ TabSession,
10
+ WorkspaceActivity,
11
+ WorkspaceRecord,
12
+ } from './types'
13
+
14
+ import { createPrefixedId } from '../platform/id'
15
+ import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
16
+
17
+ export function formatDivergence(divergence: BranchDivergence | undefined): string {
18
+ if (divergence == null) return ''
19
+ const parts: string[] = []
20
+ if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
21
+ if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
22
+ return parts.join(' ')
23
+ }
24
+
25
+ /**
26
+ * The workspace standing for the repo checkout itself.
27
+ *
28
+ * Falls back to the first record because a project loaded from an older
29
+ * catalog can have workspaces with no `primary` among them, and every caller
30
+ * wants *something* to anchor the project row on. Callers that need the real
31
+ * primary or nothing — the CLI, catalog repair — should keep asking for
32
+ * `source === 'primary'` directly and get `undefined` when it is absent.
33
+ */
34
+ export function getPrimaryWorkspace(
35
+ workspaces: WorkspaceRecord[] | undefined
36
+ ): WorkspaceRecord | undefined {
37
+ if (!workspaces) return undefined
38
+ return workspaces.find((workspace) => workspace.source === 'primary') ?? workspaces[0]
39
+ }
40
+
41
+ /** `4823` -> `4.8k`, so a churn number never widens the sidebar row. */
42
+ export function formatDiffCount(value: number): string {
43
+ if (value < 1_000) return String(value)
44
+ const thousands = value / 1_000
45
+ if (thousands < 10) return `${thousands.toFixed(1).replace(/\.0$/, '')}k`
46
+ return `${Math.round(thousands)}k`
47
+ }
48
+
49
+ /** `+149 -629` — lines the workspace changed since it forked. Empty when clean. */
50
+ export function formatDiffStat(divergence: BranchDivergence | undefined): {
51
+ added: string
52
+ removed: string
53
+ } {
54
+ const added = divergence?.added ?? 0
55
+ const removed = divergence?.removed ?? 0
56
+ return {
57
+ added: added > 0 ? `+${formatDiffCount(added)}` : '',
58
+ removed: removed > 0 ? `-${formatDiffCount(removed)}` : '',
59
+ }
60
+ }
61
+
62
+ /**
63
+ * What the repo checkout is called in the sidebar.
64
+ *
65
+ * Not the directory name: the project heading directly above it already says
66
+ * that, and a row repeating its own parent is what made the two look like the
67
+ * same thing. "root" names its role instead — the checkout the worktrees hang
68
+ * off — and the branch under it says which one it is.
69
+ */
70
+ export const PRIMARY_WORKSPACE_NAME = 'root'
71
+
72
+ export function createPrimaryWorkspace(projectPath: string, now: string): WorkspaceRecord {
73
+ const id = createPrefixedId('workspace')
74
+ return {
75
+ createdAt: now,
76
+ createdByAimux: false,
77
+ id,
78
+ name: PRIMARY_WORKSPACE_NAME,
79
+ path: projectPath,
80
+ repoRoot: projectPath,
81
+ source: 'primary',
82
+ updatedAt: now,
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Retire the old primary name — the directory's own basename — for `root`.
88
+ *
89
+ * Catalogs written before the checkout had a row of its own carry it, and there
90
+ * it duplicates the project heading it now sits under. Only that exact name is
91
+ * replaced: anything else is a name the user chose, and it stays.
92
+ */
93
+ function renamePrimaryFromDirectoryName(workspaces: WorkspaceRecord[]): WorkspaceRecord[] {
94
+ return workspaces.map((workspace) =>
95
+ workspace.source === 'primary' && workspace.name === basename(workspace.path)
96
+ ? { ...workspace, name: PRIMARY_WORKSPACE_NAME }
97
+ : workspace
98
+ )
99
+ }
100
+
101
+ export function ensureProjectWorkspaces(
102
+ project: ProjectRecord,
103
+ now = new Date().toISOString()
104
+ ): ProjectRecord {
105
+ if (project.workspaces?.length != null && project.workspaces?.length !== 0) {
106
+ const workspaces = renamePrimaryFromDirectoryName(
107
+ mergeExistingGitWorktrees(pruneMissingAimuxTempWorkspaces(project.workspaces), now)
108
+ )
109
+ if (workspaces.length === 0 && project.projectPath != null && project.projectPath !== '') {
110
+ const workspace = createPrimaryWorkspace(project.projectPath, now)
111
+ return { ...project, activeWorkspaceId: workspace.id, workspaces: [workspace] }
112
+ }
113
+ // Prefer a worktree when the stored selection is gone: coming back to a
114
+ // project, the branch you were on is a better guess than the trunk. Not a
115
+ // restriction — the checkout hosts tabs like any other workspace — just
116
+ // which one to open on. An explicit selection always wins.
117
+ const activeWorkspaceId = workspaces.some((w) => w.id === project.activeWorkspaceId)
118
+ ? project.activeWorkspaceId
119
+ : (workspaces.find((w) => w.source !== 'primary') ?? workspaces[0])?.id
120
+ return {
121
+ ...project,
122
+ activeWorkspaceId,
123
+ // projectPath stays pinned to the repo. The active workspace's path is a
124
+ // separate thing — read it with getActiveWorkspacePath.
125
+ projectPath: workspaces.find((w) => w.source === 'primary')?.repoRoot ?? project.projectPath,
126
+ workspaces,
127
+ }
128
+ }
129
+
130
+ if (!(project.projectPath != null && project.projectPath !== '')) {
131
+ return project
132
+ }
133
+
134
+ const workspace = createPrimaryWorkspace(project.projectPath, now)
135
+ const workspaces = mergeExistingGitWorktrees([workspace], now)
136
+ // Same rule as above: an adopted worktree wins over the repo checkout. A
137
+ // project with nothing but its primary lands there and stays inert until
138
+ // `<C-p>`, which is the intended funnel.
139
+ return {
140
+ ...project,
141
+ activeWorkspaceId: (workspaces.find((entry) => entry.source !== 'primary') ?? workspace).id,
142
+ workspaces,
143
+ }
144
+ }
145
+
146
+ function stableWorkspaceId(path: string): string {
147
+ // Keeps the `worktree-` prefix on purpose: this id is re-derived from the
148
+ // path on every load, so changing it would re-key every adopted workspace
149
+ // and orphan the tabs bound to them.
150
+ return `worktree-${createHash('sha1').update(path).digest('hex').slice(0, 10)}`
151
+ }
152
+
153
+ function mergeExistingGitWorktrees(workspaces: WorkspaceRecord[], now: string): WorkspaceRecord[] {
154
+ const anchor = workspaces.find((workspace) => existsSync(workspace.path)) ?? workspaces[0]
155
+ if (!anchor) return workspaces
156
+ const discovered = listGitWorktreesSync(anchor.path)
157
+ if (discovered.length === 0) return workspaces
158
+
159
+ // Git keeps admin entries for workspaces whose directory is gone (e.g. a temp
160
+ // dir cleared on reboot). Drop that stale state so the entries stop
161
+ // resurfacing in `git worktree list`, and ignore them while reconciling.
162
+ if (discovered.some((entry) => entry.prunable === true)) {
163
+ pruneGitWorktreesSync(anchor.path)
164
+ }
165
+ const live = discovered.filter((entry) => entry.prunable !== true)
166
+
167
+ const byPath = new Map(workspaces.map((workspace) => [workspace.path, workspace]))
168
+ // `git worktree list` reports the main workspace first; prefer the primary
169
+ // record's repoRoot when it still exists, then git's main workspace.
170
+ const primaryRepoRoot = workspaces.find((workspace) => workspace.source === 'primary')?.repoRoot
171
+ const repoRoot =
172
+ primaryRepoRoot !== undefined && existsSync(primaryRepoRoot)
173
+ ? primaryRepoRoot
174
+ : (live[0]?.path ?? anchor.repoRoot)
175
+ for (const entry of live) {
176
+ const existing = byPath.get(entry.path)
177
+ if (existing) {
178
+ // Patch branch / commitSha from git for entries we already track —
179
+ // notably the primary workspace, whose `branch` is never set at creation
180
+ // time (createPrimaryWorkspace doesn't run `git`). Without this, the UI
181
+ // and the divergence poller have no branch for primary workspaces.
182
+ const patchedBranch = entry.branch ?? existing.branch
183
+ const patchedSha = entry.head ?? existing.commitSha
184
+ if (patchedBranch !== existing.branch || patchedSha !== existing.commitSha) {
185
+ byPath.set(entry.path, {
186
+ ...existing,
187
+ branch: patchedBranch,
188
+ commitSha: patchedSha,
189
+ updatedAt: now,
190
+ })
191
+ }
192
+ continue
193
+ }
194
+ if (isInsideAimuxWorktreeRoot(entry.path)) continue
195
+ const workspace: WorkspaceRecord = {
196
+ branch: entry.branch,
197
+ commitSha: entry.head,
198
+ createdAt: now,
199
+ createdByAimux: false,
200
+ id: stableWorkspaceId(entry.path),
201
+ name: basename(entry.path) || entry.path,
202
+ path: entry.path,
203
+ repoRoot,
204
+ source: 'external',
205
+ updatedAt: now,
206
+ }
207
+ byPath.set(entry.path, workspace)
208
+ }
209
+ return (
210
+ [...byPath.values()]
211
+ // Drop records whose directory no longer exists (a deleted external
212
+ // workspace that git already pruned) so they don't reappear on restart.
213
+ .filter((workspace) => workspace.source === 'primary' || existsSync(workspace.path))
214
+ // Heal records whose repoRoot points at a since-deleted sibling workspace
215
+ // so later git operations (e.g. `git -C repoRoot`) don't fail.
216
+ .map((workspace) =>
217
+ existsSync(workspace.repoRoot) ? workspace : { ...workspace, repoRoot, updatedAt: now }
218
+ )
219
+ )
220
+ }
221
+
222
+ interface DiscoveredWorkspace {
223
+ path: string
224
+ head?: string
225
+ branch?: string
226
+ prunable?: boolean
227
+ }
228
+
229
+ function listGitWorktreesSync(cwd: string): DiscoveredWorkspace[] {
230
+ const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
231
+ encoding: 'utf8',
232
+ })
233
+ if (result.status !== 0 || !result.stdout) return []
234
+ const workspaces: DiscoveredWorkspace[] = []
235
+ let current: DiscoveredWorkspace | null = null
236
+ for (const line of result.stdout.split('\n')) {
237
+ if (line.startsWith('worktree ')) {
238
+ if (current) workspaces.push(current)
239
+ current = { path: line.slice('worktree '.length) }
240
+ } else if (current && line.startsWith('HEAD ')) {
241
+ current.head = line.slice('HEAD '.length)
242
+ } else if (current && line.startsWith('branch ')) {
243
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
244
+ } else if (current && line.startsWith('prunable')) {
245
+ current.prunable = true
246
+ }
247
+ }
248
+ if (current) workspaces.push(current)
249
+ return workspaces
250
+ }
251
+
252
+ function pruneGitWorktreesSync(cwd: string): void {
253
+ spawnSync('git', ['-C', cwd, 'worktree', 'prune'], { encoding: 'utf8' })
254
+ }
255
+
256
+ function pruneMissingAimuxTempWorkspaces(workspaces: WorkspaceRecord[]): WorkspaceRecord[] {
257
+ return workspaces.filter((workspace) => {
258
+ if (
259
+ workspace.source !== 'aimux-temp' ||
260
+ !workspace.createdByAimux ||
261
+ !isInsideAimuxWorktreeRoot(workspace.path)
262
+ ) {
263
+ return true
264
+ }
265
+ return existsSync(workspace.path)
266
+ })
267
+ }
268
+
269
+ /**
270
+ * The project the user is in. `currentProjectId` is a nullable string that is
271
+ * also meaningfully empty, so open-coding this lookup means repeating both
272
+ * checks; there is one home for it instead.
273
+ */
274
+ export function getCurrentProject(state: {
275
+ currentProjectId?: string | null
276
+ projects: ProjectRecord[]
277
+ }): ProjectRecord | undefined {
278
+ const id = state.currentProjectId
279
+ if (id == null || id === '') return undefined
280
+ return state.projects.find((project) => project.id === id)
281
+ }
282
+
283
+ /** The project that owns a workspace, and the workspace itself. */
284
+ export function findWorkspace(
285
+ projects: ProjectRecord[],
286
+ workspaceId: string | undefined
287
+ ): { project: ProjectRecord; workspace: WorkspaceRecord } | undefined {
288
+ if (workspaceId == null || workspaceId === '') return undefined
289
+ for (const project of projects) {
290
+ const workspace = project.workspaces?.find((entry) => entry.id === workspaceId)
291
+ if (workspace) return { project, workspace }
292
+ }
293
+ return undefined
294
+ }
295
+
296
+ export function getActiveWorkspace(
297
+ project: ProjectRecord | undefined
298
+ ): WorkspaceRecord | undefined {
299
+ if (!(project?.workspaces?.length != null && project?.workspaces?.length !== 0)) return undefined
300
+ return (
301
+ project.workspaces.find((workspace) => workspace.id === project.activeWorkspaceId) ??
302
+ project.workspaces[0]
303
+ )
304
+ }
305
+
306
+ /**
307
+ * The cwd to work in: the active workspace's path, falling back to the project
308
+ * root. Not the same as `project.projectPath`, which names the repo itself.
309
+ */
310
+ export function getActiveWorkspacePath(project: ProjectRecord | undefined): string | undefined {
311
+ return getActiveWorkspace(project)?.path ?? project?.projectPath
312
+ }
313
+
314
+ export function withActiveWorkspace(project: ProjectRecord, workspaceId: string): ProjectRecord {
315
+ const workspace = project.workspaces?.find((entry) => entry.id === workspaceId)
316
+ if (!workspace) return project
317
+ return {
318
+ ...project,
319
+ activeWorkspaceId: workspaceId,
320
+ updatedAt: new Date().toISOString(),
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Landing on a workspace — entering it, or opening one of its tabs — is what
326
+ * "you have seen it" means, so it drops the finished-a-turn tick. Returns the
327
+ * same map when there is nothing to clear, so every caller can spread it
328
+ * unconditionally without churning the sidebar's subscribers.
329
+ */
330
+ export function clearWorkspaceDone(
331
+ workspaceActivity: Record<string, WorkspaceActivity>,
332
+ workspaceId: string | undefined
333
+ ): Record<string, WorkspaceActivity> {
334
+ if (workspaceId == null || workspaceId === '') return workspaceActivity
335
+ const entry = workspaceActivity[workspaceId]
336
+ if (entry === undefined || !entry.done) return workspaceActivity
337
+ return { ...workspaceActivity, [workspaceId]: { ...entry, done: false } }
338
+ }
339
+
340
+ export function getRenderedTabWorkspaceId(
341
+ tab: { workspaceId?: string },
342
+ workspaces: { id: string }[]
343
+ ): string {
344
+ return tab.workspaceId ?? workspaces[0]?.id ?? '__main__'
345
+ }
346
+
347
+ /**
348
+ * The one gate every "which tabs does the user see" path shares: the top tab
349
+ * bar, Leader+1..9, Ctrl+Tab, the three `activeTabId` re-picks, and flash-jump
350
+ * labels. Hidden tabs are dropped here rather than at each caller — including
351
+ * on the no-project / no-active-workspace paths, which must not become a hole.
352
+ */
353
+ export function filterTabsForActiveWorkspace(
354
+ tabs: TabSession[],
355
+ project: ProjectRecord | undefined
356
+ ): TabSession[] {
357
+ const visible = tabs.filter((tab) => tab.hidden !== true)
358
+ if (!project) return visible
359
+ const activeWorkspaceId = project.activeWorkspaceId
360
+ if (activeWorkspaceId == null || activeWorkspaceId === '') return visible
361
+ const workspaces = project.workspaces ?? []
362
+ const primaryId = workspaces[0]?.id
363
+ const activeIsPrimary = primaryId != null && primaryId === activeWorkspaceId
364
+ return visible.filter((tab) => {
365
+ const owned = tab.workspaceId != null && tab.workspaceId !== ''
366
+ if (owned) return tab.workspaceId === activeWorkspaceId
367
+ // Unbound (legacy) tabs surface only under the primary workspace.
368
+ return activeIsPrimary
369
+ })
370
+ }
371
+
372
+ export function orderTabsByWorkspace(
373
+ tabs: TabSession[],
374
+ project: ProjectRecord | undefined
375
+ ): TabSession[] {
376
+ const workspaces = project?.workspaces ?? []
377
+ const order = new Map<string, number>()
378
+ for (const [index, workspace] of workspaces.entries()) {
379
+ order.set(workspace.id, index)
380
+ }
381
+ order.set('__main__', order.size)
382
+
383
+ return tabs
384
+ .map((tab, index) => ({ index, tab }))
385
+ .sort((a, b) => {
386
+ const aw = order.get(getRenderedTabWorkspaceId(a.tab, workspaces)) ?? Number.MAX_SAFE_INTEGER
387
+ const bw = order.get(getRenderedTabWorkspaceId(b.tab, workspaces)) ?? Number.MAX_SAFE_INTEGER
388
+ if (aw !== bw) return aw - bw
389
+ return a.index - b.index
390
+ })
391
+ .map((entry) => entry.tab)
392
+ }
@@ -1,11 +1,12 @@
1
- import type { AppAction, AppState, AutoCommitState } from '../types'
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, AutoCommitState } from '../types'
2
3
 
3
- function setBySession(
4
+ function setByProject(
4
5
  state: AutoCommitState,
5
- sessionId: string,
6
- next: AutoCommitState['bySession'][string]
6
+ projectId: string,
7
+ next: AutoCommitState['byProject'][string]
7
8
  ): AutoCommitState {
8
- return { bySession: { ...state.bySession, [sessionId]: next } }
9
+ return { byProject: { ...state.byProject, [projectId]: next } }
9
10
  }
10
11
 
11
12
  export function reduceAutoCommitState(
@@ -14,7 +15,7 @@ export function reduceAutoCommitState(
14
15
  ): AutoCommitState | null {
15
16
  switch (action.type) {
16
17
  case 'auto-commit-generation-started': {
17
- return setBySession(state, action.sessionId, {
18
+ return setByProject(state, action.projectId, {
18
19
  abortController: action.abortController,
19
20
  kind: 'generating',
20
21
  startedAt: action.startedAt,
@@ -23,10 +24,10 @@ export function reduceAutoCommitState(
23
24
  })
24
25
  }
25
26
  case 'auto-commit-generation-ready': {
26
- const current = state.bySession[action.sessionId]
27
+ const current = state.byProject[action.projectId]
27
28
  if (!current || current.kind !== 'generating') return null
28
29
  if (current.workingTreeHash !== action.workingTreeHash) return null
29
- return setBySession(state, action.sessionId, {
30
+ return setByProject(state, action.projectId, {
30
31
  body: action.body,
31
32
  generatedAt: action.generatedAt,
32
33
  kind: 'ready',
@@ -36,7 +37,7 @@ export function reduceAutoCommitState(
36
37
  })
37
38
  }
38
39
  case 'auto-commit-clear': {
39
- const current = state.bySession[action.sessionId]
40
+ const current = state.byProject[action.projectId]
40
41
  if (!current || current.kind === 'idle') return null
41
42
  if (current.kind === 'generating') {
42
43
  try {
@@ -45,7 +46,7 @@ export function reduceAutoCommitState(
45
46
  // ignore
46
47
  }
47
48
  }
48
- return setBySession(state, action.sessionId, { kind: 'idle' })
49
+ return setByProject(state, action.projectId, { kind: 'idle' })
49
50
  }
50
51
  default:
51
52
  return null
@@ -0,0 +1,122 @@
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState } from '../types'
3
+
4
+ import { reduceAutoCommitState } from './auto-commit-state'
5
+
6
+ /**
7
+ * The commit modal: its four stages, and the two ways a background auto-commit
8
+ * suggestion lands in it.
9
+ *
10
+ * Its own file because it is the one modal with a stage machine of its own — the
11
+ * others are a buffer and a selection — and because `modal-state.ts` was within a
12
+ * few dozen lines of the 1000-line ceiling.
13
+ *
14
+ * Returns `null` on an action it does not handle, and also when a `git-commit`
15
+ * action arrives with no commit modal open, or with a stale project: those are for
16
+ * `reduceAutoCommit` further down the chain, which owns the slice without caring
17
+ * what is on screen.
18
+ */
19
+ export function reduceGitCommitModalState(state: AppState, action: AppAction): AppState | null {
20
+ switch (action.type) {
21
+ case 'open-git-commit-modal':
22
+ return {
23
+ ...state,
24
+ focusMode: 'command-edit',
25
+ modal: {
26
+ activeField: 'title',
27
+ contentBuffer: '',
28
+ cursorPos: 0,
29
+ editBuffer: '',
30
+ projectTargetId: action.projectId ?? null,
31
+ // Git mode is what is drawn behind it, and closing has always gone back
32
+ // there. Said here rather than in `close-modal`, so that reducer has one
33
+ // rule instead of a rule and this exception.
34
+ returnTo: 'git',
35
+ selectedIndex: 0,
36
+ stage: 'edit',
37
+ type: 'git-commit',
38
+ },
39
+ }
40
+ case 'git-commit-enter-confirm':
41
+ if (state.modal.type !== 'git-commit') return state
42
+ return { ...state, focusMode: 'command-edit', modal: { ...state.modal, stage: 'confirm' } }
43
+ case 'git-commit-leave-confirm':
44
+ if (state.modal.type !== 'git-commit') return state
45
+ return { ...state, focusMode: 'command-edit', modal: { ...state.modal, stage: 'edit' } }
46
+ case 'git-commit-enter-generating':
47
+ if (state.modal.type !== 'git-commit') return state
48
+ return {
49
+ ...state,
50
+ focusMode: 'modal',
51
+ modal: { ...state.modal, projectTargetId: action.projectId, stage: 'generating' },
52
+ }
53
+ case 'git-commit-leave-generating':
54
+ if (state.modal.type !== 'git-commit') return state
55
+ return { ...state, focusMode: 'command-edit', modal: { ...state.modal, stage: 'edit' } }
56
+ case 'auto-commit-generation-ready': {
57
+ if (
58
+ state.modal.type !== 'git-commit' ||
59
+ state.modal.stage !== 'generating' ||
60
+ state.modal.projectTargetId !== action.projectId
61
+ ) {
62
+ return null
63
+ }
64
+ const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
65
+ if (!nextAutoCommit) {
66
+ // Stale result (hash mismatch or slice was cleared mid-flight): don't
67
+ // strand the modal in `generating` — flip back to edit so the user isn't
68
+ // stuck staring at a spinner that will never resolve.
69
+ return { ...state, focusMode: 'command-edit', modal: { ...state.modal, stage: 'edit' } }
70
+ }
71
+ return {
72
+ ...state,
73
+ autoCommit: nextAutoCommit,
74
+ focusMode: 'command-edit',
75
+ modal: {
76
+ ...state.modal,
77
+ activeField: 'title',
78
+ contentBuffer: action.body,
79
+ cursorPos: action.title.length,
80
+ editBuffer: action.title,
81
+ stage: 'confirm',
82
+ },
83
+ }
84
+ }
85
+ case 'git-commit-use-background-suggestion': {
86
+ if (state.modal.type !== 'git-commit' || state.modal.projectTargetId !== action.projectId) {
87
+ return null
88
+ }
89
+ const suggestion = state.autoCommit.byProject[action.projectId]
90
+ if (!suggestion || suggestion.kind !== 'ready') return null
91
+ return {
92
+ ...state,
93
+ focusMode: 'command-edit',
94
+ modal: {
95
+ ...state.modal,
96
+ activeField: 'title',
97
+ contentBuffer: suggestion.body,
98
+ cursorPos: suggestion.title.length,
99
+ editBuffer: suggestion.title,
100
+ stage: 'confirm',
101
+ },
102
+ }
103
+ }
104
+ case 'auto-commit-clear': {
105
+ if (
106
+ state.modal.type !== 'git-commit' ||
107
+ state.modal.stage !== 'generating' ||
108
+ state.modal.projectTargetId !== action.projectId
109
+ ) {
110
+ return null
111
+ }
112
+ return {
113
+ ...state,
114
+ autoCommit: reduceAutoCommitState(state.autoCommit, action) ?? state.autoCommit,
115
+ focusMode: 'command-edit',
116
+ modal: { ...state.modal, stage: 'edit' },
117
+ }
118
+ }
119
+ default:
120
+ return null
121
+ }
122
+ }
@@ -1,4 +1,5 @@
1
1
  import type { PreparedParsedDiff } from '../../ui/components/git/diff-renderer/prepare-diff'
2
+ import type { AppAction } from '../actions'
2
3
 
3
4
  import { collectFoldables } from '../../ui/components/git/diff-renderer/build-rows'
4
5
  import {
@@ -9,7 +10,6 @@ import {
9
10
  reconcileSelectedGitEntryKey,
10
11
  } from '../git-tree'
11
12
  import {
12
- type AppAction,
13
13
  type AppState,
14
14
  type FoldState,
15
15
  getParsedPayload,
@@ -1,5 +1,5 @@
1
+ import type { AppAction } from '../actions'
1
2
  import type {
2
- AppAction,
3
3
  AppState,
4
4
  BranchDivergence,
5
5
  GitFileEntry,
@@ -110,6 +110,10 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
110
110
  if (nextRatio === state.gitPane.diffModeRatio) return state
111
111
  return { ...state, gitPane: { ...state.gitPane, diffModeRatio: nextRatio } }
112
112
  }
113
+ // The preferences with no toggle of their own: the settings screen sets them
114
+ // outright rather than each one growing its own action.
115
+ case 'set-git-pane':
116
+ return { ...state, gitPane: { ...state.gitPane, ...action.patch } }
113
117
  case 'git-refresh-success': {
114
118
  const prev = state.gitPanel
115
119
  const next = action.payload
@@ -162,9 +166,9 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
162
166
  gitPanel: { ...prev, error: action.kind, files: [] },
163
167
  }
164
168
  }
165
- case 'set-worktree-divergence': {
166
- if (sameDivergence(state.worktreeDivergence, action.divergence)) return state
167
- return { ...state, worktreeDivergence: action.divergence }
169
+ case 'set-workspace-divergence': {
170
+ if (sameDivergence(state.workspaceDivergence, action.divergence)) return state
171
+ return { ...state, workspaceDivergence: action.divergence }
168
172
  }
169
173
  case 'git-panel-reset': {
170
174
  const prev = state.gitPanel
@@ -179,9 +183,9 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
179
183
  }
180
184
  return {
181
185
  ...state,
182
- // A reset means the underlying worktree/ref changed, so cached diffs are
186
+ // A reset means the underlying workspace/ref changed, so cached diffs are
183
187
  // stale — drop them (keyed by section:path, they'd otherwise serve a
184
- // previous worktree's diff for a same-named file after a move/switch).
188
+ // previous workspace's diff for a same-named file after a move/switch).
185
189
  gitMode: {
186
190
  ...state.gitMode,
187
191
  diffs: {},