@brimveyn/aimux 1.20.4 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +305 -1589
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/split-drag-controller.ts +4 -8
  19. package/src/app-runtime/tab-actions.ts +218 -0
  20. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  21. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  22. package/src/app-runtime/use-backend-runtime.ts +12 -11
  23. package/src/app-runtime/use-directory-search.ts +5 -4
  24. package/src/app-runtime/use-mouse-handlers.ts +28 -42
  25. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  26. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  27. package/src/app-runtime/use-setup-runner.ts +96 -0
  28. package/src/app-runtime/use-terminal-resize.ts +28 -33
  29. package/src/app-runtime/workspace-actions.ts +366 -0
  30. package/src/app-runtime/workspace-activity.ts +138 -0
  31. package/src/app-runtime/workspace-naming.ts +90 -0
  32. package/src/app.tsx +102 -85
  33. package/src/assets/sounds/bell.wav +0 -0
  34. package/src/assets/sounds/ding.wav +0 -0
  35. package/src/assets/sounds/plane.wav +0 -0
  36. package/src/cli/client/daemon-client.ts +2 -2
  37. package/src/cli/client/project-resolver.ts +112 -0
  38. package/src/cli/commands/project/close.ts +32 -0
  39. package/src/cli/commands/project/create.ts +93 -0
  40. package/src/cli/commands/project/list.ts +25 -0
  41. package/src/cli/commands/project/show.ts +32 -0
  42. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  43. package/src/cli/commands/tab/await.ts +2 -2
  44. package/src/cli/commands/tab/close.ts +3 -3
  45. package/src/cli/commands/tab/create.ts +74 -73
  46. package/src/cli/commands/tab/focus.ts +3 -3
  47. package/src/cli/commands/tab/list.ts +6 -6
  48. package/src/cli/commands/tab/run.ts +2 -2
  49. package/src/cli/commands/tab/send.ts +2 -2
  50. package/src/cli/commands/tab/snapshot.ts +2 -2
  51. package/src/cli/commands/tab/tail.ts +2 -2
  52. package/src/cli/commands/tab/wait.ts +2 -2
  53. package/src/cli/commands/worker/await.ts +3 -3
  54. package/src/cli/commands/worker/doctor.ts +30 -30
  55. package/src/cli/commands/worker/list.ts +15 -15
  56. package/src/cli/commands/worker/prompt.ts +3 -3
  57. package/src/cli/commands/worker/run.ts +23 -23
  58. package/src/cli/commands/worker/shared.ts +53 -59
  59. package/src/cli/commands/worker/stop.ts +29 -29
  60. package/src/cli/commands/worker/submit.ts +3 -3
  61. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  62. package/src/cli/commands/workspace/create.ts +30 -68
  63. package/src/cli/commands/workspace/list.ts +52 -9
  64. package/src/cli/commands/workspace/remove.ts +81 -0
  65. package/src/cli/completion/plan.ts +2 -2
  66. package/src/cli/completion/sources.ts +18 -18
  67. package/src/cli/context.ts +13 -13
  68. package/src/cli/flags.ts +30 -10
  69. package/src/cli/index.ts +26 -16
  70. package/src/cli/output.ts +1 -1
  71. package/src/cli/registry.ts +12 -12
  72. package/src/config/loader.ts +16 -5
  73. package/src/config.ts +149 -101
  74. package/src/daemon/catalog-writer.ts +67 -67
  75. package/src/daemon/daemon.ts +205 -201
  76. package/src/daemon/session-manager.ts +39 -39
  77. package/src/daemon/session-registry.ts +14 -14
  78. package/src/git/divergence.ts +33 -2
  79. package/src/git/git-poller.ts +1 -1
  80. package/src/git/git-status.ts +2 -2
  81. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  82. package/src/git/pr-merge.ts +64 -0
  83. package/src/git/pr-status-poller.ts +57 -0
  84. package/src/git/pr-status.ts +227 -0
  85. package/src/git/repo-discovery.ts +1 -1
  86. package/src/git/use-repo-discovery.ts +1 -1
  87. package/src/git/workspace-branch-poller.ts +54 -0
  88. package/src/git/workspace-divergence-poller.ts +65 -0
  89. package/src/git/worktree.ts +29 -0
  90. package/src/index.tsx +11 -6
  91. package/src/input/keymap/help-entries.ts +7 -5
  92. package/src/input/modes/bridge.ts +13 -12
  93. package/src/input/modes/handlers/shared.ts +1 -1
  94. package/src/input/modes/transitions.ts +34 -21
  95. package/src/input/modes/types.ts +48 -31
  96. package/src/ipc/manager-protocol.ts +44 -44
  97. package/src/ipc/protocol.ts +192 -166
  98. package/src/platform/open-url.ts +39 -0
  99. package/src/platform/play-sound.ts +184 -0
  100. package/src/platform/project-search.ts +8 -8
  101. package/src/platform/worktree-paths.ts +8 -6
  102. package/src/pty/assistant-question-extractor.ts +1 -1
  103. package/src/pty/assistant-status-detection-loop.ts +79 -55
  104. package/src/pty/assistant-status-detector.ts +40 -15
  105. package/src/pty/command-registry.ts +81 -1
  106. package/src/restart-terminal-manager.ts +1 -1
  107. package/src/services/ai-usage/provider.ts +9 -2
  108. package/src/services/ai-usage/spawn.ts +3 -1
  109. package/src/session-backend/bootstrap.ts +4 -4
  110. package/src/session-backend/local-session-backend.ts +78 -75
  111. package/src/session-backend/remote-session-backend.ts +64 -52
  112. package/src/session-backend/types.ts +43 -32
  113. package/src/settings/live.ts +90 -0
  114. package/src/settings/search.ts +43 -0
  115. package/src/settings/sections/about.ts +49 -0
  116. package/src/settings/sections/appearance.ts +54 -0
  117. package/src/settings/sections/automation.ts +105 -0
  118. package/src/settings/sections/commands.ts +72 -0
  119. package/src/settings/sections/editor.ts +56 -0
  120. package/src/settings/sections/experimental.ts +57 -0
  121. package/src/settings/sections/git.ts +112 -0
  122. package/src/settings/sections/index.ts +95 -0
  123. package/src/settings/sections/integrations.ts +23 -0
  124. package/src/settings/sections/layout.ts +72 -0
  125. package/src/settings/sections/notifications.ts +99 -0
  126. package/src/settings/sections/setup.ts +54 -0
  127. package/src/settings/sections/status-bar.ts +63 -0
  128. package/src/settings/settings-store.ts +207 -0
  129. package/src/settings/types.ts +108 -0
  130. package/src/snippets/run-shell-var.ts +48 -22
  131. package/src/state/actions.ts +342 -0
  132. package/src/state/app-store.ts +2 -1
  133. package/src/state/bars.ts +75 -0
  134. package/src/state/dispatch-ref.ts +1 -1
  135. package/src/state/git-pane-sizing.ts +0 -9
  136. package/src/state/layout-resize.ts +2 -2
  137. package/src/state/pr-status-store.ts +39 -0
  138. package/src/state/project-catalog.ts +275 -0
  139. package/src/state/project-data.ts +143 -0
  140. package/src/state/{session-persistence.ts → project-persistence.ts} +77 -73
  141. package/src/state/project-save.ts +46 -0
  142. package/src/state/project-workspaces.ts +392 -0
  143. package/src/state/reducers/auto-commit-state.ts +11 -10
  144. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  145. package/src/state/reducers/git-mode-state.ts +1 -1
  146. package/src/state/reducers/git-panel-state.ts +10 -53
  147. package/src/state/reducers/modal-state.ts +290 -681
  148. package/src/state/reducers/multi-repo-state.ts +3 -6
  149. package/src/state/reducers/project-state.ts +312 -0
  150. package/src/state/reducers/settings-state.ts +80 -0
  151. package/src/state/reducers/tab-state.ts +107 -60
  152. package/src/state/reducers/ui-state.ts +86 -15
  153. package/src/state/selectors.ts +49 -37
  154. package/src/state/store.ts +83 -57
  155. package/src/state/types.ts +261 -417
  156. package/src/state/validation.ts +14 -12
  157. package/src/terminal-manager/manager-client.ts +32 -32
  158. package/src/terminal-manager/terminal-manager.ts +24 -24
  159. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  160. package/src/ui/components/git/git-panel.tsx +2 -2
  161. package/src/ui/components/git/git-view.tsx +12 -12
  162. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  163. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  164. package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
  165. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  166. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  167. package/src/ui/components/layout/bar.tsx +191 -0
  168. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  169. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  170. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  171. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  172. package/src/ui/components/layout/status-bar.tsx +6 -2
  173. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  174. package/src/ui/components/layout/top-tab-bar.tsx +48 -37
  175. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  176. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  177. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  178. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  179. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  180. package/src/ui/components/modals/shared/form.tsx +34 -22
  181. package/src/ui/components/modals/shared/picker.tsx +10 -10
  182. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  183. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  184. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  185. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  186. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  187. package/src/ui/components/primitives/input-field.tsx +7 -3
  188. package/src/ui/components/primitives/surface.tsx +3 -0
  189. package/src/ui/components/settings/row-value.tsx +74 -0
  190. package/src/ui/components/settings/settings-row.tsx +78 -0
  191. package/src/ui/components/settings/settings-view.tsx +169 -0
  192. package/src/ui/components/setup/setup-widget.tsx +152 -0
  193. package/src/ui/flash/build-labels.ts +27 -27
  194. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  195. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  196. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  197. package/src/ui/root.tsx +164 -230
  198. package/src/ui/status-bar-model.ts +48 -34
  199. package/src/ui/terminal-graphics/kitty.ts +28 -2
  200. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  201. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  202. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  203. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  204. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  205. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  206. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  207. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  208. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  209. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  210. package/src/ui/terminal-graphics/sprites.ts +246 -0
  211. package/src/ui/truncate.ts +7 -0
  212. package/src/ui/widgets/registry.tsx +21 -0
  213. package/src/ui/widgets/widget-context-menu.ts +85 -0
  214. package/src/update.ts +2 -2
  215. package/src/app-runtime/session-actions.ts +0 -187
  216. package/src/cli/client/workspace-resolver.ts +0 -110
  217. package/src/cli/commands/workspace/close.ts +0 -32
  218. package/src/cli/commands/workspace/show.ts +0 -32
  219. package/src/cli/commands/worktree/create.ts +0 -55
  220. package/src/cli/commands/worktree/list.ts +0 -68
  221. package/src/cli/commands/worktree/remove.ts +0 -81
  222. package/src/git/worktree-branch-poller.ts +0 -54
  223. package/src/git/worktree-divergence-poller.ts +0 -59
  224. package/src/state/reducers/session-state.ts +0 -262
  225. package/src/state/session-catalog.ts +0 -143
  226. package/src/state/session-worktrees.ts +0 -260
  227. package/src/state/workspace-save.ts +0 -45
  228. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  229. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
  230. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  231. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  232. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  233. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -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,21 +1,92 @@
1
- import type { AppAction, AppState } from '../types'
1
+ import type { AppAction } from '../actions'
2
+ import type { AppState, BarSide, BarState, BarWidget } from '../types'
3
+
4
+ import {
5
+ boundaryDeltaFromRatio,
6
+ clampBarWidth,
7
+ findWidgetBar,
8
+ shiftBoundary,
9
+ visibleWidgets,
10
+ } from '../bars'
11
+
12
+ function withBar(state: AppState, side: BarSide, next: BarState): AppState {
13
+ if (next === state.bars[side]) return state
14
+ return { ...state, bars: { ...state.bars, [side]: next } }
15
+ }
16
+
17
+ function setBarWidth(state: AppState, side: BarSide, width: number): AppState {
18
+ const bar = state.bars[side]
19
+ const next = clampBarWidth(width)
20
+ if (next === bar.width) return state
21
+ return withBar(state, side, { ...bar, width: next })
22
+ }
23
+
24
+ /**
25
+ * Move `widgetId` to `side` at `index`. Covers both cross-bar moves and
26
+ * in-bar reordering — removing then re-inserting is the same operation.
27
+ */
28
+ function moveWidget(state: AppState, widgetId: string, side: BarSide, index: number): AppState {
29
+ const from = findWidgetBar(state.bars, widgetId)
30
+ if (from === null) return state
31
+ const widget = state.bars[from].widgets.find((w) => w.id === widgetId)
32
+ if (!widget) return state
33
+
34
+ const source = state.bars[from].widgets.filter((w) => w.id !== widgetId)
35
+ const target: BarWidget[] = from === side ? source : [...state.bars[side].widgets]
36
+ const at = Math.max(0, Math.min(target.length, index))
37
+ target.splice(at, 0, widget)
38
+
39
+ const bars = { ...state.bars }
40
+ bars[from] = { ...state.bars[from], widgets: from === side ? target : source }
41
+ // Showing a widget in a hidden bar would silently swallow it.
42
+ bars[side] = { ...bars[side], visible: bars[side].visible || widget.visible, widgets: target }
43
+ return { ...state, bars }
44
+ }
2
45
 
3
46
  export function reduceUIState(state: AppState, action: AppAction): AppState | null {
4
47
  switch (action.type) {
5
- case 'toggle-sidebar':
6
- return { ...state, sidebar: { ...state.sidebar, visible: !state.sidebar.visible } }
7
- case 'resize-sidebar': {
8
- const width = Math.min(
9
- state.sidebar.maxWidth,
10
- Math.max(state.sidebar.minWidth, state.sidebar.width + action.delta)
48
+ case 'toggle-bar': {
49
+ const bar = state.bars[action.side]
50
+ return withBar(state, action.side, { ...bar, visible: !bar.visible })
51
+ }
52
+ case 'resize-bar':
53
+ return setBarWidth(state, action.side, state.bars[action.side].width + action.delta)
54
+ case 'set-bar-width':
55
+ return setBarWidth(state, action.side, action.width)
56
+ case 'toggle-widget': {
57
+ const side = findWidgetBar(state.bars, action.widgetId)
58
+ if (side === null) return state
59
+ const bar = state.bars[side]
60
+ const widgets = bar.widgets.map((w) =>
61
+ w.id === action.widgetId ? { ...w, visible: !w.visible } : w
11
62
  )
12
- if (width === state.sidebar.width) return state
13
- return { ...state, sidebar: { ...state.sidebar, width } }
63
+ const revealed = widgets.some((w) => w.id === action.widgetId && w.visible)
64
+ return withBar(state, side, { ...bar, visible: bar.visible || revealed, widgets })
65
+ }
66
+ case 'move-widget':
67
+ return moveWidget(state, action.widgetId, action.side, action.index)
68
+ case 'set-bar-boundary': {
69
+ const bar = state.bars[action.side]
70
+ const delta = boundaryDeltaFromRatio(bar, action.index, action.ratio)
71
+ const widgets = shiftBoundary(bar, action.index, delta)
72
+ if (widgets === bar.widgets) return state
73
+ return withBar(state, action.side, { ...bar, widgets })
14
74
  }
15
- case 'set-sidebar-width': {
16
- const width = Math.min(state.sidebar.maxWidth, Math.max(state.sidebar.minWidth, action.width))
17
- if (width === state.sidebar.width) return state
18
- return { ...state, sidebar: { ...state.sidebar, width } }
75
+ case 'resize-widget': {
76
+ // Keyboard resize: grow/shrink this widget against its neighbour. The
77
+ // widget below owns the boundary above it, so the sign flips there.
78
+ const side = findWidgetBar(state.bars, action.widgetId)
79
+ if (side === null) return state
80
+ const bar = state.bars[side]
81
+ const visible = visibleWidgets(bar)
82
+ const at = visible.findIndex((w) => w.id === action.widgetId)
83
+ if (at === -1) return state
84
+ const total = visible.reduce((sum, w) => sum + w.grow, 0)
85
+ const index = at > 0 ? at - 1 : at
86
+ const deltaGrow = action.delta * total * (at > 0 ? -1 : 1)
87
+ const widgets = shiftBoundary(bar, index, deltaGrow)
88
+ if (widgets === bar.widgets) return state
89
+ return withBar(state, side, { ...bar, widgets })
19
90
  }
20
91
  case 'set-focus-mode':
21
92
  return { ...state, focusMode: action.focusMode }
@@ -29,10 +100,10 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
29
100
  return state
30
101
  }
31
102
  return { ...state, pendingChords: action.chords }
32
- case 'toggle-session-bar':
103
+ case 'toggle-project-bar':
33
104
  return {
34
105
  ...state,
35
- sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
106
+ projectBar: { ...state.projectBar, visible: !state.projectBar.visible },
36
107
  }
37
108
  default:
38
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