@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,25 +1,27 @@
1
1
  import type { MutableRefObject } from 'react'
2
2
 
3
3
  import type { SessionBackend } from '../session-backend/types'
4
+ import type { AppAction } from '../state/actions'
4
5
  import type {
5
- AppAction,
6
- SessionStatus,
6
+ ProjectStatus,
7
7
  TabActivity,
8
8
  TabSession,
9
9
  TerminalModeState,
10
- WorktreeRecord,
10
+ WorkspaceRecord,
11
11
  } from '../state/types'
12
12
  import type { TabRuntimeTimeouts } from './tab-runtime-timeouts'
13
13
 
14
14
  import { logInputDebug } from '../debug/input-log'
15
15
  import { clearTabSyntaxState, highlightSnapshot } from '../integrations/claude-syntax-overlay'
16
16
  import { appStore } from '../state/app-store'
17
- import { loadSessionCatalog, saveSessionCatalog } from '../state/session-catalog'
17
+ import { loadProjectCatalog, saveProjectCatalog } from '../state/project-catalog'
18
18
  import {
19
- handleCreateSessionEffect,
20
- handleDeleteSessionEffect,
21
- handleSwitchSessionEffect,
22
- } from './session-actions'
19
+ handleCreateProjectEffect,
20
+ handleDeleteProjectEffect,
21
+ handleSwitchProjectEffect,
22
+ } from './project-actions'
23
+ import { recordSetupExit } from './setup-actions'
24
+ import { forgetTabActivity, recordTabStatus, recordTurnComplete } from './workspace-activity'
23
25
 
24
26
  interface BindBackendRuntimeEventsOptions {
25
27
  backend: SessionBackend
@@ -35,6 +37,7 @@ function clearTabRuntimeState(
35
37
  ): void {
36
38
  timeouts.clearIdleTimer(tabId)
37
39
  timeouts.clearStartupGrace(tabId)
40
+ forgetTabActivity(tabId)
38
41
  }
39
42
 
40
43
  export function bindBackendRuntimeEvents({
@@ -76,6 +79,11 @@ export function bindBackendRuntimeEvents({
76
79
  logInputDebug('app.backend.event.exit', { exitCode, tabId })
77
80
  clearTabRuntimeState(timeouts, tabId)
78
81
  clearTabSyntaxState(tabId)
82
+ // A setup tab is never closed on exit. Closing it would erase the output at
83
+ // the exact moment it matters — a failed setup is what the user needs to
84
+ // read. Record the outcome and leave the dead tab holding its last frame;
85
+ // a re-run, a workspace removal, or a project switch clears it.
86
+ if (recordSetupExit(tabId, exitCode, dispatch)) return
79
87
  dispatch({ tabId, type: 'close-tab' })
80
88
  }
81
89
 
@@ -86,19 +94,27 @@ export function bindBackendRuntimeEvents({
86
94
  dispatch({ message, tabId, type: 'set-tab-error' })
87
95
  }
88
96
 
89
- const handleSessionActivity = (sessionId: string, status: SessionStatus) => {
90
- logInputDebug('app.backend.event.sessionActivity', { sessionId, status })
91
- dispatch({ sessionId, status, type: 'set-session-status' })
97
+ const handleProjectActivity = (projectId: string, status: ProjectStatus) => {
98
+ logInputDebug('app.backend.event.projectActivity', { projectId, status })
99
+ dispatch({ projectId, status, type: 'set-project-status' })
92
100
  }
93
101
 
94
- const handleTabActivity = (tabId: string, activity: TabActivity) => {
95
- logInputDebug('app.backend.event.tabActivity', { activity, tabId })
102
+ const handleTabActivity = (tabId: string, activity: TabActivity, workspaceId?: string) => {
103
+ logInputDebug('app.backend.event.tabActivity', { activity, tabId, workspaceId })
104
+ // Every project's tabs land here, including ones this client doesn't hold —
105
+ // the workspace aggregation wants them, the store ignores them.
106
+ recordTabStatus(tabId, activity, workspaceId)
96
107
  dispatch({ activity, tabId, type: 'set-tab-activity' })
97
108
  }
98
109
 
99
- // Serialise workspace lifecycle handlers behind a small FIFO so back-to-back
110
+ const handleTabTurnComplete = (tabId: string, projectId: string, workspaceId?: string) => {
111
+ logInputDebug('app.backend.event.tabTurnComplete', { projectId, tabId, workspaceId })
112
+ recordTurnComplete(tabId, workspaceId)
113
+ }
114
+
115
+ // Serialise project lifecycle handlers behind a small FIFO so back-to-back
100
116
  // switch/create/close broadcasts from multiple CLIs don't race the
101
- // in-flight `handleSwitchSessionEffect`. Every lifecycle handler awaits the
117
+ // in-flight `handleSwitchProjectEffect`. Every lifecycle handler awaits the
102
118
  // previous chain link before running.
103
119
  let lifecycleChain: Promise<void> = Promise.resolve()
104
120
  const enqueueLifecycle = (fn: () => void | Promise<void>): void => {
@@ -115,130 +131,131 @@ export function bindBackendRuntimeEvents({
115
131
  })()
116
132
  }
117
133
 
118
- const handleWorkspaceCreateRequested = (
134
+ const handleProjectCreateRequested = (
119
135
  name: string,
120
136
  projectPath: string | undefined,
121
137
  doSwitch: boolean
122
138
  ) => {
123
139
  enqueueLifecycle(() => {
124
- logInputDebug('app.backend.event.workspaceCreateRequested', { doSwitch, name, projectPath })
140
+ logInputDebug('app.backend.event.projectCreateRequested', { doSwitch, name, projectPath })
125
141
  const state = appStore.getState()
126
- handleCreateSessionEffect(state, dispatch, name, projectPath)
142
+ handleCreateProjectEffect(state, dispatch, name, projectPath)
127
143
  if (doSwitch) {
128
- // The create dispatched `set-sessions`+`load-session`; the new session
144
+ // The create dispatched `set-projects`+`load-project`; the new project
129
145
  // is now in the catalog + current. Loading already ran, so the switch
130
146
  // is effectively done — just tell the daemon so any --wait CLI exits.
131
147
  const created = appStore
132
148
  .getState()
133
- .sessions.find((s) => s.name === name && s.projectPath === projectPath)
134
- if (created) backend.announceWorkspaceSwitched(created.id)
149
+ .projects.find((s) => s.name === name && s.projectPath === projectPath)
150
+ if (created) backend.announceProjectSwitched(created.id)
135
151
  }
136
152
  })
137
153
  }
138
154
 
139
- const handleWorkspaceSwitchRequested = (targetSessionId: string) => {
155
+ const handleProjectSwitchRequested = (targetProjectId: string) => {
140
156
  enqueueLifecycle(async () => {
141
- logInputDebug('app.backend.event.workspaceSwitchRequested', { targetSessionId })
157
+ logInputDebug('app.backend.event.projectSwitchRequested', { targetProjectId })
142
158
  let state = appStore.getState()
143
- let target = state.sessions.find((s) => s.id === targetSessionId)
159
+ let target = state.projects.find((s) => s.id === targetProjectId)
144
160
  if (!target) {
145
- // In-memory list is stale (e.g. daemon just added a session via the
161
+ // In-memory list is stale (e.g. daemon just added a project via the
146
162
  // headless path). Reload from disk and try once more before giving
147
163
  // up — the daemon already validated the catalog, so this should hit.
148
- const fresh = loadSessionCatalog()
149
- target = fresh.find((s) => s.id === targetSessionId)
164
+ const fresh = loadProjectCatalog()
165
+ target = fresh.find((s) => s.id === targetProjectId)
150
166
  if (target) {
151
- dispatch({ sessions: fresh, type: 'set-sessions' })
167
+ dispatch({ projects: fresh, type: 'set-projects' })
152
168
  state = appStore.getState()
153
169
  }
154
170
  }
155
171
  if (!target) {
156
172
  // Announce anyway so any `--wait` CLI unblocks — better a spurious
157
173
  // exit than a 30s hang while the caller retries.
158
- logInputDebug('app.backend.event.workspaceSwitchRequested.notFound', { targetSessionId })
159
- backend.announceWorkspaceSwitched(targetSessionId)
174
+ logInputDebug('app.backend.event.projectSwitchRequested.notFound', { targetProjectId })
175
+ backend.announceProjectSwitched(targetProjectId)
160
176
  return
161
177
  }
162
- handleSwitchSessionEffect(state, backend, dispatch, target)
178
+ handleSwitchProjectEffect(state, backend, dispatch, target)
163
179
  // Give the reducer a tick to settle before announcing — the switch
164
- // fires `set-sessions`+`load-session` synchronously but the backend
180
+ // fires `set-projects`+`load-project` synchronously but the backend
165
181
  // re-attach happens async.
166
182
  await Promise.resolve()
167
- backend.announceWorkspaceSwitched(targetSessionId)
183
+ backend.announceProjectSwitched(targetProjectId)
168
184
  })
169
185
  }
170
186
 
171
- const handleWorkspaceCloseRequested = (targetSessionId: string) => {
187
+ const handleProjectCloseRequested = (targetProjectId: string) => {
172
188
  enqueueLifecycle(() => {
173
- logInputDebug('app.backend.event.workspaceCloseRequested', { targetSessionId })
189
+ logInputDebug('app.backend.event.projectCloseRequested', { targetProjectId })
174
190
  const state = appStore.getState()
175
- handleDeleteSessionEffect(state, backend, dispatch, targetSessionId)
191
+ handleDeleteProjectEffect(state, backend, dispatch, targetProjectId)
176
192
  })
177
193
  }
178
194
 
179
- const handleWorkspaceSwitched = (sessionId: string) => {
195
+ const handleProjectSwitched = (projectId: string) => {
180
196
  // The daemon's own relay for --wait CLIs. UI side has no work to do —
181
197
  // logging is enough.
182
- logInputDebug('app.backend.event.workspaceSwitched', { sessionId })
198
+ logInputDebug('app.backend.event.projectSwitched', { projectId })
183
199
  }
184
200
 
185
- const handleWorktreeAdded = (sessionId: string, worktree: WorktreeRecord) => {
186
- // Idempotent: skip if a duplicate id is already in the session (the CLI
187
- // may have raced the UI's own worktree-add path).
201
+ const handleWorkspaceAdded = (projectId: string, workspace: WorkspaceRecord) => {
202
+ // Idempotent: skip if a duplicate id is already in the project (the CLI
203
+ // may have raced the UI's own workspace-add path).
188
204
  const current = appStore.getState()
189
- const session = current.sessions.find((s) => s.id === sessionId)
190
- const already = session?.worktrees?.some((w) => w.id === worktree.id) === true
205
+ const project = current.projects.find((s) => s.id === projectId)
206
+ const already = project?.workspaces?.some((w) => w.id === workspace.id) === true
191
207
  if (already) {
192
- logInputDebug('app.backend.event.worktreeAdded.skipDuplicate', {
193
- sessionId,
194
- worktreeId: worktree.id,
208
+ logInputDebug('app.backend.event.workspaceAdded.skipDuplicate', {
209
+ projectId,
210
+ workspaceId: workspace.id,
195
211
  })
196
212
  return
197
213
  }
198
- logInputDebug('app.backend.event.worktreeAdded', {
199
- sessionId,
200
- worktreeId: worktree.id,
214
+ logInputDebug('app.backend.event.workspaceAdded', {
215
+ projectId,
216
+ workspaceId: workspace.id,
201
217
  })
202
- dispatch({ sessionId, type: 'add-worktree-record', worktree })
218
+ dispatch({ projectId, type: 'add-workspace-record', workspace })
203
219
  // Persist the catalog so the change survives restart even if no other
204
220
  // side-effect saves shortly after.
205
- saveSessionCatalog(appStore.getState().sessions)
221
+ saveProjectCatalog(appStore.getState().projects)
206
222
  }
207
223
 
208
- const handleWorktreeRemoved = (sessionId: string, worktreeId: string) => {
224
+ const handleWorkspaceRemoved = (projectId: string, workspaceId: string) => {
209
225
  const current = appStore.getState()
210
- const session = current.sessions.find((s) => s.id === sessionId)
211
- const present = session?.worktrees?.some((w) => w.id === worktreeId) === true
226
+ const project = current.projects.find((s) => s.id === projectId)
227
+ const present = project?.workspaces?.some((w) => w.id === workspaceId) === true
212
228
  if (!present) {
213
- logInputDebug('app.backend.event.worktreeRemoved.skipMissing', { sessionId, worktreeId })
229
+ logInputDebug('app.backend.event.workspaceRemoved.skipMissing', { projectId, workspaceId })
214
230
  return
215
231
  }
216
- logInputDebug('app.backend.event.worktreeRemoved', { sessionId, worktreeId })
217
- // Build the updated sessions list and dispatch via `set-sessions` — this
218
- // matches the pattern used by the existing worktree-delete side-effect
232
+ logInputDebug('app.backend.event.workspaceRemoved', { projectId, workspaceId })
233
+ // Build the updated projects list and dispatch via `set-projects` — this
234
+ // matches the pattern used by the existing workspace-delete side-effect
219
235
  // and centralises the write in one action rather than adding a new one.
220
- const sessions = current.sessions.map((s) => {
221
- if (s.id !== sessionId) return s
222
- const remaining = (s.worktrees ?? []).filter((w) => w.id !== worktreeId)
236
+ const projects = current.projects.map((s) => {
237
+ if (s.id !== projectId) return s
238
+ const remaining = (s.workspaces ?? []).filter((w) => w.id !== workspaceId)
223
239
  return {
224
240
  ...s,
225
- activeWorktreeId: s.activeWorktreeId === worktreeId ? remaining[0]?.id : s.activeWorktreeId,
241
+ activeWorkspaceId:
242
+ s.activeWorkspaceId === workspaceId ? remaining[0]?.id : s.activeWorkspaceId,
226
243
  updatedAt: new Date().toISOString(),
227
- worktrees: remaining,
244
+ workspaces: remaining,
228
245
  }
229
246
  })
230
- saveSessionCatalog(sessions)
231
- dispatch({ sessions, type: 'set-sessions' })
247
+ saveProjectCatalog(projects)
248
+ dispatch({ projects, type: 'set-projects' })
232
249
  }
233
250
 
234
- const handleTabAdded = (sessionId: string, tab: TabSession) => {
251
+ const handleTabAdded = (projectId: string, tab: TabSession) => {
235
252
  // Idempotent: the daemon broadcasts `tabAdded` for every createTab,
236
253
  // including the ones this UI process initiated (which already dispatched
237
254
  // `add-tab` locally). Skip the duplicate so we don't get two entries for
238
255
  // the same tab id.
239
256
  const current = appStore.getState()
240
257
  if (current.tabs.some((t) => t.id === tab.id)) {
241
- logInputDebug('app.backend.event.tabAdded.skipDuplicate', { sessionId, tabId: tab.id })
258
+ logInputDebug('app.backend.event.tabAdded.skipDuplicate', { projectId, tabId: tab.id })
242
259
  dispatch({
243
260
  autoRenameStatus: tab.autoRenameStatus,
244
261
  tabId: tab.id,
@@ -246,56 +263,58 @@ export function bindBackendRuntimeEvents({
246
263
  })
247
264
  return
248
265
  }
249
- if (current.currentSessionId !== sessionId) {
250
- logInputDebug('app.backend.event.tabAdded.skipForeignSession', {
251
- currentSessionId: current.currentSessionId,
252
- sessionId,
266
+ if (current.currentProjectId !== projectId) {
267
+ logInputDebug('app.backend.event.tabAdded.skipForeignProject', {
268
+ currentProjectId: current.currentProjectId,
269
+ projectId,
253
270
  tabId: tab.id,
254
271
  })
255
272
  return
256
273
  }
257
- logInputDebug('app.backend.event.tabAdded', { sessionId, tabId: tab.id })
274
+ logInputDebug('app.backend.event.tabAdded', { projectId, tabId: tab.id })
258
275
  dispatch({ tab, type: 'add-tab' })
259
276
  }
260
277
 
261
278
  const handleTabMetadataUpdated = (
262
- sessionId: string,
279
+ projectId: string,
263
280
  tabId: string,
264
281
  patch: { title?: string; autoRenameStatus?: 'eligible' | 'attempted' }
265
282
  ) => {
266
- if (appStore.getState().currentSessionId !== sessionId) return
283
+ if (appStore.getState().currentProjectId !== projectId) return
267
284
  dispatch({ ...patch, tabId, type: 'update-tab-metadata' })
268
285
  }
269
286
 
270
287
  backend.on('render', handleRender)
271
288
  backend.on('exit', handleExit)
272
289
  backend.on('error', handleError)
273
- backend.on('sessionActivity', handleSessionActivity)
290
+ backend.on('projectActivity', handleProjectActivity)
274
291
  backend.on('tabActivity', handleTabActivity)
292
+ backend.on('tabTurnComplete', handleTabTurnComplete)
275
293
  backend.on('tabAdded', handleTabAdded)
276
294
  backend.on('tabMetadataUpdated', handleTabMetadataUpdated)
277
- backend.on('workspaceCreateRequested', handleWorkspaceCreateRequested)
278
- backend.on('workspaceSwitchRequested', handleWorkspaceSwitchRequested)
279
- backend.on('workspaceCloseRequested', handleWorkspaceCloseRequested)
280
- backend.on('workspaceSwitched', handleWorkspaceSwitched)
281
- backend.on('worktreeAdded', handleWorktreeAdded)
282
- backend.on('worktreeRemoved', handleWorktreeRemoved)
295
+ backend.on('projectCreateRequested', handleProjectCreateRequested)
296
+ backend.on('projectSwitchRequested', handleProjectSwitchRequested)
297
+ backend.on('projectCloseRequested', handleProjectCloseRequested)
298
+ backend.on('projectSwitched', handleProjectSwitched)
299
+ backend.on('workspaceAdded', handleWorkspaceAdded)
300
+ backend.on('workspaceRemoved', handleWorkspaceRemoved)
283
301
 
284
302
  return () => {
285
303
  timeouts.clearAllTimers()
286
304
  backend.off('render', handleRender)
287
305
  backend.off('exit', handleExit)
288
306
  backend.off('error', handleError)
289
- backend.off('sessionActivity', handleSessionActivity)
307
+ backend.off('projectActivity', handleProjectActivity)
290
308
  backend.off('tabActivity', handleTabActivity)
309
+ backend.off('tabTurnComplete', handleTabTurnComplete)
291
310
  backend.off('tabAdded', handleTabAdded)
292
311
  backend.off('tabMetadataUpdated', handleTabMetadataUpdated)
293
- backend.off('workspaceCreateRequested', handleWorkspaceCreateRequested)
294
- backend.off('workspaceSwitchRequested', handleWorkspaceSwitchRequested)
295
- backend.off('workspaceCloseRequested', handleWorkspaceCloseRequested)
296
- backend.off('workspaceSwitched', handleWorkspaceSwitched)
297
- backend.off('worktreeAdded', handleWorktreeAdded)
298
- backend.off('worktreeRemoved', handleWorktreeRemoved)
312
+ backend.off('projectCreateRequested', handleProjectCreateRequested)
313
+ backend.off('projectSwitchRequested', handleProjectSwitchRequested)
314
+ backend.off('projectCloseRequested', handleProjectCloseRequested)
315
+ backend.off('projectSwitched', handleProjectSwitched)
316
+ backend.off('workspaceAdded', handleWorkspaceAdded)
317
+ backend.off('workspaceRemoved', handleWorkspaceRemoved)
299
318
  void backend.destroy(true)
300
319
  }
301
320
  }
@@ -0,0 +1,201 @@
1
+ import {
2
+ DEFAULT_EDITOR_ARGS,
3
+ getExternalEditorConfig,
4
+ KNOWN_GUI_EDITORS,
5
+ } from '@brimveyn/aimux-config'
6
+ import { existsSync } from 'node:fs'
7
+ import { join as joinPath, resolve as resolvePath } from 'node:path'
8
+
9
+ import type { SideEffectContext } from './side-effect-context'
10
+
11
+ import { logInputDebug } from '../debug/input-log'
12
+ import { getProfileConfigDir } from '../profile-paths'
13
+ import { isCommandAvailable, shellQuote, shellSplit } from '../pty/command-registry'
14
+ import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
15
+ import { getSelectedSnippet } from './selection'
16
+
17
+ /**
18
+ * Open the file backing the currently selected snippet in the user's editor.
19
+ * Config-pinned snippets (id starts with `config:`) live in `aimux.config.ts`
20
+ * (or `.js`); user-edited snippets live in `aimux-snippets.json`.
21
+ *
22
+ * On error (no editor, editor not in PATH) the failure is silent: there is no
23
+ * snippet-picker status line. The user can check the debug log.
24
+ */
25
+ export function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
26
+ const snippet = getSelectedSnippet(ctx.state)
27
+ if (!snippet) return
28
+
29
+ const configDir = getProfileConfigDir()
30
+ let absolutePath: string
31
+
32
+ if (isConfigSnippetId(snippet.id)) {
33
+ const tsPath = joinPath(configDir, 'aimux.config.ts')
34
+ const jsPath = joinPath(configDir, 'aimux.config.js')
35
+ absolutePath = existsSync(jsPath) && !existsSync(tsPath) ? jsPath : tsPath
36
+ } else {
37
+ absolutePath = getSnippetsCatalogPath()
38
+ }
39
+
40
+ launchEditorOnFile(ctx, absolutePath, configDir, (message) => {
41
+ logInputDebug('snippets.openInEditor.error', { message, path: absolutePath })
42
+ ctx.dispatch({ message, type: 'snippet-picker-set-message' })
43
+ })
44
+ }
45
+
46
+ export function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
47
+ const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
48
+ const cwd = fileEntry?.repoPath ?? ctx.getCurrentProjectProjectPath()
49
+ if (!(cwd != null && cwd !== '')) {
50
+ ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
51
+ return
52
+ }
53
+ const absolutePath = resolvePath(cwd, relPath)
54
+ launchEditorOnFile(ctx, absolutePath, cwd, (message) =>
55
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
56
+ )
57
+ }
58
+
59
+ export function launchEditorOnFile(
60
+ ctx: SideEffectContext,
61
+ absolutePath: string,
62
+ cwd: string,
63
+ onError: (message: string) => void
64
+ ): void {
65
+ const config = getExternalEditorConfig()
66
+ const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
67
+ if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
68
+ onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
69
+ return
70
+ }
71
+
72
+ const cmdParts = shellSplit(rawCommand)
73
+ const executable = cmdParts[0]
74
+ if (!(executable != null && executable !== '')) {
75
+ onError('invalid editor command')
76
+ return
77
+ }
78
+ const baseName = executable.split('/').pop() ?? executable
79
+ const extraCmdArgs = cmdParts.slice(1)
80
+
81
+ const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
82
+
83
+ const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
84
+ // No line target — let substitution strip `{line}` placeholders so we don't
85
+ // defeat the editor's "restore last cursor position" feature.
86
+ const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
87
+
88
+ if (!isCommandAvailable(executable)) {
89
+ onError(`editor not found in PATH: ${executable}`)
90
+ return
91
+ }
92
+
93
+ if (kind === 'gui') {
94
+ spawnDetached(ctx, [executable, ...resolvedArgs], cwd)
95
+ return
96
+ }
97
+
98
+ if (config.terminal && config.terminal.length > 0) {
99
+ const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
100
+ const argv = config.terminal.map((a) =>
101
+ a.replaceAll('{cmd}', shellCmd).replaceAll('{cwd}', cwd)
102
+ )
103
+ spawnDetached(ctx, argv, cwd)
104
+ return
105
+ }
106
+
107
+ void openEditorInline(ctx, executable, resolvedArgs, cwd)
108
+ }
109
+
110
+ /**
111
+ * Substitute `{file}` and `{line}` placeholders in an editor-arg template.
112
+ *
113
+ * When `line` is `undefined` we drop the line bits cleanly so we don't pass a
114
+ * misleading `:1` / `+1` that would defeat the editor's "restore last cursor
115
+ * position" feature:
116
+ * `['--line', '{line}', '{file}']` → `['{file}']`
117
+ * `['+{line}', '{file}']` → `['{file}']`
118
+ * `['-g', '{file}:{line}']` → `['-g', '{file}']`
119
+ * `['{file}:{line}']` → `['{file}']`
120
+ */
121
+ function substituteEditorArgs(template: string[], file: string, line?: string): string[] {
122
+ if (line !== undefined) {
123
+ return template.map((a) => a.replaceAll('{file}', file).replaceAll('{line}', line))
124
+ }
125
+ const out: string[] = []
126
+ for (let i = 0; i < template.length; i++) {
127
+ const arg = template[i] ?? ''
128
+ // Drop a flag immediately followed by a bare `{line}` arg (--line, -line, etc.).
129
+ if (template[i + 1] === '{line}') {
130
+ i++
131
+ continue
132
+ }
133
+ // Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
134
+ if (/^[+:]?\{line\}$/.test(arg)) continue
135
+ // Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
136
+ out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
137
+ }
138
+ return out
139
+ }
140
+
141
+ function buildShellCmd(cwd: string, executable: string, args: string[]): string {
142
+ const quoted = [executable, ...args].map(shellQuote).join(' ')
143
+ return `cd ${shellQuote(cwd)} && ${quoted}`
144
+ }
145
+
146
+ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): void {
147
+ try {
148
+ const child = Bun.spawn(argv, {
149
+ cwd,
150
+ stderr: 'pipe',
151
+ stdin: 'ignore',
152
+ stdout: 'ignore',
153
+ })
154
+ void (async () => {
155
+ const stderr = await new Response(child.stderr).text()
156
+ const code = await child.exited
157
+ if (code !== 0) {
158
+ const firstStderrLine = stderr.trim().split('\n')[0]
159
+ const firstLine =
160
+ firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
161
+ ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
162
+ }
163
+ })()
164
+ child.unref()
165
+ } catch (error) {
166
+ const msg = error instanceof Error ? error.message : 'failed to spawn'
167
+ ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Suspend the opentui renderer, hand the TTY to the editor (inheriting
173
+ * stdin/stdout/stderr), then resume and force a redraw on exit. Matches the
174
+ * shellout pattern used by opencode (packages/opencode/src/cli/cmd/tui/util/editor.ts).
175
+ */
176
+ async function openEditorInline(
177
+ ctx: SideEffectContext,
178
+ executable: string,
179
+ args: string[],
180
+ cwd: string
181
+ ): Promise<void> {
182
+ const { renderer } = ctx
183
+ try {
184
+ renderer.suspend()
185
+ renderer.currentRenderBuffer.clear()
186
+ const proc = Bun.spawn([executable, ...args], {
187
+ cwd,
188
+ stderr: 'inherit',
189
+ stdin: 'inherit',
190
+ stdout: 'inherit',
191
+ })
192
+ await proc.exited
193
+ } catch (error) {
194
+ const msg = error instanceof Error ? error.message : 'failed to spawn editor'
195
+ ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
196
+ } finally {
197
+ renderer.currentRenderBuffer.clear()
198
+ renderer.resume()
199
+ renderer.requestRender()
200
+ }
201
+ }