@brimveyn/aimux 1.21.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -1,4 +1,5 @@
1
- import type { AppAction, AppState, TabSession } from '../types'
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, TabSession } from '../types'
2
3
 
3
4
  import {
4
5
  allLeafIds,
@@ -14,12 +15,13 @@ import {
14
15
  setSplitRatio,
15
16
  splitNode,
16
17
  } from '../layout-tree'
17
- import { normalizeGroupedTabOrder } from '../session-persistence'
18
+ import { normalizeGroupedTabOrder } from '../project-persistence'
18
19
  import {
19
- filterTabsForActiveWorktree,
20
- orderTabsByWorktree,
21
- withActiveWorktree,
22
- } from '../session-worktrees'
20
+ clearWorkspaceDone,
21
+ filterTabsForActiveWorkspace,
22
+ orderTabsByWorkspace,
23
+ withActiveWorkspace,
24
+ } from '../project-workspaces'
23
25
  import { createDefaultTerminalModes } from '../terminal-modes'
24
26
 
25
27
  const MAX_BUFFER_LENGTH = 50_000
@@ -197,47 +199,61 @@ function getActiveIndex(state: AppState): number {
197
199
  return state.tabs.findIndex((tab) => tab.id === state.activeTabId)
198
200
  }
199
201
 
200
- function getCurrentSession(state: AppState) {
201
- return state.currentSessionId != null && state.currentSessionId !== ''
202
- ? state.sessions.find((session) => session.id === state.currentSessionId)
202
+ function getCurrentProject(state: AppState) {
203
+ return state.currentProjectId != null && state.currentProjectId !== ''
204
+ ? state.projects.find((project) => project.id === state.currentProjectId)
203
205
  : undefined
204
206
  }
205
207
 
206
- function withActiveTabWorktree(
208
+ function withActiveTabWorkspace(
207
209
  state: AppState,
208
210
  tabId: string | null,
209
211
  opts?: { onlyIfMissing?: boolean }
210
212
  ): AppState {
211
- if (
212
- tabId == null ||
213
- tabId === '' ||
214
- !(state.currentSessionId != null && state.currentSessionId !== '')
215
- )
216
- return state
217
- const tab = state.tabs.find((entry) => entry.id === tabId)
218
- if (!(tab?.worktreeId != null && tab?.worktreeId !== '')) return state
219
- const worktreeId = tab.worktreeId
220
- // When `onlyIfMissing` is set, we leave the session's worktree alone if it
221
- // already points at a known worktree. Used by `hydrate-workspace` so that
222
- // a backend re-attach after a user-initiated worktree switch (j/k cycling)
223
- // doesn't clobber the freshly chosen worktree by re-syncing from the
213
+ if (tabId == null || tabId === '') return state
214
+ // Opening the tab is reading its notification, whatever else this helper
215
+ // decides about the workspace. Every path that makes a tab the active one
216
+ // goes through here.
217
+ const seen = { ...state, tabs: clearTabUnseen(state.tabs, tabId) }
218
+ if (!(seen.currentProjectId != null && seen.currentProjectId !== '')) return seen
219
+ const tab = seen.tabs.find((entry) => entry.id === tabId)
220
+ if (!(tab?.workspaceId != null && tab?.workspaceId !== '')) return seen
221
+ const workspaceId = tab.workspaceId
222
+ // Opening one of a workspace's tabs is seeing that workspace, so it drops the
223
+ // finished-a-turn tick otherwise a background tab finishing in the workspace
224
+ // you are already in leaves a that only a workspace switch could clear.
225
+ const workspaceActivity = clearWorkspaceDone(seen.workspaceActivity, workspaceId)
226
+ // When `onlyIfMissing` is set, we leave the project's workspace alone if it
227
+ // already points at a known workspace. Used by `hydrate-project` so that
228
+ // a backend re-attach after a user-initiated workspace switch (j/k cycling)
229
+ // doesn't clobber the freshly chosen workspace by re-syncing from the
224
230
  // restored active tab.
225
231
  if (opts?.onlyIfMissing === true) {
226
- const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
227
- const hasValidWorktree =
228
- session?.activeWorktreeId != null &&
229
- session.activeWorktreeId !== '' &&
230
- (session.worktrees?.some((w) => w.id === session.activeWorktreeId) ?? false)
231
- if (hasValidWorktree) return state
232
+ const project = seen.projects.find((entry) => entry.id === seen.currentProjectId)
233
+ const hasValidWorkspace =
234
+ project?.activeWorkspaceId != null &&
235
+ project.activeWorkspaceId !== '' &&
236
+ (project.workspaces?.some((w) => w.id === project.activeWorkspaceId) ?? false)
237
+ if (hasValidWorkspace) return { ...seen, workspaceActivity }
232
238
  }
233
239
  return {
234
- ...state,
235
- sessions: state.sessions.map((session) =>
236
- session.id === state.currentSessionId ? withActiveWorktree(session, worktreeId) : session
240
+ ...seen,
241
+ projects: seen.projects.map((project) =>
242
+ project.id === seen.currentProjectId ? withActiveWorkspace(project, workspaceId) : project
237
243
  ),
244
+ workspaceActivity,
238
245
  }
239
246
  }
240
247
 
248
+ /**
249
+ * Drop one tab's unseen mark. Returns the same array when it holds none, so a
250
+ * caller can run it on every activation without re-rendering the tab list.
251
+ */
252
+ function clearTabUnseen(tabs: TabSession[], tabId: string): TabSession[] {
253
+ if (!tabs.some((tab) => tab.id === tabId && tab.unseen === true)) return tabs
254
+ return tabs.map((tab) => (tab.id === tabId ? { ...tab, unseen: undefined } : tab))
255
+ }
256
+
241
257
  function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
242
258
  if (indexToClose < 0 || indexToClose >= state.tabs.length) {
243
259
  return state
@@ -262,7 +278,12 @@ function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
262
278
  getAdjacentLeaf(groupTree, closingTabId, 'down') ??
263
279
  getAdjacentLeaf(groupTree, closingTabId, 'up'))
264
280
  : null
265
- nextActiveTabId = layoutNeighbor ?? tabs[indexToClose]?.id ?? tabs[indexToClose - 1]?.id ?? null
281
+ // Positional fallbacks must skip hidden tabs: landing `activeTabId` on one
282
+ // would render a pane the user cannot see in any tab bar.
283
+ const positional = [tabs[indexToClose], tabs[indexToClose - 1]].find(
284
+ (tab) => tab !== undefined && tab.hidden !== true
285
+ )
286
+ nextActiveTabId = layoutNeighbor ?? positional?.id ?? null
266
287
  } else {
267
288
  nextActiveTabId = tabs.find((tab) => tab.id === state.activeTabId)?.id ?? null
268
289
  }
@@ -292,7 +313,7 @@ function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
292
313
  }
293
314
  }
294
315
 
295
- return withActiveTabWorktree(
316
+ return withActiveTabWorkspace(
296
317
  {
297
318
  ...state,
298
319
  activeTabId: nextActiveTabId,
@@ -309,35 +330,41 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
309
330
  switch (action.type) {
310
331
  case 'add-tab': {
311
332
  const newTab = { ...action.tab, activity: action.tab.activity ?? 'idle' }
312
- return withActiveTabWorktree(
333
+ // A hidden tab is chrome-invisible, so adding it must be invisible too:
334
+ // no focus steal, no modal dismissal, and — critically — no workspace
335
+ // re-sync, which would teleport a user sitting on another workspace.
336
+ if (newTab.hidden === true) {
337
+ return { ...state, tabs: [...state.tabs, newTab] }
338
+ }
339
+ return withActiveTabWorkspace(
313
340
  {
314
341
  ...state,
315
342
  activeTabId: newTab.id,
316
343
  focusMode: 'navigation',
317
- modal: { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: null },
344
+ modal: { editBuffer: null, projectTargetId: null, selectedIndex: 0, type: null },
318
345
  tabs: [...state.tabs, newTab],
319
346
  },
320
347
  newTab.id
321
348
  )
322
349
  }
323
- case 'hydrate-workspace': {
324
- // The session's activeWorktreeId may have just been switched by the
350
+ case 'hydrate-project': {
351
+ // The project's activeWorkspaceId may have just been switched by the
325
352
  // user (j/k cycle, sidebar click) before the backend attached. Honor
326
- // that choice by only considering tabs visible in that worktree
353
+ // that choice by only considering tabs visible in that workspace
327
354
  // otherwise we'd land the active tab on whatever the backend picked
328
- // (typically the worktree we *last* persisted, not the current one).
329
- const currentSession =
330
- state.currentSessionId != null && state.currentSessionId !== ''
331
- ? state.sessions.find((s) => s.id === state.currentSessionId)
355
+ // (typically the workspace we *last* persisted, not the current one).
356
+ const currentProject =
357
+ state.currentProjectId != null && state.currentProjectId !== ''
358
+ ? state.projects.find((s) => s.id === state.currentProjectId)
332
359
  : undefined
333
- const visibleForWorktree = filterTabsForActiveWorktree(action.tabs, currentSession)
334
- const visibleIds = new Set(visibleForWorktree.map((t) => t.id))
360
+ const visibleForWorkspace = filterTabsForActiveWorkspace(action.tabs, currentProject)
361
+ const visibleIds = new Set(visibleForWorkspace.map((t) => t.id))
335
362
  const hydratedActiveTabId =
336
363
  action.activeTabId != null &&
337
364
  action.activeTabId !== '' &&
338
365
  visibleIds.has(action.activeTabId)
339
366
  ? action.activeTabId
340
- : (visibleForWorktree[0]?.id ?? null)
367
+ : (visibleForWorkspace[0]?.id ?? null)
341
368
  const tabIds = new Set(action.tabs.map((t) => t.id))
342
369
 
343
370
  // Restore from new multi-tree format or migrate from legacy single tree
@@ -368,7 +395,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
368
395
  }
369
396
  }
370
397
 
371
- return withActiveTabWorktree(
398
+ return withActiveTabWorkspace(
372
399
  {
373
400
  ...state,
374
401
  activeTabId: hydratedActiveTabId,
@@ -389,24 +416,24 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
389
416
  case 'close-active-tab':
390
417
  return closeTabAtIndex(state, getActiveIndex(state))
391
418
  case 'set-active-tab':
392
- return withActiveTabWorktree({ ...state, activeTabId: action.tabId }, action.tabId)
419
+ return withActiveTabWorkspace({ ...state, activeTabId: action.tabId }, action.tabId)
393
420
  case 'move-active-tab': {
394
421
  if (state.tabs.length === 0) {
395
422
  return state
396
423
  }
397
- const session = getCurrentSession(state)
398
- const visibleTabs = filterTabsForActiveWorktree(state.tabs, session)
424
+ const project = getCurrentProject(state)
425
+ const visibleTabs = filterTabsForActiveWorkspace(state.tabs, project)
399
426
  if (visibleTabs.length === 0) {
400
427
  return state
401
428
  }
402
- const orderedTabs = orderTabsByWorktree(visibleTabs, session)
429
+ const orderedTabs = orderTabsByWorkspace(visibleTabs, project)
403
430
  const currentIndex = orderedTabs.findIndex((tab) => tab.id === state.activeTabId)
404
431
  const safeIndex = currentIndex === -1 ? 0 : currentIndex
405
432
  const nextIndex = (safeIndex + action.delta + orderedTabs.length) % orderedTabs.length
406
433
  const nextTabId = orderedTabs[nextIndex]?.id
407
434
  return nextTabId == null || nextTabId === '' || nextTabId === state.activeTabId
408
435
  ? state
409
- : withActiveTabWorktree({ ...state, activeTabId: nextTabId }, nextTabId)
436
+ : withActiveTabWorkspace({ ...state, activeTabId: nextTabId }, nextTabId)
410
437
  }
411
438
  case 'reorder-active-tab': {
412
439
  const activeIndex = getActiveIndex(state)
@@ -436,8 +463,14 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
436
463
  return { ...state, tabs }
437
464
  }
438
465
 
439
- // Standalone tab reorder
440
- const nextIndex = activeIndex + action.delta
466
+ // Standalone tab reorder. Step over hidden tabs rather than swapping with
467
+ // one: the swap would look like a dead keypress and shuffle the hidden
468
+ // tab out of its append-order slot.
469
+ let nextIndex = activeIndex + action.delta
470
+ const step = action.delta < 0 ? -1 : 1
471
+ while (state.tabs[nextIndex]?.hidden === true) {
472
+ nextIndex += step
473
+ }
441
474
  if (nextIndex < 0 || nextIndex >= state.tabs.length) {
442
475
  return state
443
476
  }
@@ -473,7 +506,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
473
506
  // Drag-and-drop reorder of the visible tab strip. `orderedTabIds` is the
474
507
  // flattened new order of the currently-visible tabs (group members already
475
508
  // expanded into contiguous runs). We only rewrite the slots those tabs
476
- // occupy in `state.tabs`, leaving tabs from other worktrees anchored in
509
+ // occupy in `state.tabs`, leaving tabs from other workspaces anchored in
477
510
  // place. Because group members arrive contiguous, splits stay intact.
478
511
  const visibleSet = new Set(action.orderedTabIds)
479
512
  const byId = new Map(state.tabs.map((tab) => [tab.id, tab]))
@@ -486,8 +519,8 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
486
519
  })
487
520
  return { ...state, tabs }
488
521
  }
489
- case 'reset-tab-session':
490
- return withActiveTabWorktree(
522
+ case 'reset-tab-project':
523
+ return withActiveTabWorkspace(
491
524
  {
492
525
  ...state,
493
526
  activeTabId: action.tabId,
@@ -527,7 +560,20 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
527
560
  case 'set-tab-activity':
528
561
  return {
529
562
  ...state,
530
- tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, activity: action.activity })),
563
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
564
+ ...tab,
565
+ activity: action.activity,
566
+ // Back to work: whatever it wanted to tell you is out of date.
567
+ unseen:
568
+ action.activity === 'working' || action.activity === 'waiting-input'
569
+ ? undefined
570
+ : tab.unseen,
571
+ })),
572
+ }
573
+ case 'mark-tab-unseen':
574
+ return {
575
+ ...state,
576
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, unseen: true })),
531
577
  }
532
578
  case 'set-tab-error':
533
579
  return {
@@ -555,6 +601,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
555
601
  tabs: updateTab(state.tabs, action.tabId, (tab) => ({
556
602
  ...tab,
557
603
  autoRenameStatus: action.autoRenameStatus ?? tab.autoRenameStatus,
604
+ hidden: action.hidden ?? tab.hidden,
558
605
  title: action.title ?? tab.title,
559
606
  })),
560
607
  }
@@ -590,7 +637,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
590
637
  }
591
638
  const tabs = [...state.tabs.slice(0, insertIndex), newTab, ...state.tabs.slice(insertIndex)]
592
639
 
593
- return withActiveTabWorktree(
640
+ return withActiveTabWorkspace(
594
641
  {
595
642
  ...state,
596
643
  activeTabId: newTab.id,
@@ -621,7 +668,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
621
668
  if (!(neighbor != null && neighbor !== '')) {
622
669
  return state
623
670
  }
624
- return withActiveTabWorktree({ ...state, activeTabId: neighbor }, neighbor)
671
+ return withActiveTabWorkspace({ ...state, activeTabId: neighbor }, neighbor)
625
672
  }
626
673
  case 'resize-pane': {
627
674
  const resizeGroupId = getGroupIdForTab(state.tabGroupMap, action.tabId)
@@ -1,4 +1,5 @@
1
- import type { AppAction, AppState, BarSide, BarState, BarWidget } from '../types'
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, BarSide, BarState, BarWidget } from '../types'
2
3
 
3
4
  import {
4
5
  boundaryDeltaFromRatio,
@@ -99,10 +100,10 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
99
100
  return state
100
101
  }
101
102
  return { ...state, pendingChords: action.chords }
102
- case 'toggle-session-bar':
103
+ case 'toggle-project-bar':
103
104
  return {
104
105
  ...state,
105
- sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
106
+ projectBar: { ...state.projectBar, visible: !state.projectBar.visible },
106
107
  }
107
108
  default:
108
109
  return null
@@ -1,38 +1,39 @@
1
- import type { AssistantOption } from '../pty/command-registry'
2
- import type { AssistantId, SessionRecord, SnippetRecord, WorktreeRecord } from './types'
1
+ import type { ProjectRecord, SnippetRecord, WorkspaceRecord } from './types'
2
+
3
+ import { type AssistantOption, getAllAssistantOptions } from '../pty/command-registry'
3
4
 
4
5
  export interface BaseRefOption {
5
- /** Git ref the new worktree is forked from. */
6
+ /** Git ref the new workspace is forked from. */
6
7
  ref: string
7
8
  label: string
8
- kind: 'worktree' | 'branch'
9
- /** Worktree name, for the 'worktree' kind. */
9
+ kind: 'workspace' | 'branch'
10
+ /** Workspace name, for the 'workspace' kind. */
10
11
  detail?: string
11
12
  }
12
13
 
13
14
  /**
14
- * Ordered, filtered base-ref candidates for the worktree-create "Base" picker:
15
- * branches checked out in the session's worktrees first (labelled with the
16
- * worktree name), then the remaining local branches. A branch already surfaced
17
- * via a worktree is not repeated. Throwaway `aimux/` branches are skipped unless
18
- * a live worktree is on them — `git worktree remove` leaves the branch behind,
19
- * so deleted temp worktrees would otherwise haunt the list as orphan branches.
15
+ * Ordered, filtered base-ref candidates for the workspace-create "Base" picker:
16
+ * branches checked out in the project's workspaces first (labelled with the
17
+ * workspace name), then the remaining local branches. A branch already surfaced
18
+ * via a workspace is not repeated. Throwaway `aimux/` branches are skipped unless
19
+ * a live workspace is on them — `git worktree remove` leaves the branch behind,
20
+ * so deleted temp workspaces would otherwise haunt the list as orphan branches.
20
21
  */
21
22
  export function buildBaseRefOptions(
22
- worktrees: WorktreeRecord[],
23
+ workspaces: WorkspaceRecord[],
23
24
  localBranches: string[],
24
25
  query: string
25
26
  ): BaseRefOption[] {
26
27
  const seen = new Set<string>()
27
28
  const options: BaseRefOption[] = []
28
- for (const worktree of worktrees) {
29
- if (worktree.branch == null || worktree.branch === '' || seen.has(worktree.branch)) continue
30
- seen.add(worktree.branch)
29
+ for (const workspace of workspaces) {
30
+ if (workspace.branch == null || workspace.branch === '' || seen.has(workspace.branch)) continue
31
+ seen.add(workspace.branch)
31
32
  options.push({
32
- detail: worktree.name,
33
- kind: 'worktree',
34
- label: worktree.branch,
35
- ref: worktree.branch,
33
+ detail: workspace.name,
34
+ kind: 'workspace',
35
+ label: workspace.branch,
36
+ ref: workspace.branch,
36
37
  })
37
38
  }
38
39
  for (const branch of localBranches) {
@@ -45,16 +46,6 @@ export function buildBaseRefOptions(
45
46
  return options.filter((option) => option.label.toLowerCase().includes(trimmed))
46
47
  }
47
48
 
48
- /**
49
- * 0 when the template picker should NOT show the "None" fallback (no assistant
50
- * was picked — typical for the template shortcut path), 1 otherwise. Shared
51
- * by the modal reducer, the side-effect that resolves templateId, and the
52
- * picker UI so the three stay in sync.
53
- */
54
- export function getTemplateNoneOffset(selectedAssistantId: AssistantId | null): 0 | 1 {
55
- return selectedAssistantId == null ? 0 : 1
56
- }
57
-
58
49
  export function filterAssistants(
59
50
  options: AssistantOption[],
60
51
  filter: string | null
@@ -66,18 +57,39 @@ export function filterAssistants(
66
57
  )
67
58
  }
68
59
 
69
- export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
60
+ /**
61
+ * Assistants offered by the new-tab picker. One source of truth on purpose: the
62
+ * modal renders this list while `launch-selected-assistant` indexes into it, so
63
+ * a divergence launches the row above or below the one the user highlighted.
64
+ *
65
+ * Chained from `<C-p>`, the prompt is pasted into the assistant and drives the
66
+ * workspace's name — neither of which a plain shell can do — so Terminal is not
67
+ * offered there. A bare `<C-n>` still lists it.
68
+ */
69
+ export function getNewTabAssistantOptions(
70
+ customCommands: Record<string, string>,
71
+ filter: string | null,
72
+ excludeTerminal: boolean
73
+ ): AssistantOption[] {
74
+ const all = getAllAssistantOptions(customCommands)
75
+ return filterAssistants(
76
+ excludeTerminal ? all.filter((option) => option.id !== 'terminal') : all,
77
+ filter
78
+ )
79
+ }
80
+
81
+ export function filterProjects(projects: ProjectRecord[], filter: string | null): ProjectRecord[] {
70
82
  if (!(filter != null && filter !== '')) {
71
- return sessions
83
+ return projects
72
84
  }
73
85
 
74
86
  const lower = filter.toLowerCase()
75
- return sessions.filter(
76
- (session) =>
77
- session.name.toLowerCase().includes(lower) ||
78
- (session.projectPath != null &&
79
- session.projectPath !== '' &&
80
- session.projectPath.toLowerCase().includes(lower))
87
+ return projects.filter(
88
+ (project) =>
89
+ project.name.toLowerCase().includes(lower) ||
90
+ (project.projectPath != null &&
91
+ project.projectPath !== '' &&
92
+ project.projectPath.toLowerCase().includes(lower))
81
93
  )
82
94
  }
83
95
 
@@ -1,4 +1,4 @@
1
- import type { WorktreeTemplate } from '../config'
1
+ import type { AppAction } from './actions'
2
2
 
3
3
  import { clampBarWidth, KNOWN_WIDGET_IDS } from './bars'
4
4
  import { reduceAutoCommit } from './reducers/auto-commit-state'
@@ -6,19 +6,19 @@ import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
6
6
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
7
7
  import { emptyModal, reduceModalState } from './reducers/modal-state'
8
8
  import { reduceMultiRepoState } from './reducers/multi-repo-state'
9
- import { reduceSessionState } from './reducers/session-state'
9
+ import { reduceProjectState } from './reducers/project-state'
10
+ import { emptySettingsUI, reduceSettingsState } from './reducers/settings-state'
10
11
  import { reduceTabState } from './reducers/tab-state'
11
12
  import { reduceUIState } from './reducers/ui-state'
12
13
  import { filterSnippets } from './selectors'
13
14
  import {
14
- type AppAction,
15
15
  type AppState,
16
16
  type BarsState,
17
17
  EMPTY_AUTO_COMMIT_STATE,
18
18
  EMPTY_MULTI_REPO_STATE,
19
19
  type GitModeState,
20
20
  type GitPaneState,
21
- type SessionRecord,
21
+ type ProjectRecord,
22
22
  type SnippetRecord,
23
23
  } from './types'
24
24
 
@@ -29,8 +29,7 @@ export interface InitialStateOverrides {
29
29
  gitMode?: Partial<GitModeState>
30
30
  gitPane?: Partial<GitPaneState>
31
31
  bars?: BarsState
32
- sessionBarVisible?: boolean
33
- worktreeTemplates?: WorktreeTemplate[]
32
+ projectBarVisible?: boolean
34
33
  }
35
34
 
36
35
  const DEFAULT_GIT_PANE: GitPaneState = {
@@ -46,14 +45,26 @@ export const DEFAULT_BARS: BarsState = {
46
45
  left: {
47
46
  visible: true,
48
47
  widgets: [
49
- { grow: 50, id: 'workspaces', visible: true },
48
+ { grow: 50, id: 'projects', visible: true },
50
49
  { grow: 50, id: 'git', visible: true },
50
+ // Opt-in: reachable via the bar's right-click menu ("Show Setup"). Present
51
+ // here so sanitizeBars places it rather than treating it as corruption.
52
+ { grow: 50, id: 'setup', visible: false },
51
53
  ],
52
54
  width: 28,
53
55
  },
54
56
  right: { visible: false, widgets: [], width: 40 },
55
57
  }
56
58
 
59
+ /**
60
+ * Persisted widget ids from before the project/workspace rename. Without this
61
+ * the old id is simply unknown, so the widget is pruned, the bar renders empty
62
+ * and the emptied layout is written straight back to aimux.json.
63
+ *
64
+ * ponytail: drop with the other rename shims.
65
+ */
66
+ const LEGACY_WIDGET_IDS: Record<string, string> = { workspaces: 'projects' }
67
+
57
68
  /**
58
69
  * Drop widget ids this build cannot render (config written by a newer or
59
70
  * patched version) and normalise widths — the only place unknown ids can enter.
@@ -62,63 +73,82 @@ function sanitizeBars(bars: BarsState): BarsState {
62
73
  const sanitizeBar = (bar: BarsState[keyof BarsState]): BarsState[keyof BarsState] => ({
63
74
  ...bar,
64
75
  widgets: bar.widgets
76
+ .map((widget) => ({ ...widget, id: LEGACY_WIDGET_IDS[widget.id] ?? widget.id }))
65
77
  .filter((widget) => (KNOWN_WIDGET_IDS as readonly string[]).includes(widget.id))
66
78
  .map((widget) => ({ ...widget, grow: Math.max(1, Math.round(widget.grow)) })),
67
79
  width: clampBarWidth(bar.width),
68
80
  })
69
- return { left: sanitizeBar(bars.left), right: sanitizeBar(bars.right) }
81
+ const sanitized: BarsState = { left: sanitizeBar(bars.left), right: sanitizeBar(bars.right) }
82
+
83
+ // A widget can only ever be moved between bars or hidden, never deleted, so
84
+ // one that is in neither bar means the persisted layout was corrupted —
85
+ // which is exactly what the pre-rename `workspaces` id did to older files
86
+ // before LEGACY_WIDGET_IDS existed. Put it back where it ships by default,
87
+ // otherwise the bar renders empty forever and re-saves that emptiness.
88
+ for (const side of ['left', 'right'] as const) {
89
+ for (const fallback of DEFAULT_BARS[side].widgets) {
90
+ const present =
91
+ sanitized.left.widgets.some((w) => w.id === fallback.id) ||
92
+ sanitized.right.widgets.some((w) => w.id === fallback.id)
93
+ if (!present) {
94
+ sanitized[side] = { ...sanitized[side], widgets: [...sanitized[side].widgets, fallback] }
95
+ }
96
+ }
97
+ }
98
+ return sanitized
70
99
  }
71
100
 
72
101
  export function createInitialState(
73
102
  customCommands: Record<string, string> = {},
74
- sessions: SessionRecord[] = [],
103
+ projects: ProjectRecord[] = [],
75
104
  snippets: SnippetRecord[] = [],
76
- showSessionPicker = false,
105
+ showProjectPicker = false,
77
106
  overrides: InitialStateOverrides = {}
78
107
  ): AppState {
79
108
  return {
80
109
  activeTabId: null,
81
110
  autoCommit: EMPTY_AUTO_COMMIT_STATE,
82
111
  bars: sanitizeBars(overrides.bars ?? DEFAULT_BARS),
83
- currentSessionId: null,
112
+ currentProjectId: null,
84
113
  customCommands,
85
- focusMode: showSessionPicker ? 'command-edit' : 'navigation',
114
+ focusMode: showProjectPicker ? 'command-edit' : 'navigation',
86
115
  gitMode: { ...emptyGitMode(), ...overrides.gitMode },
87
116
  gitPane: { ...DEFAULT_GIT_PANE, ...overrides.gitPane },
88
117
  gitPanel: emptyGitPanel(),
89
- lastActiveTabByWorktree: {},
118
+ lastActiveTabByWorkspace: {},
90
119
  layout: {
91
120
  terminalCols: DEFAULT_TERMINAL_COLS,
92
121
  terminalRows: DEFAULT_TERMINAL_ROWS,
93
122
  },
94
123
  layoutTrees: {},
95
- modal: showSessionPicker
124
+ modal: showProjectPicker
96
125
  ? {
97
126
  cursorPos: 0,
98
127
  editBuffer: '',
128
+ projectTargetId: null,
99
129
  selectedIndex: 0,
100
- sessionTargetId: null,
101
- type: 'session-picker',
130
+ type: 'project-picker',
102
131
  }
103
132
  : emptyModal(),
104
133
  multiRepo: EMPTY_MULTI_REPO_STATE,
105
134
  pendingChords: null,
106
- sessionBar: {
107
- visible: overrides.sessionBarVisible ?? true,
135
+ projectBar: {
136
+ visible: overrides.projectBarVisible ?? true,
108
137
  },
109
- sessions,
110
- sessionStatuses: {},
138
+ projects,
139
+ projectStatuses: {},
140
+ settings: emptySettingsUI(),
111
141
  snippets,
112
142
  tabGroupMap: {},
113
143
  tabs: [],
114
- worktreeDivergence: {},
115
- worktreeTemplates: overrides.worktreeTemplates ?? [],
144
+ workspaceActivity: {},
145
+ workspaceDivergence: {},
116
146
  }
117
147
  }
118
148
 
119
149
  export function appReducer(state: AppState, action: AppAction): AppState {
120
- const sessionState = reduceSessionState(state, action)
121
- if (sessionState) return sessionState
150
+ const projectState = reduceProjectState(state, action)
151
+ if (projectState) return projectState
122
152
 
123
153
  const tabState = reduceTabState(state, action)
124
154
  if (tabState) return tabState
@@ -141,6 +171,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
141
171
  const multiRepoState = reduceMultiRepoState(state, action)
142
172
  if (multiRepoState) return multiRepoState
143
173
 
174
+ const settingsState = reduceSettingsState(state, action)
175
+ if (settingsState) return settingsState
176
+
144
177
  switch (action.type) {
145
178
  case 'set-snippets':
146
179
  return { ...state, snippets: action.snippets }