@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,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,75 +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. `isWorkspaceSnapshotV1` requires this key —
46
- // dropping it invalidates every entry in the session catalog.
45
+ // Mirror of the left bar. `isProjectSnapshotV1` requires this key —
46
+ // dropping it invalidates every entry in the project catalog.
47
47
  sidebar: {
48
48
  visible: state.bars.left.visible,
49
49
  width: state.bars.left.width,
50
50
  },
51
51
  tabGroupMap: Object.keys(state.tabGroupMap).length > 0 ? state.tabGroupMap : undefined,
52
- tabs: state.tabs.map((tab) => ({
53
- assistant: tab.assistant,
54
- autoRenameStatus: tab.autoRenameStatus,
55
- buffer: tab.buffer,
56
- command: tab.command,
57
- errorMessage: tab.errorMessage,
58
- exitCode: tab.exitCode,
59
- id: tab.id,
60
- status: tab.status === 'disconnected' ? 'running' : tab.status,
61
- terminalModes: tab.terminalModes,
62
- title: tab.title,
63
- viewport: tab.viewport,
64
- workerName: tab.workerName,
65
- worktreeId: tab.worktreeId,
66
- })),
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
+ })),
67
72
  version: 1,
68
73
  }
69
74
  }
70
75
 
71
76
  export interface RestoreOptions {
72
77
  // Force any running/starting tab to 'disconnected' on restore. True for
73
- // cold-start (daemon may not own the sessions yet) and the daemon's own
74
- // catalog hydration. False for live in-app workspace switches where an
75
- // 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
76
81
  // will overwrite the status with daemon truth — leaving the flag on would
77
82
  // briefly flash the "Restored snapshot" hint on every j/k cycle.
78
83
  forceDisconnected?: boolean
79
- // When provided, drop tabs pinned to a worktree id that the session no
80
- // longer owns. Some delete paths (notably the sidebar's "Remove worktree")
81
- // 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
82
87
  // orphans bound to a vanished id. Those orphans are invisible (filtered out
83
- // by the active-worktree filter) yet keep a worktree id that a *future*
84
- // 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.
85
90
  // Pruning them on restore both repairs corrupted catalogs and prevents the
86
- // collision. Tabs with no worktree id (legacy/unbound) are always kept.
87
- validWorktreeIds?: ReadonlySet<string>
91
+ // collision. Tabs with no workspace id (legacy/unbound) are always kept.
92
+ validWorkspaceIds?: ReadonlySet<string>
88
93
  }
89
94
 
90
- // Drop tabs bound to a worktree id the session no longer owns. Unbound tabs
91
- // (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.
92
97
  function pruneOrphanedTabs(
93
98
  tabs: TabSession[],
94
- validWorktreeIds: ReadonlySet<string> | undefined
99
+ validWorkspaceIds: ReadonlySet<string> | undefined
95
100
  ): TabSession[] {
96
- if (!validWorktreeIds) return tabs
101
+ if (!validWorkspaceIds) return tabs
97
102
  return tabs.filter(
98
- (tab) => tab.worktreeId == null || tab.worktreeId === '' || validWorktreeIds.has(tab.worktreeId)
103
+ (tab) =>
104
+ tab.workspaceId == null || tab.workspaceId === '' || validWorkspaceIds.has(tab.workspaceId)
99
105
  )
100
106
  }
101
107
 
102
- export function restoreTabsFromWorkspace(
103
- snapshot: WorkspaceSnapshotV1 | undefined,
108
+ export function restoreTabsFromProject(
109
+ snapshot: ProjectSnapshotV1 | undefined,
104
110
  options: RestoreOptions = {}
105
111
  ): TabSession[] {
106
112
  if (!snapshot || snapshot.version !== 1) {
@@ -128,13 +134,13 @@ export function restoreTabsFromWorkspace(
128
134
  title: tab.title,
129
135
  viewport: tab.viewport,
130
136
  workerName: tab.workerName,
131
- worktreeId: tab.worktreeId,
137
+ workspaceId: tab.workspaceId,
132
138
  }))
133
- return pruneOrphanedTabs(restored, options.validWorktreeIds)
139
+ return pruneOrphanedTabs(restored, options.validWorkspaceIds)
134
140
  }
135
141
 
136
142
  export function restoreLayoutTrees(
137
- snapshot: WorkspaceSnapshotV1 | undefined,
143
+ snapshot: ProjectSnapshotV1 | undefined,
138
144
  tabs: TabSession[]
139
145
  ): { layoutTrees: Record<string, LayoutNode>; tabGroupMap: Record<string, string> } {
140
146
  const validTabIds = new Set(tabs.map((t) => t.id))
@@ -167,16 +173,16 @@ export function restoreLayoutTrees(
167
173
  return { layoutTrees, tabGroupMap }
168
174
  }
169
175
 
170
- // When a worktree is removed, its tabs are killed live via disposeWorktreeTabs
171
- // — but the session's persisted workspaceSnapshot still references them by
172
- // 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
173
179
  // or restart) resurrects the dead tabs and they reappear in h-l navigation.
174
- export function pruneSnapshotOfWorktree(
175
- snapshot: WorkspaceSnapshotV1 | undefined,
176
- worktreeId: string
177
- ): WorkspaceSnapshotV1 | undefined {
180
+ export function pruneSnapshotOfWorkspace(
181
+ snapshot: ProjectSnapshotV1 | undefined,
182
+ workspaceId: string
183
+ ): ProjectSnapshotV1 | undefined {
178
184
  if (!snapshot) return snapshot
179
- const keptTabs = snapshot.tabs.filter((tab) => tab.worktreeId !== worktreeId)
185
+ const keptTabs = snapshot.tabs.filter((tab) => tab.workspaceId !== workspaceId)
180
186
  if (keptTabs.length === snapshot.tabs.length) return snapshot
181
187
  const validTabIds = new Set(keptTabs.map((tab) => tab.id))
182
188
 
@@ -275,29 +281,29 @@ export function normalizeGroupedTabOrder(
275
281
  return orderedTabs
276
282
  }
277
283
 
278
- export function restoreWorkspaceState(
284
+ export function restoreProjectState(
279
285
  state: AppState,
280
- workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
286
+ projectSnapshot: ProjectSnapshotV1 | undefined,
281
287
  options: RestoreOptions = {}
282
288
  ): Pick<AppState, 'tabs' | 'activeTabId' | 'focusMode' | 'layoutTrees' | 'tabGroupMap'> &
283
- Partial<Pick<AppState, 'lastActiveTabByWorktree'>> {
284
- const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
289
+ Partial<Pick<AppState, 'lastActiveTabByWorkspace'>> {
290
+ const tabs = restoreTabsFromProject(projectSnapshot, options)
285
291
  const activeTabId =
286
- workspaceSnapshot?.activeTabId != null &&
287
- workspaceSnapshot?.activeTabId !== '' &&
288
- tabs.some((tab) => tab.id === workspaceSnapshot.activeTabId)
289
- ? workspaceSnapshot.activeTabId
292
+ projectSnapshot?.activeTabId != null &&
293
+ projectSnapshot?.activeTabId !== '' &&
294
+ tabs.some((tab) => tab.id === projectSnapshot.activeTabId)
295
+ ? projectSnapshot.activeTabId
290
296
  : (tabs[0]?.id ?? null)
291
297
 
292
- const { layoutTrees, tabGroupMap } = restoreLayoutTrees(workspaceSnapshot, tabs)
298
+ const { layoutTrees, tabGroupMap } = restoreLayoutTrees(projectSnapshot, tabs)
293
299
  const orderedTabs = normalizeGroupedTabOrder(tabs, layoutTrees, tabGroupMap)
294
300
 
295
- // 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
296
302
  // the live in-memory map. While off, the key is omitted entirely so callers
297
303
  // that spread the result never overwrite their existing map on a switch.
298
304
  const persistedLastActiveTab =
299
- RESTORE_LAST_ACTIVE_TAB_BY_WORKTREE && workspaceSnapshot?.lastActiveTabByWorktree
300
- ? { ...state.lastActiveTabByWorktree, ...workspaceSnapshot.lastActiveTabByWorktree }
305
+ RESTORE_LAST_ACTIVE_TAB_BY_WORKSPACE && projectSnapshot?.lastActiveTabByWorkspace
306
+ ? { ...state.lastActiveTabByWorkspace, ...projectSnapshot.lastActiveTabByWorkspace }
301
307
  : undefined
302
308
 
303
309
  return {
@@ -306,6 +312,6 @@ export function restoreWorkspaceState(
306
312
  layoutTrees,
307
313
  tabGroupMap,
308
314
  tabs: orderedTabs,
309
- ...(persistedLastActiveTab ? { lastActiveTabByWorktree: persistedLastActiveTab } : {}),
315
+ ...(persistedLastActiveTab ? { lastActiveTabByWorkspace: persistedLastActiveTab } : {}),
310
316
  }
311
317
  }
@@ -0,0 +1,46 @@
1
+ import type { AppState, ProjectRecord } from './types'
2
+
3
+ import { loadConfig, saveConfig } from '../config'
4
+ import { saveProjectCatalog } from './project-catalog'
5
+ import { serializeProject } from './project-persistence'
6
+
7
+ export function buildProjectsWithCurrentSnapshot(
8
+ projects: ProjectRecord[],
9
+ currentProjectId: string | null,
10
+ state: AppState
11
+ ): ProjectRecord[] {
12
+ return projects.map((project) =>
13
+ project.id === currentProjectId
14
+ ? {
15
+ ...project,
16
+ projectSnapshot: serializeProject(state),
17
+ updatedAt: new Date().toISOString(),
18
+ }
19
+ : project
20
+ )
21
+ }
22
+
23
+ export function saveCurrentProject(state: AppState): void {
24
+ saveConfig({
25
+ ...loadConfig(),
26
+ bars: state.bars,
27
+ customCommands: state.customCommands,
28
+ gitPane: {
29
+ diffModeRatio: state.gitPane.diffModeRatio,
30
+ fileListMode: state.gitPane.fileListMode,
31
+ // Was missing, so every autosave dropped it and `t`/`T` in git mode never
32
+ // survived a restart — this write replaces the whole `gitPane` block.
33
+ treeCompaction: state.gitPane.treeCompaction,
34
+ },
35
+ projectBarVisible: state.projectBar.visible,
36
+ // Legacy mirror of the left bar: lets an older build (and the project
37
+ // snapshot schema) still find a sidebar after a downgrade.
38
+ sidebar: {
39
+ visible: state.bars.left.visible,
40
+ width: state.bars.left.width,
41
+ },
42
+ })
43
+ saveProjectCatalog(
44
+ buildProjectsWithCurrentSnapshot(state.projects, state.currentProjectId, state)
45
+ )
46
+ }