@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
package/src/app.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AimuxUserConfig,
2
3
  type ResolvedConfig,
3
4
  setAutoCommitEnabled,
4
5
  setExternalEditorConfig,
@@ -8,20 +9,23 @@ import {
8
9
  import { useKeyboard, useRenderer, useTerminalDimensions } from '@opentui/react'
9
10
  import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
10
11
 
12
+ import type { SideEffectContext } from './app-runtime/side-effect-context'
11
13
  import type { KeyChord } from './input/keymap/key-chord'
12
14
  import type { TrieBinding } from './input/keymap/trie'
13
15
  import type { KeyResult, ModeContext, ModeId } from './input/modes/types'
14
16
  import type { TerminalContentOrigin } from './input/raw-input-handler'
15
17
  import type { SessionBackend } from './session-backend/types'
16
18
 
17
- import { executeSideEffect, type SideEffectContext } from './app-runtime/side-effects'
19
+ import { setActiveMeasure } from './app-runtime/measure-ref'
20
+ import { executeSideEffect } from './app-runtime/side-effects'
18
21
  import { useAutoCommitDriver } from './app-runtime/use-auto-commit-driver'
19
22
  import { useBackendRuntime } from './app-runtime/use-backend-runtime'
20
23
  import { useDirectorySearch } from './app-runtime/use-directory-search'
21
24
  import { useMouseHandlers } from './app-runtime/use-mouse-handlers'
25
+ import { useProjectAutosave } from './app-runtime/use-project-autosave'
22
26
  import { useRendererBindings } from './app-runtime/use-renderer-bindings'
27
+ import { useSetupRunner } from './app-runtime/use-setup-runner'
23
28
  import { useTerminalResize } from './app-runtime/use-terminal-resize'
24
- import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
25
29
  import { loadConfig, saveConfig } from './config'
26
30
  import { enqueueGitOp } from './git/command-queue'
27
31
  import { pruneOrphanAimuxBranches } from './git/worktree'
@@ -34,11 +38,19 @@ import { highlightSnapshot, warmClaudeSyntaxOverlay } from './integrations/claud
34
38
  import { ensureClaudeSettingsThemePref, syncClaudeTheme } from './integrations/claude-theme-sync'
35
39
  import { getProfileConfigDir, getProfileName } from './profile-paths'
36
40
  import { startAIUsageService } from './services/ai-usage/provider'
41
+ import {
42
+ useAIUsageConfig,
43
+ useAutoCommitConfig,
44
+ useHarmonizeClaudeTheme,
45
+ useSnippetTriggerChar,
46
+ } from './settings/live'
47
+ import { ALL_SETTING_ROWS } from './settings/sections'
48
+ import { hydrateSettings } from './settings/settings-store'
37
49
  import { aiUsageStore } from './state/ai-usage-store'
38
50
  import { appStore, useAppStore } from './state/app-store'
39
51
  import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
40
- import { findMostRecentSession, loadSessionCatalog } from './state/session-catalog'
41
- import { getSessionProjectPath } from './state/session-worktrees'
52
+ import { findMostRecentProject, loadProjectCatalog } from './state/project-catalog'
53
+ import { getActiveWorkspacePath } from './state/project-workspaces'
42
54
  import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
43
55
  import { createInitialState } from './state/store'
44
56
  import { toast } from './state/toast-store'
@@ -59,14 +71,16 @@ import {
59
71
  isNewerVersion,
60
72
  } from './update/version-check'
61
73
 
62
- const WORKSPACE_SAVE_DEBOUNCE_MS = 250
74
+ const PROJECT_SAVE_DEBOUNCE_MS = 250
63
75
 
64
76
  export function App({
65
77
  backend,
66
78
  resolvedConfig,
79
+ userConfig,
67
80
  }: {
68
81
  backend: SessionBackend
69
82
  resolvedConfig: ResolvedConfig
83
+ userConfig: AimuxUserConfig
70
84
  }) {
71
85
  // Publish the auto-commit enabled flag before any children render so
72
86
  // actions (which live outside React) can read it synchronously.
@@ -104,8 +118,8 @@ export function App({
104
118
  // selector reads. We discard the value — the store is the source of truth.
105
119
  useState(() => {
106
120
  const json = loadConfig()
107
- const sessionBarVisible =
108
- resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
121
+ const projectBarVisible =
122
+ resolvedConfig.projectBar?.initialVisible ?? json.projectBarVisible ?? true
109
123
  // Merge config-file gitPane (persisted prefs) with user's resolved gitPane
110
124
  // (programmatic config). User config wins; file provides persisted prior state.
111
125
  const userGitPane = resolvedConfig.gitPane
@@ -118,32 +132,28 @@ export function App({
118
132
  ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
119
133
  }
120
134
 
121
- const sessionCatalog = loadSessionCatalog()
135
+ const projectCatalog = loadProjectCatalog()
122
136
  const mergedSnippets = mergeConfigSnippets(loadSnippetCatalog(), resolvedConfig.snippets)
123
137
  const initial = createInitialState(
124
138
  json.customCommands,
125
- sessionCatalog,
139
+ projectCatalog,
126
140
  mergedSnippets,
127
- sessionCatalog.length === 0,
141
+ projectCatalog.length === 0,
128
142
  {
129
143
  bars: json.bars,
130
144
  gitPane: gitPaneOverrides,
131
- sessionBarVisible,
132
- worktreeTemplates:
133
- resolvedConfig.worktreeTemplates.length > 0
134
- ? resolvedConfig.worktreeTemplates
135
- : json.worktreeTemplates,
145
+ projectBarVisible,
136
146
  }
137
147
  )
138
148
  // Replace the module-level default with the fully-resolved initial state.
139
149
  // Preserves the dispatch baked into the store by app-store.ts.
140
150
  appStore.setState(initial)
141
- const mostRecent = findMostRecentSession(sessionCatalog)
151
+ const mostRecent = findMostRecentProject(projectCatalog)
142
152
  if (mostRecent) {
143
153
  appStore.getState().dispatch({
144
- sessionId: mostRecent.id,
145
- type: 'load-session',
146
- workspaceSnapshot: mostRecent.workspaceSnapshot,
154
+ projectId: mostRecent.id,
155
+ projectSnapshot: mostRecent.projectSnapshot,
156
+ type: 'load-project',
147
157
  })
148
158
  }
149
159
  return null
@@ -156,6 +166,15 @@ export function App({
156
166
  const state = useAppStore((s) => s)
157
167
  const dispatch = state.dispatch
158
168
 
169
+ // Config the settings screen can change under us. The resolved config file is
170
+ // the baseline each of these falls back to.
171
+ const autoCommitConfig = useAutoCommitConfig(resolvedConfig.autoCommit)
172
+ const aiUsageConfig = useAIUsageConfig(resolvedConfig.statusBar?.aiUsage)
173
+ const harmonizeClaudeTheme = useHarmonizeClaudeTheme(
174
+ resolvedConfig.theme?.beta?.harmonizeClaudeTheme
175
+ )
176
+ const snippetTriggerChar = useSnippetTriggerChar(resolvedConfig.snippetTriggerChar)
177
+
159
178
  useLayoutEffect(() => {
160
179
  setActiveDispatch(dispatch)
161
180
  return () => {
@@ -164,14 +183,26 @@ export function App({
164
183
  }
165
184
  }, [dispatch])
166
185
 
186
+ // Replays what the settings screen has written on top of the config file (the
187
+ // setters at the top of this component are its baseline). Deliberately after
188
+ // the effect above and never during render: a row applies its value through
189
+ // whatever owns it, and some of those owners are reached by dispatching.
190
+ // Layout effect, so it lands before the first paint.
191
+ useLayoutEffect(() => {
192
+ hydrateSettings(ALL_SETTING_ROWS, userConfig)
193
+ // Once per launch: the config file is read at startup and does not change
194
+ // under us, and every later write goes through `writeRow`.
195
+ // eslint-disable-next-line react-hooks/exhaustive-deps
196
+ }, [])
197
+
167
198
  useEffect(() => {
168
- if (!(resolvedConfig.theme?.beta?.harmonizeClaudeTheme === true)) return
199
+ if (!harmonizeClaudeTheme) return
169
200
  ensureClaudeSettingsThemePref()
170
201
  syncClaudeTheme(getCurrentTheme(), getCurrentMode())
171
202
  return subscribeThemeChanges((resolved, mode) => {
172
203
  syncClaudeTheme(resolved, mode)
173
204
  })
174
- }, [resolvedConfig.theme?.beta?.harmonizeClaudeTheme])
205
+ }, [harmonizeClaudeTheme])
175
206
 
176
207
  useEffect(() => {
177
208
  // Opt-in via `integrations.claudeHooks` in aimux.config.ts. When enabled,
@@ -184,13 +215,12 @@ export function App({
184
215
  }, [resolvedConfig.integrations.claudeHooks])
185
216
 
186
217
  useEffect(() => {
187
- const aiUsage = resolvedConfig.statusBar?.aiUsage
188
- if (!(aiUsage?.enabled === true)) {
218
+ if (!(aiUsageConfig.enabled === true)) {
189
219
  aiUsageStore.getState().setEnabled(false)
190
220
  return
191
221
  }
192
222
  aiUsageStore.getState().setEnabled(true)
193
- const handle = startAIUsageService(aiUsage, (snap) => {
223
+ const handle = startAIUsageService(aiUsageConfig, (snap) => {
194
224
  aiUsageStore.getState().setSnapshot(snap)
195
225
  })
196
226
  return () => {
@@ -198,7 +228,9 @@ export function App({
198
228
  aiUsageStore.getState().clear()
199
229
  aiUsageStore.getState().setEnabled(false)
200
230
  }
201
- }, [resolvedConfig.statusBar?.aiUsage])
231
+ // Memoized by `useAIUsageConfig`, so toggling the indicator restarts the
232
+ // service and nothing else does.
233
+ }, [aiUsageConfig])
202
234
 
203
235
  useEffect(() => {
204
236
  if (process.env.AIMUX_DISABLE_UPDATE_CHECK === '1') return
@@ -228,16 +260,16 @@ export function App({
228
260
  }, [])
229
261
 
230
262
  useEffect(() => {
231
- // One-shot cleanup: prune `aimux/` branches orphaned by temp worktrees that
263
+ // One-shot cleanup: prune `aimux/` branches orphaned by temp workspaces that
232
264
  // were deleted before delete-time branch cleanup existed. Gated by a config
233
265
  // flag so it runs once per machine. All git calls are best-effort (nothrow)
234
- // and git protects branches still checked out in a live worktree.
266
+ // and git protects branches still checked out in a live workspace.
235
267
  if (loadConfig().prunedOrphanAimuxBranches === true) return
236
268
  void (async () => {
237
269
  const repoRoots = new Set<string>()
238
- for (const session of loadSessionCatalog()) {
239
- for (const worktree of session.worktrees ?? []) {
240
- if (worktree.repoRoot !== '') repoRoots.add(worktree.repoRoot)
270
+ for (const project of loadProjectCatalog()) {
271
+ for (const workspace of project.workspaces ?? []) {
272
+ if (workspace.repoRoot !== '') repoRoots.add(workspace.repoRoot)
241
273
  }
242
274
  }
243
275
  let removed = 0
@@ -256,15 +288,15 @@ export function App({
256
288
  // still the bootstrap default, and useBackendRuntime's attach effect would
257
289
  // then hand 80x24 to the backend, resizing the daemon-persisted PTYs (or
258
290
  // newly-spawned ones) to the wrong size and stranding the assistant's
259
- // already-drawn content in the top-left until a workspace switch.
291
+ // already-drawn content in the top-left until a project switch.
260
292
  const layoutRef = useRef(state.layout)
261
293
  const activeTab = useMemo(
262
294
  () => state.tabs.find((tab) => tab.id === state.activeTabId),
263
295
  [state.activeTabId, state.tabs]
264
296
  )
265
- const currentSession = useMemo(
266
- () => state.sessions.find((session) => session.id === state.currentSessionId),
267
- [state.currentSessionId, state.sessions]
297
+ const currentProject = useMemo(
298
+ () => state.projects.find((project) => project.id === state.currentProjectId),
299
+ [state.currentProjectId, state.projects]
268
300
  )
269
301
  const activeMouseForwardingEnabled = activeTab?.terminalModes.mouseTrackingMode !== 'none'
270
302
  const activeLocalScrollbackEnabled =
@@ -285,11 +317,11 @@ export function App({
285
317
  snippetsRef.current = state.snippets
286
318
  const branchRef = useRef(state.gitPanel.branch)
287
319
  branchRef.current = state.gitPanel.branch
288
- const triggerCharRef = useRef(resolvedConfig.snippetTriggerChar)
289
- triggerCharRef.current = resolvedConfig.snippetTriggerChar
320
+ const triggerCharRef = useRef(snippetTriggerChar)
321
+ triggerCharRef.current = snippetTriggerChar
290
322
 
291
323
  const contentOriginRef = useRef<TerminalContentOrigin>({ cols: 0, rows: 0, x: 0, y: 0 })
292
- const currentSessionWorkspaceSnapshot = currentSession?.workspaceSnapshot
324
+ const currentProjectProjectSnapshot = currentProject?.projectSnapshot
293
325
 
294
326
  // Must run before useBackendRuntime so its open-loop terminalSize seeds
295
327
  // layoutRef before the attach effect reads it. See the comment on layoutRef.
@@ -303,6 +335,15 @@ export function App({
303
335
  })
304
336
  layoutRef.current = { terminalCols: terminalSize.cols, terminalRows: terminalSize.rows }
305
337
 
338
+ // Reaches the setup widget, which hosts its own PTY outside root.tsx's pane
339
+ // tree and so never sees the onMeasure prop chain.
340
+ useLayoutEffect(() => {
341
+ setActiveMeasure(terminalSize.onMeasure)
342
+ return () => {
343
+ setActiveMeasure(null)
344
+ }
345
+ }, [terminalSize.onMeasure])
346
+
306
347
  const syntaxOverlayFlag = resolvedConfig.theme?.beta?.experimentalSyntaxHighlight === true
307
348
  const syntaxOverlayFlagRef = useRef(syntaxOverlayFlag)
308
349
  syntaxOverlayFlagRef.current = syntaxOverlayFlag
@@ -337,18 +378,20 @@ export function App({
337
378
  const { clearIdleTimer, clearStartupGrace, startStartupGrace } = useBackendRuntime({
338
379
  activeTabId: state.activeTabId,
339
380
  backend,
340
- currentSessionId: state.currentSessionId,
341
- currentSessionWorkspaceSnapshot,
381
+ currentProjectId: state.currentProjectId,
382
+ currentProjectProjectSnapshot,
342
383
  dispatch,
343
384
  layoutRef,
344
385
  resizingRef,
345
386
  syntaxOverlayEnabled,
346
387
  })
347
388
 
348
- useWorkspaceAutosave(state, WORKSPACE_SAVE_DEBOUNCE_MS)
389
+ useProjectAutosave(state, PROJECT_SAVE_DEBOUNCE_MS)
349
390
  useDirectorySearch(state.modal, dispatch)
350
391
  useAutoCommitDriver({
351
- config: resolvedConfig.autoCommit,
392
+ // Through the settings screen, so a change lands without a restart. The
393
+ // resolved config is the baseline it falls back to.
394
+ config: autoCommitConfig,
352
395
  dispatch,
353
396
  getProfileConfigRoot: getProfileConfigDir,
354
397
  state,
@@ -416,9 +459,9 @@ export function App({
416
459
  clearIdleTimer,
417
460
  clearStartupGrace,
418
461
  dispatch,
419
- getCurrentSessionProjectPath: () => {
420
- if (!(state.currentSessionId != null && state.currentSessionId !== '')) return
421
- return getSessionProjectPath(state.sessions.find((s) => s.id === state.currentSessionId))
462
+ getCurrentProjectProjectPath: () => {
463
+ if (!(state.currentProjectId != null && state.currentProjectId !== '')) return
464
+ return getActiveWorkspacePath(state.projects.find((s) => s.id === state.currentProjectId))
422
465
  },
423
466
  // Read straight from the store, not stateRef. stateRef is only refreshed
424
467
  // on render, so within a single JS turn (a click handler that dispatches
@@ -432,6 +475,8 @@ export function App({
432
475
  themeId,
433
476
  }
434
477
 
478
+ useSetupRunner(state, sideEffectCtx)
479
+
435
480
  function processKeyResult(result: KeyResult, modeId: ModeId): void {
436
481
  for (const action of result.actions) {
437
482
  dispatch(action)
@@ -488,12 +533,12 @@ export function App({
488
533
  dispatch({ tabId: target.tabId, type: 'set-active-tab' })
489
534
  return
490
535
  }
491
- if (target.kind === 'workspace' || target.kind === 'worktree') {
536
+ if (target.kind === 'project' || target.kind === 'workspace') {
492
537
  executeSideEffect(
493
538
  {
494
- index: target.sessionIndex,
495
- type: 'switch-session-by-index',
496
- worktreeId: target.worktreeId,
539
+ index: target.projectIndex,
540
+ type: 'switch-project-by-index',
541
+ workspaceId: target.workspaceId,
497
542
  },
498
543
  sideEffectCtx
499
544
  )
Binary file
Binary file
Binary file
@@ -205,10 +205,10 @@ export class DaemonClient {
205
205
  return response.payload
206
206
  }
207
207
 
208
- async listTabs(sessionId: string): Promise<ListTabsResult> {
208
+ async listTabs(projectId: string): Promise<ListTabsResult> {
209
209
  const response = await this.send({
210
210
  id: crypto.randomUUID(),
211
- payload: { sessionId },
211
+ payload: { projectId },
212
212
  type: 'listTabs',
213
213
  })
214
214
  if (response.type !== 'listTabsResult') {
@@ -0,0 +1,112 @@
1
+ import type { ProjectRecord, WorkspaceRecord } from '../../state/types'
2
+
3
+ import { findMostRecentProject, loadProjectCatalog } from '../../state/project-catalog'
4
+
5
+ /**
6
+ * Where a command's target project came from. An orchestrator needs this to
7
+ * tell "I asked for pragma-once" from "aimux guessed, and the UI had moved on":
8
+ * `active` is the only origin that can silently follow the UI to another repo.
9
+ */
10
+ export type ProjectOrigin = 'flag' | 'env' | 'active'
11
+
12
+ export interface ResolvedProject {
13
+ origin: ProjectOrigin
14
+ record: ProjectRecord
15
+ }
16
+
17
+ /** Env pin for headless orchestrators — `--project` still wins over it. */
18
+ export const PROJECT_ENV_VAR = 'AIMUX_PROJECT'
19
+ /** ponytail: pre-rename spelling, honoured for one release. Drop with --workspace. */
20
+ const LEGACY_PROJECT_ENV_VAR = 'AIMUX_WORKSPACE'
21
+
22
+ /** The project's primary (root) workspace, i.e. the repository it is about. */
23
+ export function findPrimaryWorkspace(project: ProjectRecord): WorkspaceRecord | undefined {
24
+ return project.workspaces?.find((workspace) => workspace.source === 'primary')
25
+ }
26
+
27
+ /**
28
+ * The repo every fresh workspace for this project is cut from. Surfaced in
29
+ * every worker envelope so an agent can see *which project* it just acted on
30
+ * instead of inferring it from a workspace path hash.
31
+ */
32
+ export function projectRepoRoot(project: ProjectRecord): string | null {
33
+ return findPrimaryWorkspace(project)?.repoRoot ?? project.projectPath ?? null
34
+ }
35
+
36
+ /** Stable project identity block embedded in command output. */
37
+ export function projectIdentity(project: ProjectRecord): {
38
+ id: string
39
+ name: string
40
+ repoRoot: string | null
41
+ } {
42
+ return { id: project.id, name: project.name, repoRoot: projectRepoRoot(project) }
43
+ }
44
+
45
+ /**
46
+ * Resolve `--project W` to a project record from the catalog, reporting where
47
+ * the choice came from. Precedence: the explicit flag, then `AIMUX_PROJECT`,
48
+ * then the most recently opened project. Throws when the catalog is empty (no
49
+ * project has ever been created) or when the explicit name/id doesn't match.
50
+ *
51
+ * Matching: exact id wins; otherwise exact name (case-sensitive); otherwise
52
+ * unique case-insensitive name match.
53
+ */
54
+ export function resolveProjectWithOrigin(name: string | undefined): ResolvedProject {
55
+ const projects = loadProjectCatalog()
56
+ if (projects.length === 0) {
57
+ throw new Error(
58
+ 'no projects found — create one from the aimux UI first (or pass --project once created)'
59
+ )
60
+ }
61
+
62
+ const flag = name !== undefined && name !== '' ? name : undefined
63
+ const env = process.env[PROJECT_ENV_VAR] ?? process.env[LEGACY_PROJECT_ENV_VAR]
64
+ const fromEnv = env != null && env !== '' ? env : undefined
65
+ const selector = flag ?? fromEnv
66
+ if (selector === undefined) {
67
+ const active = findMostRecentProject(projects)
68
+ if (!active) {
69
+ throw new Error('no active project and the catalog is empty')
70
+ }
71
+ return { origin: 'active', record: active }
72
+ }
73
+
74
+ return {
75
+ origin: flag !== undefined ? 'flag' : 'env',
76
+ record: matchProject(projects, selector),
77
+ }
78
+ }
79
+
80
+ export function resolveProject(name: string | undefined): ProjectRecord {
81
+ return resolveProjectWithOrigin(name).record
82
+ }
83
+
84
+ function matchProject(projects: ProjectRecord[], name: string): ProjectRecord {
85
+ const byId = projects.find((project) => project.id === name)
86
+ if (byId) return byId
87
+
88
+ const exactNameMatches = projects.filter((project) => project.name === name)
89
+ if (exactNameMatches.length > 1) {
90
+ throw new Error(
91
+ `project "${name}" matches multiple projects: ${exactNameMatches.map((s) => s.id).join(', ')}`
92
+ )
93
+ }
94
+ const exactOnly = exactNameMatches[0]
95
+ if (exactOnly) return exactOnly
96
+
97
+ const lower = name.toLowerCase()
98
+ const ciMatches = projects.filter((project) => project.name.toLowerCase() === lower)
99
+ if (ciMatches.length > 1) {
100
+ throw new Error(
101
+ `project "${name}" matches multiple projects: ${ciMatches.map((s) => s.id).join(', ')}`
102
+ )
103
+ }
104
+ const ciOnly = ciMatches[0]
105
+ if (ciOnly) return ciOnly
106
+
107
+ throw new Error(`project not found: ${name}`)
108
+ }
109
+
110
+ export function listProjects(): ProjectRecord[] {
111
+ return loadProjectCatalog()
112
+ }
@@ -0,0 +1,32 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { IPC_CAPABILITY_PROJECT_LIFECYCLE } from '../../../ipc/protocol'
4
+ import { resolveProject } from '../../client/project-resolver'
5
+ import { SHARED_FLAGS } from '../../flags'
6
+ import { EXIT_OK, writeJson } from '../../output'
7
+
8
+ export const projectClose: CliCommand = {
9
+ args: [{ complete: { kind: 'dynamic', source: 'project' }, name: 'project', required: true }],
10
+ flags: SHARED_FLAGS,
11
+ group: 'project',
12
+ run: async (ctx) => {
13
+ const target = ctx.args.positionals[0]
14
+ if (typeof target !== 'string' || target.length === 0) {
15
+ throw new Error('target project is required (id or name)')
16
+ }
17
+ const project = resolveProject(target)
18
+
19
+ const daemon = await ctx.getDaemon()
20
+ if (!daemon.hasCapability(IPC_CAPABILITY_PROJECT_LIFECYCLE)) {
21
+ throw new Error(
22
+ 'daemon predates projectLifecycle capability — restart aimux to pick up the new daemon'
23
+ )
24
+ }
25
+
26
+ await daemon.expectOk('closeProject', { targetProjectId: project.id })
27
+ writeJson({ closedProjectId: project.id, name: project.name })
28
+ return EXIT_OK
29
+ },
30
+ summary: 'Close a project (via the UI when attached, otherwise the catalog)',
31
+ verb: 'close',
32
+ }
@@ -0,0 +1,93 @@
1
+ import { resolve as resolvePath } from 'node:path'
2
+
3
+ import type { CliCommand } from '../../registry'
4
+
5
+ import { IPC_CAPABILITY_PROJECT_LIFECYCLE } from '../../../ipc/protocol'
6
+ import { SHARED_FLAGS } from '../../flags'
7
+ import { EXIT_OK, EXIT_TIMEOUT, writeJson } from '../../output'
8
+
9
+ const DEFAULT_WAIT_TIMEOUT_MS = 30_000
10
+
11
+ export const projectCreate: CliCommand = {
12
+ args: [{ complete: { kind: 'none' }, name: 'name', required: true }],
13
+ flags: [
14
+ ...SHARED_FLAGS,
15
+ {
16
+ complete: { kind: 'file' },
17
+ description: 'project path to associate with the project',
18
+ kind: 'string',
19
+ name: 'project',
20
+ },
21
+ {
22
+ description: 'immediately switch the running UI to the new project',
23
+ kind: 'boolean',
24
+ name: 'switch',
25
+ },
26
+ {
27
+ description: 'with --switch, wait until the UI confirms the switch completed',
28
+ kind: 'boolean',
29
+ name: 'wait',
30
+ },
31
+ {
32
+ description: 'timeout for --wait, in milliseconds (default 30000)',
33
+ kind: 'number',
34
+ name: 'timeout',
35
+ },
36
+ ],
37
+ group: 'project',
38
+ run: async (ctx) => {
39
+ const name = ctx.args.positionals[0]
40
+ if (typeof name !== 'string' || name.length === 0) {
41
+ throw new Error('project name is required')
42
+ }
43
+ const projectRaw =
44
+ typeof ctx.args.flags.project === 'string' ? ctx.args.flags.project : undefined
45
+ const projectPath = projectRaw === undefined ? undefined : resolvePath(projectRaw)
46
+ const doSwitch = ctx.args.flags.switch === true
47
+ const wait = ctx.args.flags.wait === true
48
+ const timeoutMs =
49
+ typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : DEFAULT_WAIT_TIMEOUT_MS
50
+
51
+ if (wait && !doSwitch) {
52
+ // Without --switch, the UI never emits an ack event, so there's
53
+ // nothing meaningful for --wait to wait on.
54
+ throw new Error('--wait requires --switch (nothing to wait for otherwise)')
55
+ }
56
+
57
+ const daemon = await ctx.getDaemon()
58
+ if (!daemon.hasCapability(IPC_CAPABILITY_PROJECT_LIFECYCLE)) {
59
+ throw new Error(
60
+ 'daemon predates projectLifecycle capability — restart aimux to pick up the new daemon'
61
+ )
62
+ }
63
+
64
+ if (!wait) {
65
+ await daemon.expectOk('createProject', { name, projectPath, switch: doSwitch })
66
+ writeJson({ name, projectPath, switch: doSwitch })
67
+ return EXIT_OK
68
+ }
69
+
70
+ // Subscribe BEFORE sending the request. The UI's create+switch path
71
+ // relays as `projectSwitched` once handleCreateProjectEffect has
72
+ // dispatched load-project. Match on name+projectPath since the CLI
73
+ // doesn't know the id the UI will assign to the new project.
74
+ const settled = new Promise<number>((resolve) => {
75
+ const off = daemon.on('projectSwitched', (payload) => {
76
+ off()
77
+ clearTimeout(timer)
78
+ writeJson({ name, projectId: payload.projectId, projectPath, switch: doSwitch })
79
+ resolve(EXIT_OK)
80
+ })
81
+ const timer = setTimeout(() => {
82
+ off()
83
+ writeJson({ error: 'timed out waiting for projectSwitched', name, projectPath })
84
+ resolve(EXIT_TIMEOUT)
85
+ }, timeoutMs)
86
+ })
87
+
88
+ await daemon.expectOk('createProject', { name, projectPath, switch: doSwitch })
89
+ return settled
90
+ },
91
+ summary: 'Create a new project (via the UI when attached, otherwise the catalog)',
92
+ verb: 'create',
93
+ }
@@ -0,0 +1,25 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { listProjects } from '../../client/project-resolver'
4
+ import { SHARED_FLAGS } from '../../flags'
5
+ import { EXIT_OK, writeJson } from '../../output'
6
+
7
+ export const projectList: CliCommand = {
8
+ args: [],
9
+ flags: SHARED_FLAGS,
10
+ group: 'project',
11
+ run: async () => {
12
+ const projects = listProjects()
13
+ writeJson({
14
+ projects: projects.map((project) => ({
15
+ id: project.id,
16
+ lastOpenedAt: project.lastOpenedAt,
17
+ name: project.name,
18
+ projectPath: project.projectPath,
19
+ })),
20
+ })
21
+ return EXIT_OK
22
+ },
23
+ summary: 'List known projects in the profile catalog',
24
+ verb: 'list',
25
+ }
@@ -0,0 +1,32 @@
1
+ import type { CliCommand } from '../../registry'
2
+
3
+ import { SHARED_FLAGS } from '../../flags'
4
+ import { EXIT_OK, writeJson } from '../../output'
5
+
6
+ export const projectShow: CliCommand = {
7
+ args: [],
8
+ flags: SHARED_FLAGS,
9
+ group: 'project',
10
+ run: async (ctx) => {
11
+ const project = ctx.getProject()
12
+ writeJson({
13
+ activeWorkspaceId: project.activeWorkspaceId,
14
+ createdAt: project.createdAt,
15
+ id: project.id,
16
+ lastOpenedAt: project.lastOpenedAt,
17
+ name: project.name,
18
+ projectPath: project.projectPath,
19
+ workspaces:
20
+ project.workspaces?.map((w) => ({
21
+ branch: w.branch,
22
+ id: w.id,
23
+ name: w.name,
24
+ path: w.path,
25
+ source: w.source,
26
+ })) ?? [],
27
+ })
28
+ return EXIT_OK
29
+ },
30
+ summary: 'Show the active project (or the one named via --project)',
31
+ verb: 'show',
32
+ }