@brimveyn/aimux 1.20.4 → 1.22.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 (233) 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 +305 -1589
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/split-drag-controller.ts +4 -8
  19. package/src/app-runtime/tab-actions.ts +218 -0
  20. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  21. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  22. package/src/app-runtime/use-backend-runtime.ts +12 -11
  23. package/src/app-runtime/use-directory-search.ts +5 -4
  24. package/src/app-runtime/use-mouse-handlers.ts +28 -42
  25. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  26. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  27. package/src/app-runtime/use-setup-runner.ts +96 -0
  28. package/src/app-runtime/use-terminal-resize.ts +28 -33
  29. package/src/app-runtime/workspace-actions.ts +366 -0
  30. package/src/app-runtime/workspace-activity.ts +138 -0
  31. package/src/app-runtime/workspace-naming.ts +90 -0
  32. package/src/app.tsx +102 -85
  33. package/src/assets/sounds/bell.wav +0 -0
  34. package/src/assets/sounds/ding.wav +0 -0
  35. package/src/assets/sounds/plane.wav +0 -0
  36. package/src/cli/client/daemon-client.ts +2 -2
  37. package/src/cli/client/project-resolver.ts +112 -0
  38. package/src/cli/commands/project/close.ts +32 -0
  39. package/src/cli/commands/project/create.ts +93 -0
  40. package/src/cli/commands/project/list.ts +25 -0
  41. package/src/cli/commands/project/show.ts +32 -0
  42. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  43. package/src/cli/commands/tab/await.ts +2 -2
  44. package/src/cli/commands/tab/close.ts +3 -3
  45. package/src/cli/commands/tab/create.ts +74 -73
  46. package/src/cli/commands/tab/focus.ts +3 -3
  47. package/src/cli/commands/tab/list.ts +6 -6
  48. package/src/cli/commands/tab/run.ts +2 -2
  49. package/src/cli/commands/tab/send.ts +2 -2
  50. package/src/cli/commands/tab/snapshot.ts +2 -2
  51. package/src/cli/commands/tab/tail.ts +2 -2
  52. package/src/cli/commands/tab/wait.ts +2 -2
  53. package/src/cli/commands/worker/await.ts +3 -3
  54. package/src/cli/commands/worker/doctor.ts +30 -30
  55. package/src/cli/commands/worker/list.ts +15 -15
  56. package/src/cli/commands/worker/prompt.ts +3 -3
  57. package/src/cli/commands/worker/run.ts +23 -23
  58. package/src/cli/commands/worker/shared.ts +53 -59
  59. package/src/cli/commands/worker/stop.ts +29 -29
  60. package/src/cli/commands/worker/submit.ts +3 -3
  61. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  62. package/src/cli/commands/workspace/create.ts +30 -68
  63. package/src/cli/commands/workspace/list.ts +52 -9
  64. package/src/cli/commands/workspace/remove.ts +81 -0
  65. package/src/cli/completion/plan.ts +2 -2
  66. package/src/cli/completion/sources.ts +18 -18
  67. package/src/cli/context.ts +13 -13
  68. package/src/cli/flags.ts +30 -10
  69. package/src/cli/index.ts +26 -16
  70. package/src/cli/output.ts +1 -1
  71. package/src/cli/registry.ts +12 -12
  72. package/src/config/loader.ts +16 -5
  73. package/src/config.ts +149 -101
  74. package/src/daemon/catalog-writer.ts +67 -67
  75. package/src/daemon/daemon.ts +205 -201
  76. package/src/daemon/session-manager.ts +39 -39
  77. package/src/daemon/session-registry.ts +14 -14
  78. package/src/git/divergence.ts +33 -2
  79. package/src/git/git-poller.ts +1 -1
  80. package/src/git/git-status.ts +2 -2
  81. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  82. package/src/git/pr-merge.ts +64 -0
  83. package/src/git/pr-status-poller.ts +57 -0
  84. package/src/git/pr-status.ts +227 -0
  85. package/src/git/repo-discovery.ts +1 -1
  86. package/src/git/use-repo-discovery.ts +1 -1
  87. package/src/git/workspace-branch-poller.ts +54 -0
  88. package/src/git/workspace-divergence-poller.ts +65 -0
  89. package/src/git/worktree.ts +29 -0
  90. package/src/index.tsx +11 -6
  91. package/src/input/keymap/help-entries.ts +7 -5
  92. package/src/input/modes/bridge.ts +13 -12
  93. package/src/input/modes/handlers/shared.ts +1 -1
  94. package/src/input/modes/transitions.ts +34 -21
  95. package/src/input/modes/types.ts +48 -31
  96. package/src/ipc/manager-protocol.ts +44 -44
  97. package/src/ipc/protocol.ts +192 -166
  98. package/src/platform/open-url.ts +39 -0
  99. package/src/platform/play-sound.ts +184 -0
  100. package/src/platform/project-search.ts +8 -8
  101. package/src/platform/worktree-paths.ts +8 -6
  102. package/src/pty/assistant-question-extractor.ts +1 -1
  103. package/src/pty/assistant-status-detection-loop.ts +79 -55
  104. package/src/pty/assistant-status-detector.ts +40 -15
  105. package/src/pty/command-registry.ts +81 -1
  106. package/src/restart-terminal-manager.ts +1 -1
  107. package/src/services/ai-usage/provider.ts +9 -2
  108. package/src/services/ai-usage/spawn.ts +3 -1
  109. package/src/session-backend/bootstrap.ts +4 -4
  110. package/src/session-backend/local-session-backend.ts +78 -75
  111. package/src/session-backend/remote-session-backend.ts +64 -52
  112. package/src/session-backend/types.ts +43 -32
  113. package/src/settings/live.ts +90 -0
  114. package/src/settings/search.ts +43 -0
  115. package/src/settings/sections/about.ts +49 -0
  116. package/src/settings/sections/appearance.ts +54 -0
  117. package/src/settings/sections/automation.ts +105 -0
  118. package/src/settings/sections/commands.ts +72 -0
  119. package/src/settings/sections/editor.ts +56 -0
  120. package/src/settings/sections/experimental.ts +57 -0
  121. package/src/settings/sections/git.ts +112 -0
  122. package/src/settings/sections/index.ts +95 -0
  123. package/src/settings/sections/integrations.ts +23 -0
  124. package/src/settings/sections/layout.ts +72 -0
  125. package/src/settings/sections/notifications.ts +99 -0
  126. package/src/settings/sections/setup.ts +54 -0
  127. package/src/settings/sections/status-bar.ts +63 -0
  128. package/src/settings/settings-store.ts +207 -0
  129. package/src/settings/types.ts +108 -0
  130. package/src/snippets/run-shell-var.ts +48 -22
  131. package/src/state/actions.ts +342 -0
  132. package/src/state/app-store.ts +2 -1
  133. package/src/state/bars.ts +75 -0
  134. package/src/state/dispatch-ref.ts +1 -1
  135. package/src/state/git-pane-sizing.ts +0 -9
  136. package/src/state/layout-resize.ts +2 -2
  137. package/src/state/pr-status-store.ts +39 -0
  138. package/src/state/project-catalog.ts +275 -0
  139. package/src/state/project-data.ts +143 -0
  140. package/src/state/{session-persistence.ts → project-persistence.ts} +77 -73
  141. package/src/state/project-save.ts +46 -0
  142. package/src/state/project-workspaces.ts +392 -0
  143. package/src/state/reducers/auto-commit-state.ts +11 -10
  144. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  145. package/src/state/reducers/git-mode-state.ts +1 -1
  146. package/src/state/reducers/git-panel-state.ts +10 -53
  147. package/src/state/reducers/modal-state.ts +290 -681
  148. package/src/state/reducers/multi-repo-state.ts +3 -6
  149. package/src/state/reducers/project-state.ts +312 -0
  150. package/src/state/reducers/settings-state.ts +80 -0
  151. package/src/state/reducers/tab-state.ts +107 -60
  152. package/src/state/reducers/ui-state.ts +86 -15
  153. package/src/state/selectors.ts +49 -37
  154. package/src/state/store.ts +83 -57
  155. package/src/state/types.ts +261 -417
  156. package/src/state/validation.ts +14 -12
  157. package/src/terminal-manager/manager-client.ts +32 -32
  158. package/src/terminal-manager/terminal-manager.ts +24 -24
  159. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  160. package/src/ui/components/git/git-panel.tsx +2 -2
  161. package/src/ui/components/git/git-view.tsx +12 -12
  162. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  163. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  164. package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
  165. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  166. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  167. package/src/ui/components/layout/bar.tsx +191 -0
  168. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  169. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  170. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  171. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  172. package/src/ui/components/layout/status-bar.tsx +6 -2
  173. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  174. package/src/ui/components/layout/top-tab-bar.tsx +48 -37
  175. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  176. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  177. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  178. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  179. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  180. package/src/ui/components/modals/shared/form.tsx +34 -22
  181. package/src/ui/components/modals/shared/picker.tsx +10 -10
  182. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  183. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  184. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  185. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  186. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  187. package/src/ui/components/primitives/input-field.tsx +7 -3
  188. package/src/ui/components/primitives/surface.tsx +3 -0
  189. package/src/ui/components/settings/row-value.tsx +74 -0
  190. package/src/ui/components/settings/settings-row.tsx +78 -0
  191. package/src/ui/components/settings/settings-view.tsx +169 -0
  192. package/src/ui/components/setup/setup-widget.tsx +152 -0
  193. package/src/ui/flash/build-labels.ts +27 -27
  194. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  195. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  196. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  197. package/src/ui/root.tsx +164 -230
  198. package/src/ui/status-bar-model.ts +48 -34
  199. package/src/ui/terminal-graphics/kitty.ts +28 -2
  200. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  201. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  202. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  203. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  204. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  205. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  206. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  207. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  208. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  209. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  210. package/src/ui/terminal-graphics/sprites.ts +246 -0
  211. package/src/ui/truncate.ts +7 -0
  212. package/src/ui/widgets/registry.tsx +21 -0
  213. package/src/ui/widgets/widget-context-menu.ts +85 -0
  214. package/src/update.ts +2 -2
  215. package/src/app-runtime/session-actions.ts +0 -187
  216. package/src/cli/client/workspace-resolver.ts +0 -110
  217. package/src/cli/commands/workspace/close.ts +0 -32
  218. package/src/cli/commands/workspace/show.ts +0 -32
  219. package/src/cli/commands/worktree/create.ts +0 -55
  220. package/src/cli/commands/worktree/list.ts +0 -68
  221. package/src/cli/commands/worktree/remove.ts +0 -81
  222. package/src/git/worktree-branch-poller.ts +0 -54
  223. package/src/git/worktree-divergence-poller.ts +0 -59
  224. package/src/state/reducers/session-state.ts +0 -262
  225. package/src/state/session-catalog.ts +0 -143
  226. package/src/state/session-worktrees.ts +0 -260
  227. package/src/state/workspace-save.ts +0 -45
  228. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  229. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
  230. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  231. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  232. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  233. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -0,0 +1,275 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { ProjectRecord } from './types'
5
+
6
+ import { loadConfig, saveConfig } from '../config'
7
+ import { logDebug } from '../debug/input-log'
8
+ import { getProfileConfigDir } from '../profile-paths'
9
+ import { ensureProjectWorkspaces } from './project-workspaces'
10
+ import { toast } from './toast-store'
11
+ import { isProjectRecord } from './validation'
12
+
13
+ const CATALOG_VERSION = 2
14
+
15
+ interface ProjectCatalogFile {
16
+ version: typeof CATALOG_VERSION
17
+ projects: ProjectRecord[]
18
+ }
19
+
20
+ // Resolved per call, never module-cached: $HOME is read at call time so tests
21
+ // that redirect it cannot clobber the real catalog.
22
+ function projectsPath(): string {
23
+ return join(getProfileConfigDir(), 'aimux-projects.json')
24
+ }
25
+
26
+ // ponytail: the v1 file is left on disk so a downgrade still works. Drop this
27
+ // helper, migrateV1 and the fallback branch a release or two out.
28
+ function legacySessionsPath(): string {
29
+ return join(getProfileConfigDir(), 'aimux-sessions.json')
30
+ }
31
+
32
+ /** The v1 shape, before project/workspace naming. Only what migrateV1 reads. */
33
+ interface LegacyProjectRecordV1 {
34
+ projectPath?: string
35
+ workspaceSnapshot?: {
36
+ tabs?: { worktreeId?: string }[]
37
+ lastActiveTabByWorktree?: Record<string, string>
38
+ }
39
+ worktrees?: { source?: string; repoRoot?: string }[]
40
+ activeWorktreeId?: string
41
+ }
42
+
43
+ /**
44
+ * v1 -> v2: `worktrees` became `workspaces`, `workspaceSnapshot` became
45
+ * `projectSnapshot`, and each tab's `worktreeId` became `workspaceId`. Record
46
+ * ids are deliberately left alone — they are opaque and may be scripted.
47
+ *
48
+ * `projectPath` is also healed here: v1 rewrote it with the *active worktree's*
49
+ * path, so it usually pointed inside the worktree root rather than at the repo.
50
+ */
51
+ function migrateV1(records: LegacyProjectRecordV1[]): unknown[] {
52
+ return records.map((record) => {
53
+ const { activeWorktreeId, workspaceSnapshot, worktrees, ...rest } = record
54
+ const primaryRepoRoot = worktrees?.find((w) => w.source === 'primary')?.repoRoot
55
+ return {
56
+ ...rest,
57
+ ...(primaryRepoRoot != null && primaryRepoRoot !== ''
58
+ ? { projectPath: primaryRepoRoot }
59
+ : null),
60
+ ...(worktrees ? { workspaces: worktrees } : null),
61
+ ...(activeWorktreeId != null ? { activeWorkspaceId: activeWorktreeId } : null),
62
+ ...(workspaceSnapshot
63
+ ? {
64
+ projectSnapshot: (() => {
65
+ const { lastActiveTabByWorktree, tabs, ...snapshot } = workspaceSnapshot
66
+ return {
67
+ ...snapshot,
68
+ ...(lastActiveTabByWorktree
69
+ ? { lastActiveTabByWorkspace: lastActiveTabByWorktree }
70
+ : null),
71
+ ...(tabs
72
+ ? {
73
+ tabs: tabs.map(({ worktreeId, ...tab }) => ({
74
+ ...tab,
75
+ ...(worktreeId != null ? { workspaceId: worktreeId } : null),
76
+ })),
77
+ }
78
+ : null),
79
+ }
80
+ })(),
81
+ }
82
+ : null),
83
+ }
84
+ })
85
+ }
86
+
87
+ /**
88
+ * Copy the catalog aside before the load path writes over it. Called whenever
89
+ * we could not read the file whole: the app is about to persist its own
90
+ * reduced view, and without this that write is the point of no return.
91
+ */
92
+ function backupUnreadableCatalog(): void {
93
+ const source = projectsPath()
94
+ if (!existsSync(source)) return
95
+ const target = `${source}.unreadable`
96
+ try {
97
+ copyFileSync(source, target)
98
+ logDebug('projects.catalog.backup', { target })
99
+ } catch (error) {
100
+ logDebug('projects.catalog.backupFailed', {
101
+ error: error instanceof Error ? error.message : String(error),
102
+ })
103
+ }
104
+ }
105
+
106
+ function parseCatalog(path: string, version: number): { projects: unknown[] } | { issue: string } {
107
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as {
108
+ version?: unknown
109
+ projects?: unknown
110
+ sessions?: unknown
111
+ }
112
+ if (parsed.version !== version) {
113
+ return { issue: `invalid project catalog header in ${path}` }
114
+ }
115
+ // v1 called the array `sessions`.
116
+ const records = parsed.projects ?? parsed.sessions
117
+ if (!Array.isArray(records)) {
118
+ return { issue: `invalid project catalog header in ${path}` }
119
+ }
120
+ return { projects: records }
121
+ }
122
+
123
+ function readCatalogFile(): { file: ProjectCatalogFile | null; issue?: string } {
124
+ try {
125
+ let records: unknown[]
126
+ if (existsSync(projectsPath())) {
127
+ const parsed = parseCatalog(projectsPath(), CATALOG_VERSION)
128
+ if ('issue' in parsed) {
129
+ backupUnreadableCatalog()
130
+ return { file: null, issue: parsed.issue }
131
+ }
132
+ records = parsed.projects
133
+ } else if (existsSync(legacySessionsPath())) {
134
+ const parsed = parseCatalog(legacySessionsPath(), 1)
135
+ if ('issue' in parsed) return { file: null, issue: parsed.issue }
136
+ records = migrateV1(parsed.projects as LegacyProjectRecordV1[])
137
+ logDebug('projects.catalog.migrateV1', { projectCount: records.length })
138
+ } else {
139
+ return { file: null }
140
+ }
141
+
142
+ // Keep the records that survive validation instead of failing the whole
143
+ // file: one malformed entry used to cost the user every project, and since
144
+ // the load path writes back what it loaded, that loss became permanent on
145
+ // the next save. Whatever we could not read is preserved by the backup
146
+ // below, so nothing is destroyed either way.
147
+ const valid = records.filter(isProjectRecord)
148
+ if (valid.length !== records.length) {
149
+ backupUnreadableCatalog()
150
+ return {
151
+ file: { projects: valid, version: CATALOG_VERSION },
152
+ issue: `${records.length - valid.length} unreadable project record(s) skipped`,
153
+ }
154
+ }
155
+
156
+ return { file: { projects: valid, version: CATALOG_VERSION } }
157
+ } catch (error) {
158
+ backupUnreadableCatalog()
159
+ return {
160
+ file: null,
161
+ issue: `failed to load project catalog: ${error instanceof Error ? error.message : String(error)}`,
162
+ }
163
+ }
164
+ }
165
+
166
+ export function loadProjectCatalog(): ProjectRecord[] {
167
+ const { file, issue } = readCatalogFile()
168
+ if (file) {
169
+ logDebug('projects.catalog.load', { projectCount: file.projects.length })
170
+ if (issue != null && issue !== '') {
171
+ // Same reasoning as the save path below: losing projects is not something
172
+ // the user should have to read a debug log to discover.
173
+ toast.error(`${issue} — original kept at ${projectsPath()}.unreadable`)
174
+ }
175
+ const normalized = normalizeProjects(file.projects)
176
+ // A fresh migration has no v2 file yet, so write even when normalizing
177
+ // changed nothing.
178
+ if (
179
+ !existsSync(projectsPath()) ||
180
+ JSON.stringify(normalized) !== JSON.stringify(file.projects)
181
+ ) {
182
+ saveProjectCatalog(normalized)
183
+ }
184
+ return normalized
185
+ }
186
+
187
+ if (issue != null && issue !== '') {
188
+ logDebug('projects.catalog.loadIssue', { issue, path: projectsPath() })
189
+ }
190
+
191
+ const config = loadConfig()
192
+ if (!config.projectSnapshot) {
193
+ logDebug('projects.catalog.load', { projectCount: 0 })
194
+ return []
195
+ }
196
+
197
+ const now = new Date().toISOString()
198
+ const migrated: ProjectRecord = {
199
+ createdAt: now,
200
+ id: `project-${Date.now()}`,
201
+ lastOpenedAt: now,
202
+ name: 'Last project',
203
+ projectSnapshot: config.projectSnapshot,
204
+ updatedAt: now,
205
+ }
206
+
207
+ const normalized = normalizeProjects([migrated])
208
+ saveProjectCatalog(normalized)
209
+ saveConfig({ ...config, projectSnapshot: undefined })
210
+ logDebug('projects.catalog.migrateLegacyProject', {
211
+ migratedProjectId: migrated.id,
212
+ tabCount: migrated.projectSnapshot?.tabs.length ?? 0,
213
+ })
214
+ return normalized
215
+ }
216
+
217
+ export function saveProjectCatalog(projects: ProjectRecord[]): void {
218
+ try {
219
+ mkdirSync(getProfileConfigDir(), { recursive: true })
220
+ writeFileSync(
221
+ projectsPath(),
222
+ `${JSON.stringify({ projects, version: CATALOG_VERSION }, null, 2)}\n`
223
+ )
224
+ logDebug('projects.catalog.save', { projectCount: projects.length })
225
+ } catch (error) {
226
+ const message = error instanceof Error ? error.message : String(error)
227
+ logDebug('projects.catalog.saveError', {
228
+ error: message,
229
+ path: projectsPath(),
230
+ projectCount: projects.length,
231
+ })
232
+ // Persisting the catalog underpins project/workspace state — a silent
233
+ // failure means the user loses projects on restart. Surface it.
234
+ toast.error(`Failed to save projects: ${message}`)
235
+ }
236
+ }
237
+
238
+ export function getProjectCatalogPath(): string {
239
+ return projectsPath()
240
+ }
241
+
242
+ export function findMostRecentProject(projects: ProjectRecord[]): ProjectRecord | undefined {
243
+ let best: ProjectRecord | undefined
244
+ for (const candidate of projects) {
245
+ if (!best || candidate.lastOpenedAt.localeCompare(best.lastOpenedAt) > 0) {
246
+ best = candidate
247
+ }
248
+ }
249
+ return best
250
+ }
251
+
252
+ /**
253
+ * Assign a stable `order` to every project. Records with an existing numeric
254
+ * `order` keep their slot (sorted ascending); the rest are appended by
255
+ * createdAt ascending so older projects come first.
256
+ */
257
+ function normalizeOrder(projects: ProjectRecord[]): ProjectRecord[] {
258
+ const withOrder: ProjectRecord[] = []
259
+ const withoutOrder: ProjectRecord[] = []
260
+ for (const s of projects) {
261
+ if (typeof s.order === 'number' && Number.isFinite(s.order)) {
262
+ withOrder.push(s)
263
+ } else {
264
+ withoutOrder.push(s)
265
+ }
266
+ }
267
+ withOrder.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
268
+ withoutOrder.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
269
+ const merged = [...withOrder, ...withoutOrder]
270
+ return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
271
+ }
272
+
273
+ function normalizeProjects(projects: ProjectRecord[]): ProjectRecord[] {
274
+ return normalizeOrder(projects).map((project) => ensureProjectWorkspaces(project))
275
+ }
@@ -0,0 +1,143 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { getProfileConfigDir } from '../profile-paths'
5
+
6
+ const SETUP_HEADER = `#!/usr/bin/env bash
7
+ set -euo pipefail
8
+
9
+ `
10
+
11
+ const SETUP_STUB = `#!/usr/bin/env bash
12
+ set -euo pipefail
13
+
14
+ # aimux setup script.
15
+ #
16
+ # Runs once per newly created workspace, with the working directory set to that
17
+ # workspace's root. A workspace is a fresh \`git worktree\`, so it contains only
18
+ # files tracked by git — no .env, no node_modules, no .venv, no build cache.
19
+ #
20
+ # Keep it idempotent and non-interactive. A non-zero exit is surfaced in the
21
+ # setup widget and recorded on the workspace.
22
+
23
+ echo "nothing to do yet — edit this script"
24
+ `
25
+
26
+ // Resolved per call, never module-cached: --profile is applied after module
27
+ // import, so a cached path would point at the wrong profile.
28
+ export function getProjectDataDir(projectId: string): string {
29
+ return join(getProfileConfigDir(), 'projects', projectId)
30
+ }
31
+
32
+ export function getSetupScriptPath(projectId: string): string {
33
+ return join(getProjectDataDir(projectId), 'setup.sh')
34
+ }
35
+
36
+ export function hasSetupScript(projectId: string): boolean {
37
+ return existsSync(getSetupScriptPath(projectId))
38
+ }
39
+
40
+ /** Returns the script path, creating an executable stub if there is none. */
41
+ export function ensureSetupScriptStub(projectId: string): string {
42
+ const path = getSetupScriptPath(projectId)
43
+ if (existsSync(path)) return path
44
+ mkdirSync(getProjectDataDir(projectId), { recursive: true })
45
+ writeFileSync(path, SETUP_STUB)
46
+ chmodSync(path, 0o755)
47
+ return path
48
+ }
49
+
50
+ const EMPTY_LINES: readonly string[] = []
51
+
52
+ /**
53
+ * Keyed by path, not project id: the path depends on the active profile, which is
54
+ * resolved per call. Holds one small array per project that has a setup script.
55
+ */
56
+ const setupLinesCache = new Map<string, { mtimeMs: number; lines: string[] }>()
57
+
58
+ /** A line that does something, as opposed to a shebang, an option or a comment. */
59
+ function isWorkLine(line: string): boolean {
60
+ const trimmed = line.trim()
61
+ return (
62
+ trimmed !== '' &&
63
+ !trimmed.startsWith('#') &&
64
+ !/^set\s+-[a-zA-Z]/.test(trimmed) &&
65
+ !/^set\s+-o\s/.test(trimmed)
66
+ )
67
+ }
68
+
69
+ function parseSetupScript(source: string): string[] {
70
+ return source
71
+ .split('\n')
72
+ .filter((line) => isWorkLine(line))
73
+ .map((line) => line.trim())
74
+ }
75
+
76
+ /**
77
+ * The script's actual work, with the boilerplate stripped: the shebang, the
78
+ * `set -e…` line, comments and blank lines. One line left means the script is a
79
+ * command, and a single text field can hold all of it; more than one means it is
80
+ * a script, and a text field would silently truncate it.
81
+ *
82
+ * Cached against the file's mtime, because the settings screen builds its Setup
83
+ * rows from this: the search filter runs it for every project on every keystroke,
84
+ * and `getModalOptionCount` runs it from inside the reducer. A `statSync` per
85
+ * project is a syscall; a read-and-parse per project per keystroke is a habit
86
+ * that gets worse with every project the user adds. An edit made outside aimux
87
+ * still lands — that is what the mtime is for.
88
+ *
89
+ * `readonly` because the array it hands back is the cached one, not a copy: a
90
+ * caller that sorted or spliced it would be editing every later caller's answer.
91
+ */
92
+ export function readSetupScriptLines(projectId: string): readonly string[] {
93
+ const path = getSetupScriptPath(projectId)
94
+ try {
95
+ const { mtimeMs } = statSync(path)
96
+ const cached = setupLinesCache.get(path)
97
+ if (cached?.mtimeMs === mtimeMs) return cached.lines
98
+ const lines = parseSetupScript(readFileSync(path, 'utf8'))
99
+ setupLinesCache.set(path, { lines, mtimeMs })
100
+ return lines
101
+ } catch {
102
+ // Missing or unreadable. Not the same thing, but the caller has nothing
103
+ // better to show for either, and the editor path still works.
104
+ setupLinesCache.delete(path)
105
+ return EMPTY_LINES
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Put this one command in the script, replacing the work line that was there.
111
+ * Round-trips with `readSetupScriptLines`.
112
+ *
113
+ * Everything that is not that line survives: the shebang, the `set -e…`, and any
114
+ * comments. Rewriting the file from a fixed header was simpler and quietly threw
115
+ * away whatever the user had written around their one command — a field that
116
+ * deletes the notes next to what it edits is not an editor.
117
+ */
118
+ export function writeSetupCommand(projectId: string, command: string): void {
119
+ mkdirSync(getProjectDataDir(projectId), { recursive: true })
120
+ const path = getSetupScriptPath(projectId)
121
+ writeFileSync(path, nextSetupSource(path, command.trim()))
122
+ chmodSync(path, 0o755)
123
+ // Not left to the mtime: a write and the read that follows it can land in the
124
+ // same millisecond, and the cache would hand back what was there before.
125
+ setupLinesCache.delete(path)
126
+ }
127
+
128
+ function nextSetupSource(path: string, command: string): string {
129
+ let existing: string
130
+ try {
131
+ existing = readFileSync(path, 'utf8')
132
+ } catch {
133
+ // No script yet (or none we can read): the template is all there is to keep.
134
+ return `${SETUP_HEADER}${command}\n`
135
+ }
136
+
137
+ const lines = existing.split('\n')
138
+ const first = lines.findIndex((line) => isWorkLine(line))
139
+ // Nothing to replace — the stub, or comments only. Append rather than drop them.
140
+ if (first === -1) return `${existing.replace(/\n*$/, '')}\n\n${command}\n`
141
+ lines[first] = command
142
+ return lines.join('\n')
143
+ }
@@ -1,8 +1,8 @@
1
- import type { AppState, TabSession, TabStatus, WorkspaceSnapshotV1 } from './types'
1
+ import type { AppState, ProjectSnapshotV1, TabSession, TabStatus } from './types'
2
2
 
3
3
  import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './layout-tree'
4
4
 
5
- export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
5
+ export function createEmptyProjectSnapshot(): ProjectSnapshotV1 {
6
6
  return {
7
7
  activeTabId: null,
8
8
  savedAt: new Date().toISOString(),
@@ -16,13 +16,13 @@ export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
16
16
  }
17
17
 
18
18
  /**
19
- * The per-worktree last-tab memory is serialized into every snapshot now so the
20
- * data accrues across sessions, but restoring it at startup stays dormant until
19
+ * The per-workspace last-tab memory is serialized into every snapshot now so the
20
+ * data accrues across projects, but restoring it at startup stays dormant until
21
21
  * a future (non-breaking) change is ready to consume it. Flipping this to true
22
- * makes restart restore the last-viewed tab per worktree; until then restart
22
+ * makes restart restore the last-viewed tab per workspace; until then restart
23
23
  * behavior is unchanged and the live in-memory map is never clobbered on switch.
24
24
  */
25
- const RESTORE_LAST_ACTIVE_TAB_BY_WORKTREE: boolean = false
25
+ const RESTORE_LAST_ACTIVE_TAB_BY_WORKSPACE: boolean = false
26
26
 
27
27
  function getDisconnectedStatus(status: TabStatus): TabStatus {
28
28
  if (status === 'running' || status === 'starting') {
@@ -32,73 +32,81 @@ function getDisconnectedStatus(status: TabStatus): TabStatus {
32
32
  return status
33
33
  }
34
34
 
35
- export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
35
+ export function serializeProject(state: AppState): ProjectSnapshotV1 {
36
36
  return {
37
37
  activeTabId: state.activeTabId,
38
- lastActiveTabByWorktree:
39
- Object.keys(state.lastActiveTabByWorktree).length > 0
40
- ? state.lastActiveTabByWorktree
38
+ lastActiveTabByWorkspace:
39
+ Object.keys(state.lastActiveTabByWorkspace).length > 0
40
+ ? state.lastActiveTabByWorkspace
41
41
  : undefined,
42
42
  layoutTree: Object.values(state.layoutTrees)[0] ?? undefined,
43
43
  layoutTrees: Object.keys(state.layoutTrees).length > 0 ? state.layoutTrees : undefined,
44
44
  savedAt: new Date().toISOString(),
45
+ // Mirror of the left bar. `isProjectSnapshotV1` requires this key —
46
+ // dropping it invalidates every entry in the project catalog.
45
47
  sidebar: {
46
- visible: state.sidebar.visible,
47
- width: state.sidebar.width,
48
+ visible: state.bars.left.visible,
49
+ width: state.bars.left.width,
48
50
  },
49
51
  tabGroupMap: Object.keys(state.tabGroupMap).length > 0 ? state.tabGroupMap : undefined,
50
- tabs: state.tabs.map((tab) => ({
51
- assistant: tab.assistant,
52
- autoRenameStatus: tab.autoRenameStatus,
53
- buffer: tab.buffer,
54
- command: tab.command,
55
- errorMessage: tab.errorMessage,
56
- exitCode: tab.exitCode,
57
- id: tab.id,
58
- status: tab.status === 'disconnected' ? 'running' : tab.status,
59
- terminalModes: tab.terminalModes,
60
- title: tab.title,
61
- viewport: tab.viewport,
62
- workerName: tab.workerName,
63
- worktreeId: tab.worktreeId,
64
- })),
52
+ // Hidden tabs (the setup runner's PTY) are session-scoped on purpose: the
53
+ // durable record of a setup run is `WorkspaceRecord.setupRanAt`, and keeping
54
+ // them out here is what lets `hidden` stay off the ipc/daemon wire.
55
+ tabs: state.tabs
56
+ .filter((tab) => tab.hidden !== true)
57
+ .map((tab) => ({
58
+ assistant: tab.assistant,
59
+ autoRenameStatus: tab.autoRenameStatus,
60
+ buffer: tab.buffer,
61
+ command: tab.command,
62
+ errorMessage: tab.errorMessage,
63
+ exitCode: tab.exitCode,
64
+ id: tab.id,
65
+ status: tab.status === 'disconnected' ? 'running' : tab.status,
66
+ terminalModes: tab.terminalModes,
67
+ title: tab.title,
68
+ viewport: tab.viewport,
69
+ workerName: tab.workerName,
70
+ workspaceId: tab.workspaceId,
71
+ })),
65
72
  version: 1,
66
73
  }
67
74
  }
68
75
 
69
76
  export interface RestoreOptions {
70
77
  // Force any running/starting tab to 'disconnected' on restore. True for
71
- // cold-start (daemon may not own the sessions yet) and the daemon's own
72
- // catalog hydration. False for live in-app workspace switches where an
73
- // attach() is guaranteed to follow within a frame and hydrate-workspace
78
+ // cold-start (daemon may not own the projects yet) and the daemon's own
79
+ // catalog hydration. False for live in-app project switches where an
80
+ // attach() is guaranteed to follow within a frame and hydrate-project
74
81
  // will overwrite the status with daemon truth — leaving the flag on would
75
82
  // briefly flash the "Restored snapshot" hint on every j/k cycle.
76
83
  forceDisconnected?: boolean
77
- // When provided, drop tabs pinned to a worktree id that the session no
78
- // longer owns. Some delete paths (notably the sidebar's "Remove worktree")
79
- // historically removed the worktree record without closing its tabs, leaving
84
+ // When provided, drop tabs pinned to a workspace id that the project no
85
+ // longer owns. Some delete paths (notably the sidebar's "Remove workspace")
86
+ // historically removed the workspace record without closing its tabs, leaving
80
87
  // orphans bound to a vanished id. Those orphans are invisible (filtered out
81
- // by the active-worktree filter) yet keep a worktree id that a *future*
82
- // delete can collide with — exactly what closes "another worktree's" tabs.
88
+ // by the active-workspace filter) yet keep a workspace id that a *future*
89
+ // delete can collide with — exactly what closes "another workspace's" tabs.
83
90
  // Pruning them on restore both repairs corrupted catalogs and prevents the
84
- // collision. Tabs with no worktree id (legacy/unbound) are always kept.
85
- validWorktreeIds?: ReadonlySet<string>
91
+ // collision. Tabs with no workspace id (legacy/unbound) are always kept.
92
+ validWorkspaceIds?: ReadonlySet<string>
86
93
  }
87
94
 
88
- // Drop tabs bound to a worktree id the session no longer owns. Unbound tabs
89
- // (no worktreeId) are kept — they surface under the primary worktree.
95
+ // Drop tabs bound to a workspace id the project no longer owns. Unbound tabs
96
+ // (no workspaceId) are kept — they surface under the primary workspace.
90
97
  function pruneOrphanedTabs(
91
98
  tabs: TabSession[],
92
- validWorktreeIds: ReadonlySet<string> | undefined
99
+ validWorkspaceIds: ReadonlySet<string> | undefined
93
100
  ): TabSession[] {
94
- if (!validWorktreeIds) return tabs
101
+ if (!validWorkspaceIds) return tabs
95
102
  return tabs.filter(
96
- (tab) => tab.worktreeId == null || tab.worktreeId === '' || validWorktreeIds.has(tab.worktreeId)
103
+ (tab) =>
104
+ tab.workspaceId == null || tab.workspaceId === '' || validWorkspaceIds.has(tab.workspaceId)
97
105
  )
98
106
  }
99
107
 
100
- export function restoreTabsFromWorkspace(
101
- snapshot: WorkspaceSnapshotV1 | undefined,
108
+ export function restoreTabsFromProject(
109
+ snapshot: ProjectSnapshotV1 | undefined,
102
110
  options: RestoreOptions = {}
103
111
  ): TabSession[] {
104
112
  if (!snapshot || snapshot.version !== 1) {
@@ -126,13 +134,13 @@ export function restoreTabsFromWorkspace(
126
134
  title: tab.title,
127
135
  viewport: tab.viewport,
128
136
  workerName: tab.workerName,
129
- worktreeId: tab.worktreeId,
137
+ workspaceId: tab.workspaceId,
130
138
  }))
131
- return pruneOrphanedTabs(restored, options.validWorktreeIds)
139
+ return pruneOrphanedTabs(restored, options.validWorkspaceIds)
132
140
  }
133
141
 
134
142
  export function restoreLayoutTrees(
135
- snapshot: WorkspaceSnapshotV1 | undefined,
143
+ snapshot: ProjectSnapshotV1 | undefined,
136
144
  tabs: TabSession[]
137
145
  ): { layoutTrees: Record<string, LayoutNode>; tabGroupMap: Record<string, string> } {
138
146
  const validTabIds = new Set(tabs.map((t) => t.id))
@@ -165,16 +173,16 @@ export function restoreLayoutTrees(
165
173
  return { layoutTrees, tabGroupMap }
166
174
  }
167
175
 
168
- // When a worktree is removed, its tabs are killed live via disposeWorktreeTabs
169
- // — but the session's persisted workspaceSnapshot still references them by
170
- // worktreeId. Without this pruning, a subsequent load-session (session switch
176
+ // When a workspace is removed, its tabs are killed live via disposeWorkspaceTabs
177
+ // — but the project's persisted projectSnapshot still references them by
178
+ // workspaceId. Without this pruning, a subsequent load-project (project switch
171
179
  // or restart) resurrects the dead tabs and they reappear in h-l navigation.
172
- export function pruneSnapshotOfWorktree(
173
- snapshot: WorkspaceSnapshotV1 | undefined,
174
- worktreeId: string
175
- ): WorkspaceSnapshotV1 | undefined {
180
+ export function pruneSnapshotOfWorkspace(
181
+ snapshot: ProjectSnapshotV1 | undefined,
182
+ workspaceId: string
183
+ ): ProjectSnapshotV1 | undefined {
176
184
  if (!snapshot) return snapshot
177
- const keptTabs = snapshot.tabs.filter((tab) => tab.worktreeId !== worktreeId)
185
+ const keptTabs = snapshot.tabs.filter((tab) => tab.workspaceId !== workspaceId)
178
186
  if (keptTabs.length === snapshot.tabs.length) return snapshot
179
187
  const validTabIds = new Set(keptTabs.map((tab) => tab.id))
180
188
 
@@ -273,41 +281,37 @@ export function normalizeGroupedTabOrder(
273
281
  return orderedTabs
274
282
  }
275
283
 
276
- export function restoreWorkspaceState(
284
+ export function restoreProjectState(
277
285
  state: AppState,
278
- workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
286
+ projectSnapshot: ProjectSnapshotV1 | undefined,
279
287
  options: RestoreOptions = {}
280
- ): Pick<
281
- AppState,
282
- 'tabs' | 'activeTabId' | 'focusMode' | 'sidebar' | 'layoutTrees' | 'tabGroupMap'
283
- > &
284
- Partial<Pick<AppState, 'lastActiveTabByWorktree'>> {
285
- const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
288
+ ): Pick<AppState, 'tabs' | 'activeTabId' | 'focusMode' | 'layoutTrees' | 'tabGroupMap'> &
289
+ Partial<Pick<AppState, 'lastActiveTabByWorkspace'>> {
290
+ const tabs = restoreTabsFromProject(projectSnapshot, options)
286
291
  const activeTabId =
287
- workspaceSnapshot?.activeTabId != null &&
288
- workspaceSnapshot?.activeTabId !== '' &&
289
- tabs.some((tab) => tab.id === workspaceSnapshot.activeTabId)
290
- ? workspaceSnapshot.activeTabId
292
+ projectSnapshot?.activeTabId != null &&
293
+ projectSnapshot?.activeTabId !== '' &&
294
+ tabs.some((tab) => tab.id === projectSnapshot.activeTabId)
295
+ ? projectSnapshot.activeTabId
291
296
  : (tabs[0]?.id ?? null)
292
297
 
293
- const { layoutTrees, tabGroupMap } = restoreLayoutTrees(workspaceSnapshot, tabs)
298
+ const { layoutTrees, tabGroupMap } = restoreLayoutTrees(projectSnapshot, tabs)
294
299
  const orderedTabs = normalizeGroupedTabOrder(tabs, layoutTrees, tabGroupMap)
295
300
 
296
- // Dormant until the gate flips: merge the persisted per-worktree memory over
301
+ // Dormant until the gate flips: merge the persisted per-workspace memory over
297
302
  // the live in-memory map. While off, the key is omitted entirely so callers
298
303
  // that spread the result never overwrite their existing map on a switch.
299
304
  const persistedLastActiveTab =
300
- RESTORE_LAST_ACTIVE_TAB_BY_WORKTREE && workspaceSnapshot?.lastActiveTabByWorktree
301
- ? { ...state.lastActiveTabByWorktree, ...workspaceSnapshot.lastActiveTabByWorktree }
305
+ RESTORE_LAST_ACTIVE_TAB_BY_WORKSPACE && projectSnapshot?.lastActiveTabByWorkspace
306
+ ? { ...state.lastActiveTabByWorkspace, ...projectSnapshot.lastActiveTabByWorkspace }
302
307
  : undefined
303
308
 
304
309
  return {
305
310
  activeTabId,
306
311
  focusMode: 'navigation',
307
312
  layoutTrees,
308
- sidebar: state.sidebar,
309
313
  tabGroupMap,
310
314
  tabs: orderedTabs,
311
- ...(persistedLastActiveTab ? { lastActiveTabByWorktree: persistedLastActiveTab } : {}),
315
+ ...(persistedLastActiveTab ? { lastActiveTabByWorkspace: persistedLastActiveTab } : {}),
312
316
  }
313
317
  }