@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
@@ -1,262 +0,0 @@
1
- import type { AppAction, AppState } from '../types'
2
-
3
- import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
4
- import { filterSessions } from '../selectors'
5
- import { restoreWorkspaceState } from '../session-persistence'
6
- import { filterTabsForActiveWorktree, withActiveWorktree } from '../session-worktrees'
7
-
8
- const CLOSED_MODAL = {
9
- editBuffer: null,
10
- selectedIndex: 0,
11
- sessionTargetId: null,
12
- type: null,
13
- } as const
14
-
15
- export function reduceSessionState(state: AppState, action: AppAction): AppState | null {
16
- switch (action.type) {
17
- case 'load-session': {
18
- const snapshot =
19
- action.workspaceSnapshot ??
20
- state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
21
- // The session's activeWorktreeId may have been patched right before
22
- // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
23
- // sets it to the worktree the user just clicked). The snapshot's
24
- // activeTabId still reflects the *last* worktree they were on, so
25
- // honor the patched worktree by filtering the restored tab list.
26
- const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
27
- const loadedWorktrees = loadedSession?.worktrees ?? []
28
- const restored = restoreWorkspaceState(state, snapshot, {
29
- forceDisconnected: action.forceDisconnected ?? true,
30
- // Drop tabs bound to worktrees this session no longer owns so a stale
31
- // id can't be caught by a later worktree delete (closing "another
32
- // worktree's" tabs) and so corrupted catalogs self-heal on load. Only
33
- // prune once the session has worktrees — an empty list means it hasn't
34
- // been initialized yet, and pruning then would wrongly drop bound tabs.
35
- validWorktreeIds:
36
- loadedWorktrees.length > 0 ? new Set(loadedWorktrees.map((w) => w.id)) : undefined,
37
- })
38
- const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
39
- // Prefer the snapshot's tab; if it isn't visible under the active
40
- // worktree (e.g. a multi-worktree session restored onto a different
41
- // worktree), fall back to the last tab we viewed in that worktree,
42
- // then to the first visible tab.
43
- const rememberedForWorktree =
44
- loadedSession?.activeWorktreeId != null && loadedSession.activeWorktreeId !== ''
45
- ? state.lastActiveTabByWorktree[loadedSession.activeWorktreeId]
46
- : undefined
47
- const snapshotTabVisible =
48
- restored.activeTabId != null &&
49
- restored.activeTabId !== '' &&
50
- visible.some((t) => t.id === restored.activeTabId)
51
- const rememberedTabVisible =
52
- rememberedForWorktree != null &&
53
- rememberedForWorktree !== '' &&
54
- visible.some((t) => t.id === rememberedForWorktree)
55
- const fallbackTabId = rememberedTabVisible ? rememberedForWorktree : (visible[0]?.id ?? null)
56
- const activeTabId = snapshotTabVisible ? restored.activeTabId : fallbackTabId
57
- return {
58
- ...state,
59
- ...restored,
60
- activeTabId,
61
- currentSessionId: action.sessionId,
62
- focusMode: 'navigation',
63
- modal: CLOSED_MODAL,
64
- sessions: state.sessions.map((entry) =>
65
- entry.id === action.sessionId
66
- ? {
67
- ...entry,
68
- lastOpenedAt: new Date().toISOString(),
69
- updatedAt: new Date().toISOString(),
70
- }
71
- : entry
72
- ),
73
- }
74
- }
75
- case 'set-sessions':
76
- return { ...state, sessions: action.sessions }
77
- case 'create-session-record':
78
- return {
79
- ...state,
80
- currentSessionId: action.session.id,
81
- focusMode: 'navigation',
82
- modal: CLOSED_MODAL,
83
- sessions: [...state.sessions, action.session],
84
- }
85
- case 'rename-session-record':
86
- return {
87
- ...state,
88
- sessions: state.sessions.map((session) =>
89
- session.id === action.sessionId
90
- ? { ...session, name: action.name, updatedAt: new Date().toISOString() }
91
- : session
92
- ),
93
- }
94
- case 'delete-session-record': {
95
- const deletingCurrent = action.sessionId === state.currentSessionId
96
- const newSessions = state.sessions.filter((session) => session.id !== action.sessionId)
97
- const nextStatuses = { ...state.sessionStatuses }
98
- delete nextStatuses[action.sessionId]
99
- if (action.openSessionPicker === true) {
100
- const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
101
- const maxIndex = filteredNew.length
102
- const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
103
- return {
104
- ...state,
105
- activeTabId: deletingCurrent ? null : state.activeTabId,
106
- currentSessionId: deletingCurrent ? null : state.currentSessionId,
107
- focusMode: 'modal',
108
- modal: {
109
- editBuffer: null,
110
- selectedIndex: clampedIndex,
111
- sessionTargetId: null,
112
- type: 'session-picker',
113
- },
114
- sessions: newSessions,
115
- sessionStatuses: nextStatuses,
116
- tabs: deletingCurrent ? [] : state.tabs,
117
- }
118
- }
119
- return {
120
- ...state,
121
- activeTabId: deletingCurrent ? null : state.activeTabId,
122
- currentSessionId: deletingCurrent ? null : state.currentSessionId,
123
- focusMode: deletingCurrent ? 'navigation' : state.focusMode,
124
- modal: deletingCurrent ? CLOSED_MODAL : state.modal,
125
- sessions: newSessions,
126
- sessionStatuses: nextStatuses,
127
- tabs: deletingCurrent ? [] : state.tabs,
128
- }
129
- }
130
- case 'reorder-sessions': {
131
- const byId = new Map(state.sessions.map((s) => [s.id, s]))
132
- const ordered: typeof state.sessions = []
133
- let idx = 0
134
- for (const id of action.orderedIds) {
135
- const s = byId.get(id)
136
- if (s) {
137
- ordered.push({ ...s, order: idx })
138
- byId.delete(id)
139
- }
140
- idx++
141
- }
142
- let nextOrder = ordered.length
143
- for (const s of byId.values()) {
144
- ordered.push({ ...s, order: nextOrder++ })
145
- }
146
- return { ...state, sessions: ordered }
147
- }
148
- case 'reorder-active-session': {
149
- const currentId = state.currentSessionId
150
- if (currentId == null || currentId === '') return state
151
- const ids = orderSessionsForDisplay(state.sessions).map((s) => s.id)
152
- const from = ids.indexOf(currentId)
153
- if (from < 0) return state
154
- const to = from + action.delta
155
- const targetId = ids[to]
156
- if (targetId == null) return state
157
- const nextIds = moveIdToIdPosition(ids, currentId, targetId)
158
- const byId = new Map(state.sessions.map((s) => [s.id, s]))
159
- const nextSessions = nextIds.map((id, idx) => {
160
- const s = byId.get(id)
161
- return s ? { ...s, order: idx } : s
162
- })
163
- const filtered = nextSessions.filter((s): s is NonNullable<typeof s> => s != null)
164
- if (filtered.length !== state.sessions.length) return state
165
- return { ...state, sessions: filtered }
166
- }
167
- case 'set-session-status': {
168
- const prev = state.sessionStatuses[action.sessionId]
169
- if (
170
- prev !== undefined &&
171
- prev.working === action.status.working &&
172
- prev.waiting === action.status.waiting
173
- ) {
174
- return state
175
- }
176
- return {
177
- ...state,
178
- sessionStatuses: { ...state.sessionStatuses, [action.sessionId]: action.status },
179
- }
180
- }
181
- case 'add-worktree-record':
182
- return {
183
- ...state,
184
- sessions: state.sessions.map((session) => {
185
- if (session.id !== action.sessionId) return session
186
- const next = {
187
- ...session,
188
- activeWorktreeId:
189
- action.activate === true ? action.worktree.id : session.activeWorktreeId,
190
- projectPath: action.activate === true ? action.worktree.path : session.projectPath,
191
- updatedAt: new Date().toISOString(),
192
- worktrees: [...(session.worktrees ?? []), action.worktree],
193
- }
194
- return next.activeWorktreeId != null && next.activeWorktreeId !== ''
195
- ? next
196
- : withActiveWorktree(next, action.worktree.id)
197
- }),
198
- }
199
- case 'set-active-worktree': {
200
- const sessions = state.sessions.map((session) =>
201
- session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
202
- )
203
- // Remember the tab we're leaving so coming back to this worktree later
204
- // restores it instead of snapping to the first tab. Keyed by the
205
- // worktree we're departing (the current session's active one).
206
- const leavingSession = state.sessions.find((s) => s.id === action.sessionId)
207
- const leavingWorktreeId = leavingSession?.activeWorktreeId
208
- const lastActiveTabByWorktree =
209
- action.sessionId === state.currentSessionId &&
210
- leavingWorktreeId != null &&
211
- leavingWorktreeId !== '' &&
212
- state.activeTabId != null &&
213
- state.activeTabId !== ''
214
- ? { ...state.lastActiveTabByWorktree, [leavingWorktreeId]: state.activeTabId }
215
- : state.lastActiveTabByWorktree
216
- // Changing the worktree of a non-current session has no effect on
217
- // which tab the user is looking at — leave activeTabId alone.
218
- if (action.sessionId !== state.currentSessionId) {
219
- return { ...state, lastActiveTabByWorktree, sessions }
220
- }
221
- // For the current session: if the existing activeTabId belongs to
222
- // the new worktree, keep it. Otherwise restore the last tab we viewed
223
- // in that worktree, falling back to the first visible tab (or clearing
224
- // it if the worktree is empty — the pane then renders the "this
225
- // worktree has no tabs" placeholder).
226
- const updated = sessions.find((s) => s.id === action.sessionId)
227
- const visible = filterTabsForActiveWorktree(state.tabs, updated)
228
- const currentStillVisible =
229
- state.activeTabId != null &&
230
- state.activeTabId !== '' &&
231
- visible.some((t) => t.id === state.activeTabId)
232
- if (currentStillVisible) {
233
- return { ...state, lastActiveTabByWorktree, sessions }
234
- }
235
- const remembered = lastActiveTabByWorktree[action.worktreeId]
236
- const nextActiveTabId =
237
- remembered != null && remembered !== '' && visible.some((t) => t.id === remembered)
238
- ? remembered
239
- : (visible[0]?.id ?? null)
240
- return { ...state, activeTabId: nextActiveTabId, lastActiveTabByWorktree, sessions }
241
- }
242
- case 'update-worktree-record':
243
- return {
244
- ...state,
245
- sessions: state.sessions.map((session) =>
246
- session.id === action.sessionId
247
- ? {
248
- ...session,
249
- updatedAt: new Date().toISOString(),
250
- worktrees: session.worktrees?.map((worktree) =>
251
- worktree.id === action.worktreeId
252
- ? { ...worktree, ...action.patch, updatedAt: new Date().toISOString() }
253
- : worktree
254
- ),
255
- }
256
- : session
257
- ),
258
- }
259
- default:
260
- return null
261
- }
262
- }
@@ -1,143 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
-
4
- import type { SessionRecord } from './types'
5
-
6
- import { loadConfig, saveConfig } from '../config'
7
- import { logDebug } from '../debug/input-log'
8
- import { getProfileConfigDir } from '../profile-paths'
9
- import { ensureSessionWorktrees } from './session-worktrees'
10
- import { toast } from './toast-store'
11
- import { isSessionRecord } from './validation'
12
-
13
- interface SessionCatalogFile {
14
- version: 1
15
- sessions: SessionRecord[]
16
- }
17
-
18
- const SESSIONS_PATH = join(getProfileConfigDir(), 'aimux-sessions.json')
19
-
20
- function readCatalogFile(): { file: SessionCatalogFile | null; issue?: string } {
21
- try {
22
- if (!existsSync(SESSIONS_PATH)) {
23
- return { file: null }
24
- }
25
-
26
- const parsed = JSON.parse(readFileSync(SESSIONS_PATH, 'utf8')) as {
27
- version?: unknown
28
- sessions?: unknown
29
- }
30
- if (parsed.version !== 1 || !Array.isArray(parsed.sessions)) {
31
- return { file: null, issue: 'invalid session catalog header' }
32
- }
33
-
34
- if (!parsed.sessions.every(isSessionRecord)) {
35
- return { file: null, issue: 'invalid session catalog entries' }
36
- }
37
-
38
- return { file: { sessions: parsed.sessions, version: 1 } }
39
- } catch (error) {
40
- return {
41
- file: null,
42
- issue: `failed to load session catalog: ${error instanceof Error ? error.message : String(error)}`,
43
- }
44
- }
45
- }
46
-
47
- export function loadSessionCatalog(): SessionRecord[] {
48
- const { file, issue } = readCatalogFile()
49
- if (file) {
50
- logDebug('sessions.catalog.load', { sessionCount: file.sessions.length })
51
- const normalized = normalizeSessions(file.sessions)
52
- if (JSON.stringify(normalized) !== JSON.stringify(file.sessions)) {
53
- saveSessionCatalog(normalized)
54
- }
55
- return normalized
56
- }
57
-
58
- if (issue != null && issue !== '') {
59
- logDebug('sessions.catalog.loadIssue', { issue, path: SESSIONS_PATH })
60
- }
61
-
62
- const config = loadConfig()
63
- if (!config.workspaceSnapshot) {
64
- logDebug('sessions.catalog.load', { sessionCount: 0 })
65
- return []
66
- }
67
-
68
- const now = new Date().toISOString()
69
- const migrated: SessionRecord = {
70
- createdAt: now,
71
- id: `session-${Date.now()}`,
72
- lastOpenedAt: now,
73
- name: 'Last workspace',
74
- updatedAt: now,
75
- workspaceSnapshot: config.workspaceSnapshot,
76
- }
77
-
78
- const normalized = normalizeSessions([migrated])
79
- saveSessionCatalog(normalized)
80
- saveConfig({ ...config, workspaceSnapshot: undefined })
81
- logDebug('sessions.catalog.migrateLegacyWorkspace', {
82
- migratedSessionId: migrated.id,
83
- tabCount: migrated.workspaceSnapshot?.tabs.length ?? 0,
84
- })
85
- return normalized
86
- }
87
-
88
- export function saveSessionCatalog(sessions: SessionRecord[]): void {
89
- try {
90
- mkdirSync(getProfileConfigDir(), { recursive: true })
91
- writeFileSync(SESSIONS_PATH, `${JSON.stringify({ sessions, version: 1 }, null, 2)}\n`)
92
- logDebug('sessions.catalog.save', { sessionCount: sessions.length })
93
- } catch (error) {
94
- const message = error instanceof Error ? error.message : String(error)
95
- logDebug('sessions.catalog.saveError', {
96
- error: message,
97
- path: SESSIONS_PATH,
98
- sessionCount: sessions.length,
99
- })
100
- // Persisting the catalog underpins workspace/worktree state — a silent
101
- // failure means the user loses sessions on restart. Surface it.
102
- toast.error(`Failed to save sessions: ${message}`)
103
- }
104
- }
105
-
106
- export function getSessionCatalogPath(): string {
107
- return SESSIONS_PATH
108
- }
109
-
110
- export function findMostRecentSession(sessions: SessionRecord[]): SessionRecord | undefined {
111
- let best: SessionRecord | undefined
112
- for (const candidate of sessions) {
113
- if (!best || candidate.lastOpenedAt.localeCompare(best.lastOpenedAt) > 0) {
114
- best = candidate
115
- }
116
- }
117
- return best
118
- }
119
-
120
- /**
121
- * Assign a stable `order` to every session. Records with an existing numeric
122
- * `order` keep their slot (sorted ascending); the rest are appended by
123
- * createdAt ascending so older sessions come first.
124
- */
125
- function normalizeOrder(sessions: SessionRecord[]): SessionRecord[] {
126
- const withOrder: SessionRecord[] = []
127
- const withoutOrder: SessionRecord[] = []
128
- for (const s of sessions) {
129
- if (typeof s.order === 'number' && Number.isFinite(s.order)) {
130
- withOrder.push(s)
131
- } else {
132
- withoutOrder.push(s)
133
- }
134
- }
135
- withOrder.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
136
- withoutOrder.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
137
- const merged = [...withOrder, ...withoutOrder]
138
- return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
139
- }
140
-
141
- function normalizeSessions(sessions: SessionRecord[]): SessionRecord[] {
142
- return normalizeOrder(sessions).map((session) => ensureSessionWorktrees(session))
143
- }
@@ -1,260 +0,0 @@
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 { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from './types'
7
-
8
- import { createPrefixedId } from '../platform/id'
9
- import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
-
11
- export function formatDivergence(divergence: BranchDivergence | undefined): string {
12
- if (divergence == null) return ''
13
- const parts: string[] = []
14
- if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
15
- if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
16
- return parts.join(' ')
17
- }
18
-
19
- export function createPrimaryWorktree(projectPath: string, now: string): WorktreeRecord {
20
- const id = createPrefixedId('worktree')
21
- return {
22
- createdAt: now,
23
- createdByAimux: false,
24
- id,
25
- name: basename(projectPath) || projectPath,
26
- path: projectPath,
27
- repoRoot: projectPath,
28
- source: 'primary',
29
- updatedAt: now,
30
- }
31
- }
32
-
33
- export function ensureSessionWorktrees(
34
- session: SessionRecord,
35
- now = new Date().toISOString()
36
- ): SessionRecord {
37
- if (session.worktrees?.length != null && session.worktrees?.length !== 0) {
38
- const worktrees = mergeExistingGitWorktrees(
39
- pruneMissingAimuxTempWorktrees(session.worktrees),
40
- now
41
- )
42
- if (worktrees.length === 0 && session.projectPath != null && session.projectPath !== '') {
43
- const worktree = createPrimaryWorktree(session.projectPath, now)
44
- return { ...session, activeWorktreeId: worktree.id, worktrees: [worktree] }
45
- }
46
- const activeWorktreeId = worktrees.some((w) => w.id === session.activeWorktreeId)
47
- ? session.activeWorktreeId
48
- : worktrees[0]?.id
49
- const active = worktrees.find((w) => w.id === activeWorktreeId)
50
- return {
51
- ...session,
52
- activeWorktreeId,
53
- projectPath: active?.path ?? session.projectPath,
54
- worktrees,
55
- }
56
- }
57
-
58
- if (!(session.projectPath != null && session.projectPath !== '')) {
59
- return session
60
- }
61
-
62
- const worktree = createPrimaryWorktree(session.projectPath, now)
63
- const worktrees = mergeExistingGitWorktrees([worktree], now)
64
- return {
65
- ...session,
66
- activeWorktreeId:
67
- worktrees.find((entry) => entry.path === session.projectPath)?.id ?? worktree.id,
68
- worktrees,
69
- }
70
- }
71
-
72
- function stableWorktreeId(path: string): string {
73
- return `worktree-${createHash('sha1').update(path).digest('hex').slice(0, 10)}`
74
- }
75
-
76
- function mergeExistingGitWorktrees(worktrees: WorktreeRecord[], now: string): WorktreeRecord[] {
77
- const anchor = worktrees.find((worktree) => existsSync(worktree.path)) ?? worktrees[0]
78
- if (!anchor) return worktrees
79
- const discovered = listGitWorktreesSync(anchor.path)
80
- if (discovered.length === 0) return worktrees
81
-
82
- // Git keeps admin entries for worktrees whose directory is gone (e.g. a temp
83
- // dir cleared on reboot). Drop that stale state so the entries stop
84
- // resurfacing in `git worktree list`, and ignore them while reconciling.
85
- if (discovered.some((entry) => entry.prunable === true)) {
86
- pruneGitWorktreesSync(anchor.path)
87
- }
88
- const live = discovered.filter((entry) => entry.prunable !== true)
89
-
90
- const byPath = new Map(worktrees.map((worktree) => [worktree.path, worktree]))
91
- // `git worktree list` reports the main worktree first; prefer the primary
92
- // record's repoRoot when it still exists, then git's main worktree.
93
- const primaryRepoRoot = worktrees.find((worktree) => worktree.source === 'primary')?.repoRoot
94
- const repoRoot =
95
- primaryRepoRoot !== undefined && existsSync(primaryRepoRoot)
96
- ? primaryRepoRoot
97
- : (live[0]?.path ?? anchor.repoRoot)
98
- for (const entry of live) {
99
- const existing = byPath.get(entry.path)
100
- if (existing) {
101
- // Patch branch / commitSha from git for entries we already track —
102
- // notably the primary worktree, whose `branch` is never set at creation
103
- // time (createPrimaryWorktree doesn't run `git`). Without this, the UI
104
- // and the divergence poller have no branch for primary worktrees.
105
- const patchedBranch = entry.branch ?? existing.branch
106
- const patchedSha = entry.head ?? existing.commitSha
107
- if (patchedBranch !== existing.branch || patchedSha !== existing.commitSha) {
108
- byPath.set(entry.path, {
109
- ...existing,
110
- branch: patchedBranch,
111
- commitSha: patchedSha,
112
- updatedAt: now,
113
- })
114
- }
115
- continue
116
- }
117
- if (isInsideAimuxWorktreeRoot(entry.path)) continue
118
- const worktree: WorktreeRecord = {
119
- branch: entry.branch,
120
- commitSha: entry.head,
121
- createdAt: now,
122
- createdByAimux: false,
123
- id: stableWorktreeId(entry.path),
124
- name: basename(entry.path) || entry.path,
125
- path: entry.path,
126
- repoRoot,
127
- source: 'external',
128
- updatedAt: now,
129
- }
130
- byPath.set(entry.path, worktree)
131
- }
132
- return (
133
- [...byPath.values()]
134
- // Drop records whose directory no longer exists (a deleted external
135
- // worktree that git already pruned) so they don't reappear on restart.
136
- .filter((worktree) => worktree.source === 'primary' || existsSync(worktree.path))
137
- // Heal records whose repoRoot points at a since-deleted sibling worktree
138
- // so later git operations (e.g. `git -C repoRoot`) don't fail.
139
- .map((worktree) =>
140
- existsSync(worktree.repoRoot) ? worktree : { ...worktree, repoRoot, updatedAt: now }
141
- )
142
- )
143
- }
144
-
145
- interface DiscoveredWorktree {
146
- path: string
147
- head?: string
148
- branch?: string
149
- prunable?: boolean
150
- }
151
-
152
- function listGitWorktreesSync(cwd: string): DiscoveredWorktree[] {
153
- const result = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], {
154
- encoding: 'utf8',
155
- })
156
- if (result.status !== 0 || !result.stdout) return []
157
- const worktrees: DiscoveredWorktree[] = []
158
- let current: DiscoveredWorktree | null = null
159
- for (const line of result.stdout.split('\n')) {
160
- if (line.startsWith('worktree ')) {
161
- if (current) worktrees.push(current)
162
- current = { path: line.slice('worktree '.length) }
163
- } else if (current && line.startsWith('HEAD ')) {
164
- current.head = line.slice('HEAD '.length)
165
- } else if (current && line.startsWith('branch ')) {
166
- current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
167
- } else if (current && line.startsWith('prunable')) {
168
- current.prunable = true
169
- }
170
- }
171
- if (current) worktrees.push(current)
172
- return worktrees
173
- }
174
-
175
- function pruneGitWorktreesSync(cwd: string): void {
176
- spawnSync('git', ['-C', cwd, 'worktree', 'prune'], { encoding: 'utf8' })
177
- }
178
-
179
- function pruneMissingAimuxTempWorktrees(worktrees: WorktreeRecord[]): WorktreeRecord[] {
180
- return worktrees.filter((worktree) => {
181
- if (
182
- worktree.source !== 'aimux-temp' ||
183
- !worktree.createdByAimux ||
184
- !isInsideAimuxWorktreeRoot(worktree.path)
185
- ) {
186
- return true
187
- }
188
- return existsSync(worktree.path)
189
- })
190
- }
191
-
192
- export function getActiveWorktree(session: SessionRecord | undefined): WorktreeRecord | undefined {
193
- if (!(session?.worktrees?.length != null && session?.worktrees?.length !== 0)) return undefined
194
- return (
195
- session.worktrees.find((worktree) => worktree.id === session.activeWorktreeId) ??
196
- session.worktrees[0]
197
- )
198
- }
199
-
200
- export function getSessionProjectPath(session: SessionRecord | undefined): string | undefined {
201
- return getActiveWorktree(session)?.path ?? session?.projectPath
202
- }
203
-
204
- export function withActiveWorktree(session: SessionRecord, worktreeId: string): SessionRecord {
205
- const worktree = session.worktrees?.find((entry) => entry.id === worktreeId)
206
- if (!worktree) return session
207
- return {
208
- ...session,
209
- activeWorktreeId: worktreeId,
210
- projectPath: worktree.path,
211
- updatedAt: new Date().toISOString(),
212
- }
213
- }
214
-
215
- export function getRenderedTabWorktreeId(
216
- tab: { worktreeId?: string },
217
- worktrees: { id: string }[]
218
- ): string {
219
- return tab.worktreeId ?? worktrees[0]?.id ?? '__main__'
220
- }
221
-
222
- export function filterTabsForActiveWorktree(
223
- tabs: TabSession[],
224
- session: SessionRecord | undefined
225
- ): TabSession[] {
226
- if (!session) return tabs
227
- const activeWorktreeId = session.activeWorktreeId
228
- if (activeWorktreeId == null || activeWorktreeId === '') return tabs
229
- const worktrees = session.worktrees ?? []
230
- const primaryId = worktrees[0]?.id
231
- const activeIsPrimary = primaryId != null && primaryId === activeWorktreeId
232
- return tabs.filter((tab) => {
233
- const owned = tab.worktreeId != null && tab.worktreeId !== ''
234
- if (owned) return tab.worktreeId === activeWorktreeId
235
- // Unbound (legacy) tabs surface only under the primary worktree.
236
- return activeIsPrimary
237
- })
238
- }
239
-
240
- export function orderTabsByWorktree(
241
- tabs: TabSession[],
242
- session: SessionRecord | undefined
243
- ): TabSession[] {
244
- const worktrees = session?.worktrees ?? []
245
- const order = new Map<string, number>()
246
- for (const [index, worktree] of worktrees.entries()) {
247
- order.set(worktree.id, index)
248
- }
249
- order.set('__main__', order.size)
250
-
251
- return tabs
252
- .map((tab, index) => ({ index, tab }))
253
- .sort((a, b) => {
254
- const aw = order.get(getRenderedTabWorktreeId(a.tab, worktrees)) ?? Number.MAX_SAFE_INTEGER
255
- const bw = order.get(getRenderedTabWorktreeId(b.tab, worktrees)) ?? Number.MAX_SAFE_INTEGER
256
- if (aw !== bw) return aw - bw
257
- return a.index - b.index
258
- })
259
- .map((entry) => entry.tab)
260
- }