@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,185 +1,127 @@
1
- import type { CliRenderer } from '@opentui/core'
2
-
3
- import {
4
- DEFAULT_EDITOR_ARGS,
5
- getExternalEditorConfig,
6
- isAutoCommitEnabled,
7
- KNOWN_GUI_EDITORS,
8
- } from '@brimveyn/aimux-config'
9
- import { $ } from 'bun'
10
- import { existsSync } from 'node:fs'
11
- import { mkdir } from 'node:fs/promises'
12
- import { dirname, join as joinPath, resolve as resolvePath } from 'node:path'
1
+ import { isAutoCommitEnabled } from '@brimveyn/aimux-config'
13
2
 
14
3
  import type { SideEffect } from '../input/modes/types'
15
- import type { SessionBackend } from '../session-backend/types'
16
- import type { AppAction, AppState, AssistantId, TabSession, WorktreeRecord } from '../state/types'
17
- import type { ThemeId } from '../ui/themes'
4
+ import type { AssistantId, PendingWorkspaceLaunch } from '../state/types'
5
+ import type { SideEffectContext } from './side-effect-context'
18
6
 
19
- import { loadConfig, saveConfig, type WorktreeTemplate, type WorktreeTemplatePane } from '../config'
7
+ import { loadConfig, saveConfig } from '../config'
20
8
  import { logInputDebug } from '../debug/input-log'
21
9
  import { enqueueGitOp } from '../git/command-queue'
22
- import { countDirtyFiles, moveWorktree } from '../git/move-worktree'
23
- import {
24
- createGitWorktree,
25
- deleteGitBranch,
26
- getCurrentBranch,
27
- getHeadSha,
28
- getMainWorktreeRoot,
29
- listGitWorktrees,
30
- listLocalBranches,
31
- pruneGitWorktrees,
32
- removeGitWorktree,
33
- } from '../git/worktree'
34
- import { createPrefixedId } from '../platform/id'
35
- import {
36
- assertSafeAimuxWorktreePath,
37
- isInsideAimuxWorktreeRoot,
38
- makeWorktreePath,
39
- sanitizePathSegment,
40
- } from '../platform/worktree-paths'
41
- import { getProfileConfigDir } from '../profile-paths'
42
- import {
43
- getAllAssistantOptions,
44
- getAssistantOption,
45
- isCommandAvailable,
46
- parseCommand,
47
- } from '../pty/command-registry'
48
- import { appStore } from '../state/app-store'
49
- import { createTerminalBounds } from '../state/layout-resize'
50
- import {
51
- allLeafIds,
52
- computePaneRects,
53
- createLeaf,
54
- getGroupIdForTab,
55
- getTreeForTab,
56
- PANE_BORDER,
57
- type SplitDirection,
58
- splitNode,
59
- } from '../state/layout-tree'
60
- import {
61
- filterAssistants,
62
- filterSessions,
63
- filterSnippets,
64
- getTemplateNoneOffset,
65
- } from '../state/selectors'
66
- import { saveSessionCatalog } from '../state/session-catalog'
67
- import { pruneSnapshotOfWorktree } from '../state/session-persistence'
68
- import {
69
- filterTabsForActiveWorktree,
70
- getActiveWorktree,
71
- getSessionProjectPath,
72
- withActiveWorktree,
73
- } from '../state/session-worktrees'
74
- import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
75
- import { appReducer } from '../state/store'
76
- import { buildTabEntries } from '../state/tab-entries'
77
- import { createDefaultTerminalModes } from '../state/terminal-modes'
10
+ import { countDirtyFiles } from '../git/move-workspace'
11
+ import { getDefaultBranch, listLocalBranches } from '../git/worktree'
12
+ import { assistantAcceptsPromptArg } from '../pty/command-registry'
13
+ import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
14
+ import { saveCurrentProject } from '../state/project-save'
15
+ import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
78
16
  import { toast } from '../state/toast-store'
79
- import { saveCurrentWorkspace } from '../state/workspace-save'
80
17
  import { filterThemeIds } from '../ui/filter-themes'
81
18
  import { scrollGitDiff } from '../ui/git-view-controls'
82
19
  import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
83
- import { triggerAutoCommitNow } from './auto-commit-ref'
84
- import { writeToTab } from './pty-write'
20
+ import { openFileInEditor, openSelectedSnippetSourceInEditor } from './editor-actions'
21
+ import {
22
+ runGenerateAutoCommitNow,
23
+ runGitAction,
24
+ runGitActionAll,
25
+ runGitCommit,
26
+ runGitCommitAuto,
27
+ runGitPush,
28
+ runGitRm,
29
+ } from './git-actions'
30
+ import {
31
+ handleCycleSidebarItem,
32
+ handleSwitchProjectByIndex,
33
+ handleSwitchTabByIndex,
34
+ } from './navigation-actions'
85
35
  import {
86
- handleCreateSessionEffect,
87
- handleDeleteSessionEffect,
88
- handleRenameSessionEffect,
89
- handleSwitchSessionEffect,
36
+ handleCreateProjectEffect,
37
+ handleDeleteProjectEffect,
38
+ handleRenameProjectEffect,
39
+ handleSwitchProjectEffect,
90
40
  restartTabSession,
91
- switchSessionRecords,
92
- } from './session-actions'
41
+ } from './project-actions'
42
+ import { injectPromptWhenReady } from './prompt-injection'
43
+ import { getSelectedAssistantOption, getSelectedProject, getSelectedSnippet } from './selection'
44
+ import {
45
+ changeSelectedSetting,
46
+ commitSettingText,
47
+ confirmSettingsSearch,
48
+ resetSelectedSetting,
49
+ } from './settings-actions'
50
+ import {
51
+ findSetupTab,
52
+ handleAskAgentForSetupScriptEffect,
53
+ handleConfigureSetupScriptEffect,
54
+ handlePromoteSetupTabEffect,
55
+ handleRunSetupEffect,
56
+ handleStopSetupEffect,
57
+ } from './setup-actions'
93
58
  import {
94
59
  handleDeleteSnippetEffect,
95
60
  handleSaveSnippetEditorEffect,
96
61
  pasteSnippetToTab,
97
62
  } from './snippet-actions'
98
-
99
- const STARTUP_GRACE_MS = 5_000
100
- /**
101
- * Delay before injecting a template pane's `send` payload into its PTY.
102
- * Short enough that shells (which print a prompt within ~100 ms) receive the
103
- * command after their prompt is drawn; long enough to clear most PTY init
104
- * races. Not tied to STARTUP_GRACE_MS because that is the assistant timeout,
105
- * not a readiness signal. See review note: a `tab.status === 'running'`
106
- * subscription would be more robust and is the planned follow-up.
107
- */
108
- const TEMPLATE_SEND_DELAY_MS = 600
109
-
110
- export interface SideEffectContext {
111
- state: AppState
112
- dispatch: (action: AppAction) => void
113
- backend: SessionBackend
114
- renderer: CliRenderer
115
- themeId: ThemeId
116
- setThemeId: (id: ThemeId) => void
117
- activeTab: TabSession | undefined
118
- clearIdleTimer: (tabId: string) => void
119
- clearStartupGrace: (tabId: string) => void
120
- startStartupGrace: (tabId: string, timeoutMs: number) => void
121
- getState: () => AppState
122
- getCurrentSessionProjectPath: () => string | undefined
123
- }
124
-
125
- function getSelectedAssistantOption(state: AppState) {
126
- const all = getAllAssistantOptions(state.customCommands)
127
- if (state.modal.type === 'new-tab' && state.modal.selectedAssistantId != null) {
128
- const selectedAssistantId = state.modal.selectedAssistantId
129
- return all.find((entry) => entry.id === selectedAssistantId) ?? getAssistantOption(0)
130
- }
131
- const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
132
- const list = filterAssistants(all, filter)
133
- return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
134
- }
135
-
136
- function handleSessionSelection(ctx: SideEffectContext): void {
63
+ import {
64
+ confirmSplitSelection,
65
+ createTabSession,
66
+ executeSplitPane,
67
+ launchAssistant,
68
+ startExistingTab,
69
+ } from './tab-actions'
70
+ import {
71
+ createAimuxTempWorkspace,
72
+ isForceableWorkspaceDeleteError,
73
+ runDeleteWorkspace,
74
+ runMoveWorkspace,
75
+ } from './workspace-actions'
76
+ import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
77
+
78
+ function handleProjectSelection(ctx: SideEffectContext): void {
137
79
  const { backend, dispatch, state } = ctx
138
- const selectedSession = getSelectedSession(state)
139
- logInputDebug('app.sessionPicker.confirm', {
140
- creatingNew: !selectedSession,
80
+ const selectedProject = getSelectedProject(state)
81
+ logInputDebug('app.projectPicker.confirm', {
82
+ creatingNew: !selectedProject,
141
83
  selectedIndex: state.modal.selectedIndex,
142
- selectedSessionId: selectedSession?.id ?? null,
84
+ selectedProjectId: selectedProject?.id ?? null,
143
85
  })
144
86
 
145
- if (selectedSession) {
146
- handleSwitchSessionEffect(state, backend, dispatch, selectedSession)
87
+ if (selectedProject) {
88
+ handleSwitchProjectEffect(state, backend, dispatch, selectedProject)
147
89
  return
148
90
  }
149
91
 
150
- dispatch({ returnToSessionPicker: true, type: 'open-create-session-modal' })
92
+ dispatch({ returnToProjectPicker: true, type: 'open-create-project-modal' })
151
93
  }
152
94
 
153
- function handleSelectedSessionDelete(ctx: SideEffectContext): void {
95
+ function handleSelectedProjectDelete(ctx: SideEffectContext): void {
154
96
  const { backend, dispatch, state } = ctx
155
- const selectedSession = getSelectedSession(state)
156
- logInputDebug('app.sessionPicker.deleteSelected', {
97
+ const selectedProject = getSelectedProject(state)
98
+ logInputDebug('app.projectPicker.deleteSelected', {
157
99
  selectedIndex: state.modal.selectedIndex,
158
- selectedSessionId: selectedSession?.id ?? null,
100
+ selectedProjectId: selectedProject?.id ?? null,
159
101
  })
160
102
 
161
- if (selectedSession) {
162
- handleDeleteSessionEffect(state, backend, dispatch, selectedSession.id, {
163
- openSessionPicker: true,
103
+ if (selectedProject) {
104
+ handleDeleteProjectEffect(state, backend, dispatch, selectedProject.id, {
105
+ openProjectPicker: true,
164
106
  })
165
107
  }
166
108
  }
167
109
 
168
- function openSelectedSessionRename(ctx: SideEffectContext): void {
110
+ function openSelectedProjectRename(ctx: SideEffectContext): void {
169
111
  const { dispatch, state } = ctx
170
- const selectedSession = getSelectedSession(state)
171
- if (!selectedSession) {
112
+ const selectedProject = getSelectedProject(state)
113
+ if (!selectedProject) {
172
114
  return
173
115
  }
174
116
 
175
- logInputDebug('app.sessionPicker.openRenameModal', {
117
+ logInputDebug('app.projectPicker.openRenameModal', {
176
118
  selectedIndex: state.modal.selectedIndex,
177
- selectedSessionId: selectedSession.id,
119
+ selectedProjectId: selectedProject.id,
178
120
  })
179
121
  dispatch({
180
- initialName: selectedSession.name,
181
- sessionTargetId: selectedSession.id,
182
- type: 'open-session-name-modal',
122
+ initialName: selectedProject.name,
123
+ projectTargetId: selectedProject.id,
124
+ type: 'open-project-name-modal',
183
125
  })
184
126
  }
185
127
 
@@ -265,389 +207,77 @@ function applyThemeEffect(
265
207
  }
266
208
  }
267
209
 
268
- function confirmSplitSelection(ctx: SideEffectContext): void {
269
- const { dispatch, state } = ctx
270
- const option = getSelectedAssistantOption(state)
271
- const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
272
- const customCommand = state.customCommands[option.id]
273
- const tab = createTabSession(
274
- option.id,
275
- customCommand,
276
- state.customCommands,
277
- getActiveWorktree(
278
- state.currentSessionId != null && state.currentSessionId !== ''
279
- ? state.sessions.find((s) => s.id === state.currentSessionId)
280
- : undefined
281
- )?.id
282
- )
283
- dispatch({ type: 'close-modal' })
284
- executeSplitPane(ctx, direction, tab)
285
- dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
286
- }
287
-
288
- function createTabId(): string {
289
- return createPrefixedId('tab')
290
- }
291
-
292
- function getSelectedSession(state: AppState) {
293
- const filter = state.modal.type === 'session-picker' ? state.modal.editBuffer : null
294
- return filterSessions(state.sessions, filter)[state.modal.selectedIndex]
295
- }
296
-
297
- function getSelectedSnippet(state: AppState) {
298
- const filter = state.modal.type === 'snippet-picker' ? state.modal.editBuffer : null
299
- return filterSnippets(state.snippets, filter)[state.modal.selectedIndex]
300
- }
301
-
302
- export function createTabSession(
303
- assistant: AssistantId,
304
- customCommand?: string,
305
- customCommands?: Record<string, string>,
306
- worktreeId?: string
307
- ): TabSession {
308
- const allOptions = getAllAssistantOptions(customCommands ?? {})
309
- const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
310
-
311
- return {
312
- activity: 'idle',
313
- assistant,
314
- buffer: '',
315
- command: customCommand ?? option.command,
316
- id: createTabId(),
317
- status: 'starting',
318
- terminalModes: createDefaultTerminalModes(),
319
- title: option.label,
320
- worktreeId,
321
- }
322
- }
323
-
324
- export function startTabSession(
325
- backend: SessionBackend,
326
- dispatch: (action: AppAction) => void,
327
- clearStartupGrace: (tabId: string) => void,
328
- startStartupGrace: (tabId: string) => void,
329
- tab: Pick<TabSession, 'id' | 'assistant' | 'title' | 'command' | 'worktreeId'>,
330
- cols: number,
331
- rows: number,
332
- cwd?: string,
333
- autoRenameCandidate = true
334
- ): void {
335
- logInputDebug('app.tab.start.request', {
336
- cols,
337
- command: tab.command,
338
- cwd: cwd ?? null,
339
- rows,
340
- tabId: tab.id,
341
- title: tab.title,
342
- worktreeId: tab.worktreeId ?? null,
343
- })
344
- startStartupGrace(tab.id)
345
-
346
- const { args, executable } = parseCommand(tab.command)
347
-
348
- if (!isCommandAvailable(executable)) {
349
- clearStartupGrace(tab.id)
350
- dispatch({
351
- message: `[command not found] ${executable} is not available in PATH.`,
352
- tabId: tab.id,
353
- type: 'set-tab-error',
354
- })
355
- return
356
- }
357
-
358
- backend.createSession({
359
- args,
360
- assistant: tab.assistant,
361
- autoRenameCandidate,
362
- cols,
363
- command: executable,
364
- cwd,
365
- rows,
366
- tabId: tab.id,
367
- title: tab.title,
368
- worktreeId: tab.worktreeId,
369
- })
370
- }
371
-
372
- function getNewTabTargetWorktreeId(state: AppState): string | undefined {
373
- if (
374
- state.modal.type !== 'new-tab' ||
375
- !(state.currentSessionId != null && state.currentSessionId !== '')
376
- ) {
377
- return undefined
378
- }
379
- const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
380
- const index = state.modal.createWorktree
381
- ? state.modal.targetWorktreeIndex
382
- : state.modal.selectedIndex
383
- return session?.worktrees?.[index]?.id
210
+ /**
211
+ * Setup runs concurrently with the agent by design, so say so rather than
212
+ * letting the agent run tests against a half-installed tree and draw the wrong
213
+ * conclusion. Only prefixed when a setup is actually live.
214
+ */
215
+ function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
216
+ const setupRunning = findSetupTab(ctx.getState().tabs, pending.workspaceId)?.status === 'running'
217
+ if (!setupRunning) return pending.prompt
218
+ return `Note: a setup script is currently installing this workspace's dependencies in the background. Wait for it to finish before running builds, tests, or anything that reads installed dependencies.\n\n${pending.prompt}`
384
219
  }
385
220
 
386
- function launchAssistant(
221
+ /**
222
+ * Name the workspace after what its prompt describes, using the assistant the
223
+ * user just picked. Background work that must never block or fail the launch.
224
+ *
225
+ * Always `pending.prompt`, never the setup-annotated variant built for the
226
+ * agent — the note is guidance, not part of what the user asked for.
227
+ */
228
+ function renameWorkspaceFromLaunch(
387
229
  ctx: SideEffectContext,
388
- assistant: AssistantId,
389
- worktreeId?: string
230
+ pending: PendingWorkspaceLaunch,
231
+ assistant: AssistantId
390
232
  ): void {
391
- const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
392
- const customCommand = state.customCommands[assistant]
393
- const tab = createTabSession(
394
- assistant,
395
- customCommand,
396
- state.customCommands,
397
- worktreeId ??
398
- getActiveWorktree(
399
- state.currentSessionId != null && state.currentSessionId !== ''
400
- ? state.sessions.find((s) => s.id === state.currentSessionId)
401
- : undefined
402
- )?.id
403
- )
404
- logInputDebug('app.launchAssistant', {
405
- assistant,
406
- command: tab.command,
407
- tabId: tab.id,
408
- })
409
- dispatch({ tab, type: 'add-tab' })
410
- dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
411
- startTabSession(
412
- backend,
413
- dispatch,
414
- clearStartupGrace,
415
- (tabId) => startStartupGrace(tabId, STARTUP_GRACE_MS),
416
- tab,
417
- state.layout.terminalCols,
418
- state.layout.terminalRows,
419
- getTabProjectPath(ctx, tab)
233
+ const workspace = ctx
234
+ .getState()
235
+ .projects.find((entry) => entry.id === pending.projectId)
236
+ ?.workspaces?.find((entry) => entry.id === pending.workspaceId)
237
+ if (!workspace) return
238
+
239
+ void renameWorkspaceFromPrompt(
240
+ { projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
241
+ {
242
+ applyName: (projectId, workspaceId, patch) =>
243
+ ctx.dispatch({ patch, projectId, type: 'update-workspace-record', workspaceId }),
244
+ }
420
245
  )
421
246
  }
422
247
 
423
- async function launchAssistantInNewWorktree(
248
+ /**
249
+ * The `<C-p>` flow: create a workspace in the current project, then chain into
250
+ * the new-tab modal rather than leaving the user in an empty workspace.
251
+ */
252
+ async function createWorkspaceFromModal(
424
253
  ctx: SideEffectContext,
425
- assistant: AssistantId,
426
- worktreeName: string,
427
- branchName?: string,
428
- sourceWorktreeId?: string,
429
- baseRef?: string,
430
- templateId?: string
254
+ projectId: string,
255
+ params: {
256
+ prompt: string
257
+ baseRef?: string
258
+ }
431
259
  ): Promise<void> {
432
- const sessionId = ctx.state.currentSessionId
433
- if (!(sessionId != null && sessionId !== '')) return
434
- const worktree = await createAimuxTempWorktree(
260
+ // A name derived locally from the prompt, so the sidebar reads right from the
261
+ // first frame. The model-generated one replaces it a few seconds later.
262
+ // The branch is left to `createAimuxTempWorkspace`, which suffixes it with a
263
+ // timestamp: two workspaces started from the same prompt must not collide on
264
+ // the branch name before the model has had a chance to distinguish them.
265
+ const workspace = await createAimuxTempWorkspace(
435
266
  ctx,
436
- sessionId,
437
- worktreeName,
438
- branchName,
439
- baseRef,
440
- sourceWorktreeId
267
+ projectId,
268
+ placeholderWorkspaceName(params.prompt),
269
+ undefined,
270
+ params.baseRef
441
271
  )
442
- if (!worktree) return
443
-
444
- const template =
445
- templateId != null && templateId !== ''
446
- ? ctx.state.worktreeTemplates.find((entry) => entry.id === templateId)
447
- : undefined
272
+ // Undefined means the create was rejected (e.g. branch already checked out);
273
+ // the modal stays open showing the error.
274
+ if (!workspace) return
448
275
 
449
276
  ctx.dispatch({ type: 'close-modal' })
450
-
451
- if (template) {
452
- applyWorktreeTemplate(ctx, template, worktree.id, worktree.path)
453
- ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
454
- return
455
- }
456
-
457
- const customCommand = ctx.state.customCommands[assistant]
458
- const tab = createTabSession(assistant, customCommand, ctx.state.customCommands, worktree.id)
459
- ctx.dispatch({ tab, type: 'add-tab' })
460
- ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
461
- startTabSession(
462
- ctx.backend,
463
- ctx.dispatch,
464
- ctx.clearStartupGrace,
465
- (tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
466
- tab,
467
- ctx.state.layout.terminalCols,
468
- ctx.state.layout.terminalRows,
469
- worktree.path
470
- )
471
- }
472
-
473
- function applyWorktreeTemplate(
474
- ctx: SideEffectContext,
475
- template: WorktreeTemplate,
476
- worktreeId: string,
477
- worktreePath: string
478
- ): void {
479
- let firstTabId: string | null = null
480
-
481
- for (const templateTab of template.tabs) {
482
- const localToTabId = new Map<string, string>()
483
-
484
- for (let i = 0; i < templateTab.panes.length; i++) {
485
- const pane = templateTab.panes[i]
486
- if (!pane) continue
487
- const tab = createPaneTab(ctx, pane, worktreeId)
488
- localToTabId.set(pane.id, tab.id)
489
-
490
- if (i === 0) {
491
- if (firstTabId == null) firstTabId = tab.id
492
- ctx.dispatch({ tab, type: 'add-tab' })
493
- startTabSession(
494
- ctx.backend,
495
- ctx.dispatch,
496
- ctx.clearStartupGrace,
497
- (tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
498
- tab,
499
- ctx.state.layout.terminalCols,
500
- ctx.state.layout.terminalRows,
501
- worktreePath
502
- )
503
- } else {
504
- const splitFromId =
505
- pane.splitFrom != null && pane.splitFrom !== ''
506
- ? localToTabId.get(pane.splitFrom)
507
- : undefined
508
- const direction: SplitDirection = pane.direction ?? 'vertical'
509
- if (splitFromId == null || splitFromId === '') {
510
- logInputDebug('template.splitFrom.unresolved', {
511
- paneId: pane.id,
512
- splitFrom: pane.splitFrom ?? null,
513
- templateId: template.id,
514
- })
515
- continue
516
- }
517
- splitFromTab(ctx, splitFromId, direction, tab, worktreePath)
518
- if (pane.ratio != null) {
519
- const sourceRatio = clampSplitRatio(1 - pane.ratio)
520
- ctx.dispatch({
521
- axis: direction,
522
- ratio: sourceRatio,
523
- tabId: tab.id,
524
- type: 'set-split-ratio',
525
- })
526
- }
527
- }
528
-
529
- if (pane.send != null && pane.send !== '') {
530
- const payload = `${pane.send}\n`
531
- const targetTabId = tab.id
532
- setTimeout(() => {
533
- const latest = ctx.getState()
534
- const latestTab = latest.tabs.find((entry) => entry.id === targetTabId)
535
- writeToTab(ctx.backend, targetTabId, latestTab, payload)
536
- }, TEMPLATE_SEND_DELAY_MS)
537
- }
538
- }
539
- }
540
-
541
- if (firstTabId != null) {
542
- ctx.dispatch({ tabId: firstTabId, type: 'set-active-tab' })
543
- }
544
- }
545
-
546
- function createPaneTab(
547
- ctx: SideEffectContext,
548
- pane: WorktreeTemplatePane,
549
- worktreeId: string
550
- ): TabSession {
551
- // Accept `'shell'` as an alias for the registered `'terminal'` assistant so
552
- // template examples using the more intuitive name don't silently fall back
553
- // to Claude (createTabSession's unknown-id fallback resolves to index 0).
554
- const assistantId = (pane.assistant === 'shell' ? 'terminal' : pane.assistant) as AssistantId
555
- const customCommand = ctx.state.customCommands[assistantId]
556
- return createTabSession(assistantId, customCommand, ctx.state.customCommands, worktreeId)
557
- }
558
-
559
- function splitFromTab(
560
- ctx: SideEffectContext,
561
- baseTabId: string,
562
- direction: SplitDirection,
563
- newTab: TabSession,
564
- cwd?: string
565
- ): void {
566
- ctx.dispatch({ tabId: baseTabId, type: 'set-active-tab' })
567
-
568
- const latest = ctx.getState()
569
- const existingTree = getTreeForTab(latest.layoutTrees, latest.tabGroupMap, baseTabId)
570
- const baseTree = existingTree ?? createLeaf(baseTabId)
571
- const newTree = splitNode(baseTree, baseTabId, direction, newTab.id)
572
- const bounds = createTerminalBounds(latest.layout.terminalCols, latest.layout.terminalRows)
573
- const paneRect = computePaneRects(newTree, bounds).get(newTab.id)
574
-
575
- ctx.dispatch({ direction, newTab, type: 'split-pane' })
576
- startTabSession(
577
- ctx.backend,
578
- ctx.dispatch,
579
- ctx.clearStartupGrace,
580
- (tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
581
- newTab,
582
- Math.max(1, (paneRect?.cols ?? latest.layout.terminalCols) - PANE_BORDER * 2),
583
- Math.max(1, (paneRect?.rows ?? latest.layout.terminalRows) - PANE_BORDER * 2),
584
- cwd
585
- )
586
- }
587
-
588
- function clampSplitRatio(value: number): number {
589
- if (!Number.isFinite(value)) return 0.5
590
- return Math.min(0.85, Math.max(0.15, value))
591
- }
592
-
593
- function getTabProjectPath(
594
- ctx: SideEffectContext,
595
- tab: Pick<TabSession, 'worktreeId'>
596
- ): string | undefined {
597
- const session =
598
- ctx.state.currentSessionId != null && ctx.state.currentSessionId !== ''
599
- ? ctx.state.sessions.find((entry) => entry.id === ctx.state.currentSessionId)
600
- : undefined
601
- if (tab.worktreeId != null && tab.worktreeId !== '') {
602
- const worktree = session?.worktrees?.find((entry) => entry.id === tab.worktreeId)
603
- if (worktree) return worktree.path
604
- }
605
- return ctx.getCurrentSessionProjectPath()
606
- }
607
-
608
- function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
609
- const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
610
- startTabSession(
611
- backend,
612
- dispatch,
613
- clearStartupGrace,
614
- (tabId) => startStartupGrace(tabId, STARTUP_GRACE_MS),
615
- tab,
616
- state.layout.terminalCols,
617
- state.layout.terminalRows,
618
- getTabProjectPath(ctx, tab),
619
- false
620
- )
621
- }
622
-
623
- function executeSplitPane(
624
- ctx: SideEffectContext,
625
- direction: SplitDirection,
626
- tab: TabSession
627
- ): void {
628
- const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
629
- const activeTabId = state.activeTabId
630
- if (!(activeTabId != null && activeTabId !== '')) {
631
- return
632
- }
633
-
634
- const existingTree = getTreeForTab(state.layoutTrees, state.tabGroupMap, activeTabId)
635
- const baseTree = existingTree ?? createLeaf(activeTabId)
636
- const newTree = splitNode(baseTree, activeTabId, direction, tab.id)
637
- const bounds = createTerminalBounds(state.layout.terminalCols, state.layout.terminalRows)
638
- const paneRect = computePaneRects(newTree, bounds).get(tab.id)
639
-
640
- dispatch({ direction, newTab: tab, type: 'split-pane' })
641
- startTabSession(
642
- backend,
643
- dispatch,
644
- clearStartupGrace,
645
- (tabId) => startStartupGrace(tabId, STARTUP_GRACE_MS),
646
- tab,
647
- Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
648
- Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
649
- getTabProjectPath(ctx, tab)
650
- )
277
+ ctx.dispatch({
278
+ pendingWorkspace: { projectId, prompt: params.prompt, workspaceId: workspace.id },
279
+ type: 'open-new-tab-modal',
280
+ })
651
281
  }
652
282
 
653
283
  export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): void {
@@ -655,55 +285,68 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
655
285
 
656
286
  switch (effect.type) {
657
287
  case 'quit': {
658
- saveCurrentWorkspace(effect.state)
288
+ saveCurrentProject(effect.state)
659
289
  void backend.destroy(true)
660
290
  ctx.renderer.destroy()
661
291
  process.exit(0)
662
292
  return
663
293
  }
664
- case 'launch-selected-assistant': {
665
- if (
666
- state.modal.type === 'new-tab' &&
667
- state.modal.step === 'worktree-create' &&
668
- state.worktreeTemplates.length > 0
669
- ) {
670
- dispatch({ type: 'enter-new-tab-template-pick' })
294
+ case 'open-new-tab': {
295
+ // A tab opens wherever the project currently sits, the repo checkout
296
+ // included: a worktree is not free — it starts without `.env`,
297
+ // `node_modules` or anything else untracked — so plenty of repos never
298
+ // want one. `<C-p>` remains the short path to an isolated branch; it is
299
+ // an offer, not a toll gate.
300
+ if (!state.projects.some((entry) => entry.id === state.currentProjectId)) {
301
+ toast.error('Open a project first — <C-g>')
671
302
  return
672
303
  }
673
- const option = getSelectedAssistantOption(state)
674
- if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
675
- const worktreeName = state.modal.worktreeName
676
- const branchName = state.modal.branchName
677
- const baseRef = state.modal.baseRef
678
- const sourceWorktreeId = getNewTabTargetWorktreeId(state)
679
- let templateId: string | undefined
680
- if (state.modal.step === 'template') {
681
- const templateIndex =
682
- state.modal.selectedIndex - getTemplateNoneOffset(state.modal.selectedAssistantId)
683
- if (templateIndex >= 0) {
684
- templateId = state.worktreeTemplates[templateIndex]?.id
685
- }
686
- }
687
- void (async () => {
688
- try {
689
- await enqueueGitOp(async () =>
690
- launchAssistantInNewWorktree(
691
- ctx,
692
- option.id,
693
- worktreeName,
694
- branchName,
695
- sourceWorktreeId,
696
- baseRef !== '' ? baseRef : undefined,
697
- templateId
698
- )
699
- )
700
- } catch (error) {
701
- toast.error(error instanceof Error ? error.message : String(error))
702
- }
703
- })()
704
- return
304
+ dispatch({ type: 'open-new-tab-modal' })
305
+ return
306
+ }
307
+ case 'launch-selected-assistant': {
308
+ const assistant = getSelectedAssistantOption(state).id
309
+ // Chained from `<C-p>`: pin the tab to the workspace just created, hand it
310
+ // the prompt, and name the workspace with the assistant the user picked.
311
+ // Otherwise the tab lands in the project's active workspace, which
312
+ // launchAssistant resolves itself.
313
+ const pending = state.modal.type === 'new-tab' ? state.modal.pendingWorkspace : undefined
314
+ // Normalized to '' so "is there a prompt" is one comparison rather than a
315
+ // null check repeated at each decision below.
316
+ const prompt = pending
317
+ ? buildWorkspacePrompt(ctx, pending)
318
+ : ((state.modal.type === 'new-tab' ? state.modal.pendingPrompt : undefined) ?? '')
319
+
320
+ // Hand the prompt to the CLI at spawn where the CLI takes one. Pasting it
321
+ // into a live TUI works — it is what this flow did — but it means polling
322
+ // for readiness, probing the screen, and retrying. An argv slot has none of
323
+ // those failure modes.
324
+ const atSpawn = prompt !== '' && assistantAcceptsPromptArg(assistant, state.customCommands)
325
+ logInputDebug('app.launchSelectedAssistant', {
326
+ assistant,
327
+ chained: pending != null,
328
+ modal: state.modal.type,
329
+ promptAtSpawn: atSpawn,
330
+ promptLength: prompt.length,
331
+ })
332
+
333
+ const tabId = launchAssistant(
334
+ ctx,
335
+ assistant,
336
+ pending?.workspaceId,
337
+ atSpawn ? [prompt] : undefined
338
+ )
339
+ // Delivery is decided and done here, chained or not: two call sites meant
340
+ // the prompt could be built twice, from two different reads of the store.
341
+ if (prompt !== '' && !atSpawn) {
342
+ void injectPromptWhenReady({
343
+ backend: ctx.backend,
344
+ getState: ctx.getState,
345
+ prompt,
346
+ tabId,
347
+ })
705
348
  }
706
- launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
349
+ if (pending) renameWorkspaceFromLaunch(ctx, pending, assistant)
707
350
  return
708
351
  }
709
352
  case 'edit-selected-assistant': {
@@ -711,37 +354,59 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
711
354
  dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
712
355
  return
713
356
  }
714
- case 'load-new-tab-base-branches': {
357
+ case 'load-create-workspace-base-branches': {
715
358
  void (async () => {
716
- const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
717
- const sourcePath = getActiveWorktree(session)?.path ?? getSessionProjectPath(session)
359
+ const project = state.projects.find((entry) => entry.id === state.currentProjectId)
360
+ const sourcePath = getActiveWorkspace(project)?.path ?? getActiveWorkspacePath(project)
718
361
  if (!(sourcePath != null && sourcePath !== '')) return
719
- const branches = await listLocalBranches(sourcePath)
720
- if (ctx.getState().modal.type !== 'new-tab') return
721
- ctx.dispatch({ branches, type: 'set-new-tab-base-branches' })
362
+ const [branches, defaultBranch] = await Promise.all([
363
+ listLocalBranches(sourcePath),
364
+ getDefaultBranch(sourcePath),
365
+ ])
366
+ if (ctx.getState().modal.type !== 'create-workspace') return
367
+ ctx.dispatch({ branches, defaultBranch, type: 'set-create-workspace-base-branches' })
722
368
  })()
723
369
  return
724
370
  }
725
- case 'confirm-selected-session': {
726
- handleSessionSelection(ctx)
371
+ case 'create-workspace': {
372
+ if (state.modal.type !== 'create-workspace') return
373
+ const projectId = state.currentProjectId
374
+ if (!(projectId != null && projectId !== '')) return
375
+ const { baseRef, prompt } = state.modal
376
+ void (async () => {
377
+ try {
378
+ await enqueueGitOp(async () =>
379
+ createWorkspaceFromModal(ctx, projectId, {
380
+ baseRef: baseRef !== '' ? baseRef : undefined,
381
+ prompt,
382
+ })
383
+ )
384
+ } catch (error) {
385
+ toast.error(error instanceof Error ? error.message : String(error))
386
+ }
387
+ })()
388
+ return
389
+ }
390
+ case 'confirm-selected-project': {
391
+ handleProjectSelection(ctx)
727
392
  return
728
393
  }
729
- case 'delete-selected-session': {
730
- handleSelectedSessionDelete(ctx)
394
+ case 'delete-selected-project': {
395
+ handleSelectedProjectDelete(ctx)
731
396
  return
732
397
  }
733
- case 'delete-session': {
734
- handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
398
+ case 'delete-project': {
399
+ handleDeleteProjectEffect(state, backend, dispatch, effect.projectId)
735
400
  return
736
401
  }
737
- case 'delete-worktree': {
402
+ case 'delete-workspace': {
738
403
  void (async () => {
739
404
  try {
740
405
  await enqueueGitOp(async () =>
741
- runDeleteWorktree(
406
+ runDeleteWorkspace(
742
407
  { ...ctx, state: ctx.getState() },
743
- effect.sessionId,
744
- effect.worktreeId,
408
+ effect.projectId,
409
+ effect.workspaceId,
745
410
  !!(effect.force === true),
746
411
  !!(effect.closeTabs === true)
747
412
  )
@@ -749,46 +414,37 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
749
414
  } catch (error) {
750
415
  const message = error instanceof Error ? error.message : String(error)
751
416
  // Real errors surface as a toast. Recoverable failures (dirty tree,
752
- // active tabs, …) open a confirmation so the user can opt into a
753
- // force-delete: in-place inside the new-tab worktree picker (preserving
754
- // it), or as a standalone modal elsewhere (e.g. the sidebar's "Remove
755
- // worktree").
756
- if (!isForceableWorktreeDeleteError(message)) {
757
- toast.error(`Could not delete worktree: ${message}`)
417
+ // active tabs, …) open the standalone confirmation modal so the user
418
+ // can opt into a force-delete.
419
+ if (!isForceableWorkspaceDeleteError(message)) {
420
+ toast.error(`Could not delete workspace: ${message}`)
758
421
  return
759
422
  }
760
423
  const latest = ctx.getState()
761
- if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
762
- ctx.dispatch({
763
- prompt: { reason: message, worktreeId: effect.worktreeId },
764
- type: 'set-new-tab-worktree-delete-prompt',
765
- })
766
- return
767
- }
768
- const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
769
- const worktree = session?.worktrees?.find((entry) => entry.id === effect.worktreeId)
424
+ const project = latest.projects.find((entry) => entry.id === effect.projectId)
425
+ const workspace = project?.workspaces?.find((entry) => entry.id === effect.workspaceId)
770
426
  ctx.dispatch({
771
427
  closeTabs: effect.closeTabs === true,
772
428
  force: true,
429
+ projectId: effect.projectId,
773
430
  reason: message,
774
- sessionId: effect.sessionId,
775
- type: 'open-worktree-delete-confirm',
776
- worktreeId: effect.worktreeId,
777
- worktreeLabel: worktree?.branch ?? worktree?.name ?? 'this worktree',
431
+ type: 'open-workspace-delete-confirm',
432
+ workspaceId: effect.workspaceId,
433
+ workspaceLabel: workspace?.branch ?? workspace?.name ?? 'this workspace',
778
434
  })
779
435
  }
780
436
  })()
781
437
  return
782
438
  }
783
- case 'move-worktree': {
439
+ case 'move-workspace': {
784
440
  void (async () => {
785
441
  try {
786
442
  await enqueueGitOp(async () =>
787
- runMoveWorktree(
443
+ runMoveWorkspace(
788
444
  { ...ctx, state: ctx.getState() },
789
- effect.sessionId,
790
- effect.sourceWorktreeId,
791
- effect.targetWorktreeId,
445
+ effect.projectId,
446
+ effect.sourceWorkspaceId,
447
+ effect.targetWorkspaceId,
792
448
  effect.deleteSource === true,
793
449
  effect.stashTarget === true,
794
450
  effect.keepConflicts === true
@@ -800,28 +456,28 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
800
456
  })()
801
457
  return
802
458
  }
803
- case 'load-worktree-move-stats': {
459
+ case 'load-workspace-move-stats': {
804
460
  void (async () => {
805
- const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
806
- const worktrees = session?.worktrees ?? []
807
- if (worktrees.length === 0) return
461
+ const project = state.projects.find((entry) => entry.id === state.currentProjectId)
462
+ const workspaces = project?.workspaces ?? []
463
+ if (workspaces.length === 0) return
808
464
  const counts = await Promise.all(
809
- worktrees.map(async (worktree) => [worktree.id, await countDirtyFiles(worktree.path)])
465
+ workspaces.map(async (workspace) => [workspace.id, await countDirtyFiles(workspace.path)])
810
466
  )
811
- if (ctx.getState().modal.type !== 'worktree-move') return
467
+ if (ctx.getState().modal.type !== 'workspace-move') return
812
468
  ctx.dispatch({
813
469
  dirtyFiles: Object.fromEntries(counts),
814
- type: 'set-worktree-move-stats',
470
+ type: 'set-workspace-move-stats',
815
471
  })
816
472
  })()
817
473
  return
818
474
  }
819
- case 'open-rename-selected-session': {
820
- openSelectedSessionRename(ctx)
475
+ case 'open-rename-selected-project': {
476
+ openSelectedProjectRename(ctx)
821
477
  return
822
478
  }
823
- case 'create-session':
824
- handleCreateSessionEffect(state, dispatch, effect.name, effect.projectPath)
479
+ case 'create-project':
480
+ handleCreateProjectEffect(state, dispatch, effect.name, effect.projectPath)
825
481
  return
826
482
  case 'close-tab': {
827
483
  ctx.clearIdleTimer(effect.tabId)
@@ -873,8 +529,8 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
873
529
  applyThemeEffect(effect, ctx)
874
530
  return
875
531
  }
876
- case 'rename-session': {
877
- handleRenameSessionEffect(state.sessions, dispatch, effect.sessionId, effect.name)
532
+ case 'rename-project': {
533
+ handleRenameProjectEffect(state.projects, dispatch, effect.projectId, effect.name)
878
534
  return
879
535
  }
880
536
  case 'rename-tab': {
@@ -898,7 +554,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
898
554
  assistant,
899
555
  customCommand,
900
556
  state.customCommands,
901
- sourceTab?.worktreeId
557
+ sourceTab?.workspaceId
902
558
  )
903
559
  executeSplitPane(ctx, effect.direction, tab)
904
560
  return
@@ -913,62 +569,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
913
569
  }
914
570
  case 'persist-git-diff-mode-ratio': {
915
571
  const config = loadConfig()
916
- const persistedGitPane = config.gitPane
917
- const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
918
- const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
919
- saveConfig({
920
- ...config,
921
- gitPane: {
922
- diffModeRatio: effect.ratio,
923
- embeddedRatio,
924
- fileListMode: persistedGitPane?.fileListMode,
925
- mode: persistedGitPane?.mode ?? 'embedded',
926
- paneRatio,
927
- position: persistedGitPane?.position ?? 'bottom',
928
- treeCompaction: persistedGitPane?.treeCompaction,
929
- visible: persistedGitPane?.visible ?? true,
930
- },
931
- })
572
+ saveConfig({ ...config, gitPane: { ...config.gitPane, diffModeRatio: effect.ratio } })
932
573
  return
933
574
  }
934
575
  case 'persist-git-file-list-mode': {
935
576
  const config = loadConfig()
936
- const persistedGitPane = config.gitPane
937
- const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
938
- const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
939
- saveConfig({
940
- ...config,
941
- gitPane: {
942
- diffModeRatio: persistedGitPane?.diffModeRatio,
943
- embeddedRatio,
944
- fileListMode: effect.mode,
945
- mode: persistedGitPane?.mode ?? 'embedded',
946
- paneRatio,
947
- position: persistedGitPane?.position ?? 'bottom',
948
- treeCompaction: persistedGitPane?.treeCompaction,
949
- visible: persistedGitPane?.visible ?? true,
950
- },
951
- })
577
+ saveConfig({ ...config, gitPane: { ...config.gitPane, fileListMode: effect.mode } })
952
578
  return
953
579
  }
954
580
  case 'persist-git-tree-compaction': {
955
581
  const config = loadConfig()
956
- const persistedGitPane = config.gitPane
957
- const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
958
- const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
959
- saveConfig({
960
- ...config,
961
- gitPane: {
962
- diffModeRatio: persistedGitPane?.diffModeRatio,
963
- embeddedRatio,
964
- fileListMode: persistedGitPane?.fileListMode,
965
- mode: persistedGitPane?.mode ?? 'embedded',
966
- paneRatio,
967
- position: persistedGitPane?.position ?? 'bottom',
968
- treeCompaction: effect.enabled,
969
- visible: persistedGitPane?.visible ?? true,
970
- },
971
- })
582
+ saveConfig({ ...config, gitPane: { ...config.gitPane, treeCompaction: effect.enabled } })
972
583
  return
973
584
  }
974
585
  case 'git-stage': {
@@ -1012,7 +623,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
1012
623
  }
1013
624
  case 'generate-auto-commit-now': {
1014
625
  if (!isAutoCommitEnabled()) return
1015
- void runGenerateAutoCommitNow(ctx, effect.sessionId)
626
+ void runGenerateAutoCommitNow(ctx, effect.projectId)
1016
627
  return
1017
628
  }
1018
629
  case 'git-push': {
@@ -1023,8 +634,8 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
1023
634
  handleConfirmUpdateSelection(ctx)
1024
635
  return
1025
636
  }
1026
- case 'switch-session-by-index': {
1027
- handleSwitchSessionByIndex(ctx, effect.index, effect.worktreeId)
637
+ case 'switch-project-by-index': {
638
+ handleSwitchProjectByIndex(ctx, effect.index, effect.workspaceId)
1028
639
  return
1029
640
  }
1030
641
  case 'cycle-sidebar-item': {
@@ -1055,759 +666,48 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
1055
666
  openSelectedSnippetSourceInEditor(ctx)
1056
667
  return
1057
668
  }
1058
- default:
1059
- effect satisfies never
1060
- }
1061
- }
1062
-
1063
- /**
1064
- * Open the file backing the currently selected snippet in the user's editor.
1065
- * Config-pinned snippets (id starts with `config:`) live in `aimux.config.ts`
1066
- * (or `.js`); user-edited snippets live in `aimux-snippets.json`.
1067
- *
1068
- * On error (no editor, editor not in PATH) the failure is silent: there is no
1069
- * snippet-picker status line. The user can check the debug log.
1070
- */
1071
- function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
1072
- const snippet = getSelectedSnippet(ctx.state)
1073
- if (!snippet) return
1074
-
1075
- const configDir = getProfileConfigDir()
1076
- let absolutePath: string
1077
-
1078
- if (isConfigSnippetId(snippet.id)) {
1079
- const tsPath = joinPath(configDir, 'aimux.config.ts')
1080
- const jsPath = joinPath(configDir, 'aimux.config.js')
1081
- absolutePath = existsSync(jsPath) && !existsSync(tsPath) ? jsPath : tsPath
1082
- } else {
1083
- absolutePath = getSnippetsCatalogPath()
1084
- }
1085
-
1086
- launchEditorOnFile(ctx, absolutePath, configDir, (message) => {
1087
- logInputDebug('snippets.openInEditor.error', { message, path: absolutePath })
1088
- ctx.dispatch({ message, type: 'snippet-picker-set-message' })
1089
- })
1090
- }
1091
-
1092
- function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
1093
- const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
1094
- const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
1095
- if (!(cwd != null && cwd !== '')) {
1096
- ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
1097
- return
1098
- }
1099
- const absolutePath = resolvePath(cwd, relPath)
1100
- launchEditorOnFile(ctx, absolutePath, cwd, (message) =>
1101
- ctx.dispatch({ message, type: 'git-mode-set-message' })
1102
- )
1103
- }
1104
-
1105
- function launchEditorOnFile(
1106
- ctx: SideEffectContext,
1107
- absolutePath: string,
1108
- cwd: string,
1109
- onError: (message: string) => void
1110
- ): void {
1111
- const config = getExternalEditorConfig()
1112
- const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
1113
- if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
1114
- onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
1115
- return
1116
- }
1117
-
1118
- const cmdParts = shellSplit(rawCommand)
1119
- const executable = cmdParts[0]
1120
- if (!(executable != null && executable !== '')) {
1121
- onError('invalid editor command')
1122
- return
1123
- }
1124
- const baseName = executable.split('/').pop() ?? executable
1125
- const extraCmdArgs = cmdParts.slice(1)
1126
-
1127
- const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
1128
-
1129
- const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
1130
- // No line target — let substitution strip `{line}` placeholders so we don't
1131
- // defeat the editor's "restore last cursor position" feature.
1132
- const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
1133
-
1134
- if (!isCommandAvailable(executable)) {
1135
- onError(`editor not found in PATH: ${executable}`)
1136
- return
1137
- }
1138
-
1139
- if (kind === 'gui') {
1140
- spawnDetached(ctx, [executable, ...resolvedArgs], cwd)
1141
- return
1142
- }
1143
-
1144
- if (config.terminal && config.terminal.length > 0) {
1145
- const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
1146
- const argv = config.terminal.map((a) =>
1147
- a.replaceAll('{cmd}', shellCmd).replaceAll('{cwd}', cwd)
1148
- )
1149
- spawnDetached(ctx, argv, cwd)
1150
- return
1151
- }
1152
-
1153
- void openEditorInline(ctx, executable, resolvedArgs, cwd)
1154
- }
1155
-
1156
- /**
1157
- * Substitute `{file}` and `{line}` placeholders in an editor-arg template.
1158
- *
1159
- * When `line` is `undefined` we drop the line bits cleanly so we don't pass a
1160
- * misleading `:1` / `+1` that would defeat the editor's "restore last cursor
1161
- * position" feature:
1162
- * `['--line', '{line}', '{file}']` → `['{file}']`
1163
- * `['+{line}', '{file}']` → `['{file}']`
1164
- * `['-g', '{file}:{line}']` → `['-g', '{file}']`
1165
- * `['{file}:{line}']` → `['{file}']`
1166
- */
1167
- function substituteEditorArgs(template: string[], file: string, line?: string): string[] {
1168
- if (line !== undefined) {
1169
- return template.map((a) => a.replaceAll('{file}', file).replaceAll('{line}', line))
1170
- }
1171
- const out: string[] = []
1172
- for (let i = 0; i < template.length; i++) {
1173
- const arg = template[i] ?? ''
1174
- // Drop a flag immediately followed by a bare `{line}` arg (--line, -line, etc.).
1175
- if (template[i + 1] === '{line}') {
1176
- i++
1177
- continue
669
+ case 'run-setup': {
670
+ handleRunSetupEffect(ctx)
671
+ return
1178
672
  }
1179
- // Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
1180
- if (/^[+:]?\{line\}$/.test(arg)) continue
1181
- // Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
1182
- out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
1183
- }
1184
- return out
1185
- }
1186
-
1187
- function shellQuote(s: string): string {
1188
- return `'${s.replaceAll("'", `'\\''`)}'`
1189
- }
1190
-
1191
- /**
1192
- * Minimal POSIX shell-word splitter — respects single/double quotes and
1193
- * backslash escapes so values like `EDITOR='/Applications/My Editor/bin/code'`
1194
- * or `EDITOR="code --user-data-dir \"/tmp/foo bar\""` tokenize correctly.
1195
- * Does not expand variables or globs.
1196
- */
1197
- function shellSplit(input: string): string[] {
1198
- const out: string[] = []
1199
- let current = ''
1200
- let inSingle = false
1201
- let inDouble = false
1202
- let hasToken = false
1203
- for (let i = 0; i < input.length; i++) {
1204
- const c = input[i] ?? ''
1205
- if (!inSingle && !inDouble && /\s/.test(c)) {
1206
- if (hasToken) {
1207
- out.push(current)
1208
- current = ''
1209
- hasToken = false
1210
- }
1211
- continue
673
+ case 'stop-setup': {
674
+ handleStopSetupEffect(ctx)
675
+ return
1212
676
  }
1213
- hasToken = true
1214
- if (c === "'" && !inDouble) {
1215
- inSingle = !inSingle
1216
- } else if (c === '"' && !inSingle) {
1217
- inDouble = !inDouble
1218
- } else if (c === '\\' && !inSingle && i + 1 < input.length) {
1219
- current += input[++i]
1220
- } else {
1221
- current += c
677
+ case 'configure-setup-script': {
678
+ handleConfigureSetupScriptEffect(ctx, effect.projectId)
679
+ return
1222
680
  }
1223
- }
1224
- if (hasToken) out.push(current)
1225
- return out
1226
- }
1227
-
1228
- function buildShellCmd(cwd: string, executable: string, args: string[]): string {
1229
- const quoted = [executable, ...args].map(shellQuote).join(' ')
1230
- return `cd ${shellQuote(cwd)} && ${quoted}`
1231
- }
1232
-
1233
- function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): void {
1234
- try {
1235
- const child = Bun.spawn(argv, {
1236
- cwd,
1237
- stderr: 'pipe',
1238
- stdin: 'ignore',
1239
- stdout: 'ignore',
1240
- })
1241
- void (async () => {
1242
- const stderr = await new Response(child.stderr).text()
1243
- const code = await child.exited
1244
- if (code !== 0) {
1245
- const firstStderrLine = stderr.trim().split('\n')[0]
1246
- const firstLine =
1247
- firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
1248
- ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
1249
- }
1250
- })()
1251
- child.unref()
1252
- } catch (error) {
1253
- const msg = error instanceof Error ? error.message : 'failed to spawn'
1254
- ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
1255
- }
1256
- }
1257
-
1258
- /**
1259
- * Suspend the opentui renderer, hand the TTY to the editor (inheriting
1260
- * stdin/stdout/stderr), then resume and force a redraw on exit. Matches the
1261
- * shellout pattern used by opencode (packages/opencode/src/cli/cmd/tui/util/editor.ts).
1262
- */
1263
- async function openEditorInline(
1264
- ctx: SideEffectContext,
1265
- executable: string,
1266
- args: string[],
1267
- cwd: string
1268
- ): Promise<void> {
1269
- const { renderer } = ctx
1270
- try {
1271
- renderer.suspend()
1272
- renderer.currentRenderBuffer.clear()
1273
- const proc = Bun.spawn([executable, ...args], {
1274
- cwd,
1275
- stderr: 'inherit',
1276
- stdin: 'inherit',
1277
- stdout: 'inherit',
1278
- })
1279
- await proc.exited
1280
- } catch (error) {
1281
- const msg = error instanceof Error ? error.message : 'failed to spawn editor'
1282
- ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
1283
- } finally {
1284
- renderer.currentRenderBuffer.clear()
1285
- renderer.resume()
1286
- renderer.requestRender()
1287
- }
1288
- }
1289
-
1290
- function handleSwitchSessionByIndex(
1291
- ctx: SideEffectContext,
1292
- index: number,
1293
- worktreeId?: string
1294
- ): void {
1295
- const { backend, dispatch } = ctx
1296
- // Read fresh state. ctx.state is the snapshot from the previous render and
1297
- // lags behind dispatches that happened in the same JS turn.
1298
- const state = ctx.getState()
1299
- const ordered = [...state.sessions].sort(
1300
- (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1301
- )
1302
- const target = ordered[index - 1]
1303
- if (!target) {
1304
- logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
1305
- return
1306
- }
1307
-
1308
- // Resolve which worktree to land on. If the caller passed an explicit
1309
- // `worktreeId` (workspace-row tap → its primary, worktree-row tap → that
1310
- // worktree), honor it; otherwise let the target session keep its persisted
1311
- // activeWorktreeId.
1312
- const resolvedWorktreeId =
1313
- worktreeId != null &&
1314
- worktreeId !== '' &&
1315
- (target.worktrees?.some((w) => w.id === worktreeId) ?? false)
1316
- ? worktreeId
1317
- : undefined
1318
- const needsWorktreeChange =
1319
- resolvedWorktreeId != null && resolvedWorktreeId !== target.activeWorktreeId
1320
-
1321
- if (target.id === state.currentSessionId) {
1322
- if (needsWorktreeChange) {
1323
- dispatch({
1324
- sessionId: target.id,
1325
- type: 'set-active-worktree',
1326
- worktreeId: resolvedWorktreeId,
1327
- })
681
+ case 'ask-agent-for-setup-script': {
682
+ handleAskAgentForSetupScriptEffect(ctx)
683
+ return
1328
684
  }
1329
- if (state.focusMode === 'git') {
1330
- dispatch({ type: 'exit-git-mode' })
685
+ case 'promote-setup-tab': {
686
+ handlePromoteSetupTabEffect(ctx)
687
+ return
1331
688
  }
1332
- return
1333
- }
1334
-
1335
- // Cross-workspace: bundle the worktree change into the session record AND
1336
- // fold set-sessions + load-session into a SINGLE setState call. Otherwise
1337
- // any subscriber notification (re-render, useEffect, backend re-attach)
1338
- // between dispatches can re-assert the session's previously-persisted
1339
- // activeWorktreeId, dropping the user back on the last-visited worktree.
1340
- const patchedSession = needsWorktreeChange
1341
- ? withActiveWorktree(target, resolvedWorktreeId)
1342
- : target
1343
- const patchedState: AppState = needsWorktreeChange
1344
- ? {
1345
- ...state,
1346
- sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1347
- }
1348
- : state
1349
- const sessions = switchSessionRecords(patchedState, patchedSession)
1350
- saveSessionCatalog(sessions)
1351
- void backend.destroy(true)
1352
- appStore.setState((current) => {
1353
- const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1354
- return appReducer(afterSet, {
1355
- forceDisconnected: false,
1356
- sessionId: patchedSession.id,
1357
- type: 'load-session',
1358
- workspaceSnapshot: patchedSession.workspaceSnapshot,
1359
- })
1360
- })
1361
- }
1362
-
1363
- interface SidebarItem {
1364
- sessionId: string
1365
- worktreeId: string | null
1366
- }
1367
-
1368
- function buildSidebarItems(state: AppState): SidebarItem[] {
1369
- const ordered = [...state.sessions].sort(
1370
- (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1371
- )
1372
- const items: SidebarItem[] = []
1373
- for (const session of ordered) {
1374
- items.push({ sessionId: session.id, worktreeId: null })
1375
- const worktrees = session.worktrees ?? []
1376
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1377
- for (const wt of worktrees) {
1378
- if (wt.id === primary?.id) continue
1379
- items.push({ sessionId: session.id, worktreeId: wt.id })
689
+ case 'activate-settings-row': {
690
+ changeSelectedSetting(ctx)
691
+ return
1380
692
  }
1381
- }
1382
- return items
1383
- }
1384
-
1385
- function findCurrentSidebarItem(state: AppState, items: SidebarItem[]): number {
1386
- const sessionId = state.currentSessionId
1387
- if (sessionId == null || sessionId === '') return -1
1388
- const session = state.sessions.find((s) => s.id === sessionId)
1389
- const worktrees = session?.worktrees ?? []
1390
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1391
- const activeWtId = session?.activeWorktreeId ?? null
1392
- // The workspace row IS the primary worktree (no separate row), so an active
1393
- // primary or undefined active maps to the workspace-item.
1394
- const targetWorktreeId = activeWtId == null || activeWtId === primary?.id ? null : activeWtId
1395
- return items.findIndex(
1396
- (item) => item.sessionId === sessionId && item.worktreeId === targetWorktreeId
1397
- )
1398
- }
1399
-
1400
- function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void {
1401
- const { backend, dispatch } = ctx
1402
- // Read fresh from the store, not ctx.state (which is a per-render
1403
- // snapshot). Rapid key presses fire before React re-renders, so ctx.state
1404
- // can lag the actual store.
1405
- const state = appStore.getState()
1406
- const items = buildSidebarItems(state)
1407
- if (items.length === 0) return
1408
- const currentIdx = findCurrentSidebarItem(state, items)
1409
- // If we don't know the current, jump to first/last depending on direction.
1410
- let startIdx: number
1411
- if (currentIdx >= 0) {
1412
- startIdx = currentIdx
1413
- } else {
1414
- startIdx = direction === 1 ? -1 : 0
1415
- }
1416
- const len = items.length
1417
- const target = items[(((startIdx + direction) % len) + len) % len]
1418
- if (!target) return
1419
-
1420
- const session = state.sessions.find((s) => s.id === target.sessionId)
1421
- if (!session) return
1422
-
1423
- // Determine the worktree to activate. For workspace-items, that's the
1424
- // primary; for worktree-items, the specific worktree.
1425
- const worktrees = session.worktrees ?? []
1426
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1427
- const targetWorktreeId = target.worktreeId ?? primary?.id
1428
-
1429
- const isCrossWorkspace = session.id !== state.currentSessionId
1430
- const needsWorktreeChange =
1431
- targetWorktreeId != null && targetWorktreeId !== session.activeWorktreeId
1432
-
1433
- if (isCrossWorkspace) {
1434
- // Bundle the worktree change into the session record AND fold the
1435
- // session switch's two dispatches (set-sessions + load-session) into a
1436
- // SINGLE Zustand setState call — otherwise each dispatch fires a
1437
- // separate subscription notification and the @opentui/react reconciler
1438
- // paints an intermediate frame where the new session is current but
1439
- // the old activeWorktreeId still holds, producing the visible flicker.
1440
- const patchedSession = needsWorktreeChange
1441
- ? withActiveWorktree(session, targetWorktreeId)
1442
- : session
1443
- const patchedState: AppState = needsWorktreeChange
1444
- ? {
1445
- ...state,
1446
- sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1447
- }
1448
- : state
1449
- const sessions = switchSessionRecords(patchedState, patchedSession)
1450
- saveSessionCatalog(sessions)
1451
- void backend.destroy(true)
1452
- appStore.setState((current) => {
1453
- const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1454
- return appReducer(afterSet, {
1455
- // Daemon is alive and attach() will hydrate real statuses within a
1456
- // frame, so skip the snapshot's running→disconnected downgrade —
1457
- // otherwise the "Restored snapshot" hint flashes on every j/k cycle.
1458
- forceDisconnected: false,
1459
- sessionId: patchedSession.id,
1460
- type: 'load-session',
1461
- workspaceSnapshot: patchedSession.workspaceSnapshot,
1462
- })
1463
- })
1464
- return
1465
- }
1466
-
1467
- if (needsWorktreeChange) {
1468
- dispatch({
1469
- sessionId: session.id,
1470
- type: 'set-active-worktree',
1471
- worktreeId: targetWorktreeId,
1472
- })
1473
- }
1474
- }
1475
-
1476
- function handleSwitchTabByIndex(ctx: SideEffectContext, index: number): void {
1477
- const { dispatch, state } = ctx
1478
- const currentSession =
1479
- state.currentSessionId != null && state.currentSessionId !== ''
1480
- ? state.sessions.find((s) => s.id === state.currentSessionId)
1481
- : undefined
1482
- const visible = filterTabsForActiveWorktree(state.tabs, currentSession)
1483
- const entries = buildTabEntries(visible, state.layoutTrees, state.tabGroupMap, state.activeTabId)
1484
- const target = entries[index - 1]
1485
- if (!target) {
1486
- logInputDebug('app.tabBar.switchOutOfRange', { index, total: entries.length })
1487
- return
1488
- }
1489
- const targetTabId = target.kind === 'single' ? target.tab.id : target.activeLeafId
1490
- if (targetTabId === state.activeTabId) return
1491
- dispatch({ tabId: targetTabId, type: 'set-active-tab' })
1492
- }
1493
-
1494
- function replaceSession(
1495
- state: AppState,
1496
- sessionId: string,
1497
- next: (session: AppState['sessions'][number]) => AppState['sessions'][number]
1498
- ): AppState['sessions'] {
1499
- return state.sessions.map((session) => (session.id === sessionId ? next(session) : session))
1500
- }
1501
-
1502
- function handleSwitchWorktree(ctx: SideEffectContext, sessionId: string, worktreeId: string): void {
1503
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1504
- const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1505
- if (!session || !worktree) return
1506
- const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1507
- ...entry,
1508
- activeWorktreeId: worktreeId,
1509
- projectPath: worktree.path,
1510
- updatedAt: new Date().toISOString(),
1511
- }))
1512
- saveSessionCatalog(sessions)
1513
- ctx.dispatch({ sessions, type: 'set-sessions' })
1514
- }
1515
-
1516
- function normalizeBranchName(branch: string | undefined): string | undefined {
1517
- return branch?.replace(/^refs\/heads\//, '').trim()
1518
- }
1519
-
1520
- async function createAimuxTempWorktree(
1521
- ctx: SideEffectContext,
1522
- sessionId: string,
1523
- requestedName?: string,
1524
- requestedBranchName?: string,
1525
- requestedBaseRef?: string,
1526
- sourceWorktreeId?: string
1527
- ): Promise<WorktreeRecord | undefined> {
1528
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1529
- const source =
1530
- session?.worktrees?.find((entry) => entry.id === sourceWorktreeId) ?? getActiveWorktree(session)
1531
- const sourcePath = source?.path ?? getSessionProjectPath(session)
1532
- if (!session || !(sourcePath != null && sourcePath !== '')) return undefined
1533
-
1534
- // Resolve the *main* repo checkout, never the active linked worktree, so the
1535
- // record's repoRoot stays valid after sibling worktrees are deleted.
1536
- const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
1537
- const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
1538
- const baseRef = requestedBaseRef ?? baseBranch
1539
- const worktreeId = createPrefixedId('worktree')
1540
- const trimmedName = requestedName?.trim()
1541
- const worktreeName =
1542
- trimmedName != null && trimmedName !== ''
1543
- ? trimmedName
1544
- : `wt-${sanitizePathSegment(session.name, 12)}`
1545
- const trimmedBranch = requestedBranchName?.trim()
1546
- const branchName =
1547
- trimmedBranch != null && trimmedBranch !== ''
1548
- ? trimmedBranch
1549
- : `aimux/${sanitizePathSegment(worktreeName, 40)}-${Date.now().toString(36)}`
1550
- const targetPath = makeWorktreePath({ repoRoot, worktreeId, worktreeName })
1551
-
1552
- const existingWorktree = (await listGitWorktrees(repoRoot)).find(
1553
- (entry) =>
1554
- entry.prunable !== true &&
1555
- normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
1556
- )
1557
- if (existingWorktree) {
1558
- ctx.dispatch({
1559
- message: `Branch already checked out in another worktree: ${existingWorktree.path}`,
1560
- type: 'set-new-tab-branch-error',
1561
- })
1562
- return undefined
1563
- }
1564
-
1565
- await mkdir(dirname(targetPath), { recursive: true })
1566
- await assertSafeAimuxWorktreePath(targetPath)
1567
- await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
1568
- const now = new Date().toISOString()
1569
-
1570
- const worktree: WorktreeRecord = {
1571
- baseRef,
1572
- branch: branchName,
1573
- commitSha: await getHeadSha(targetPath),
1574
- createdAt: now,
1575
- createdByAimux: true,
1576
- id: worktreeId,
1577
- name: worktreeName,
1578
- path: targetPath,
1579
- repoRoot,
1580
- source: 'aimux-temp',
1581
- updatedAt: now,
1582
- }
1583
- const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1584
- ...entry,
1585
- activeWorktreeId: worktree.id,
1586
- projectPath: worktree.path,
1587
- updatedAt: now,
1588
- worktrees: [...(entry.worktrees ?? []), worktree],
1589
- }))
1590
- saveSessionCatalog(sessions)
1591
- ctx.dispatch({ sessions, type: 'set-sessions' })
1592
- toast.success(`Created worktree ${branchName}`)
1593
- return worktree
1594
- }
1595
-
1596
- // Dispose and close every tab pinned to a worktree (timers, pty session, state).
1597
- function disposeWorktreeTabs(ctx: SideEffectContext, worktreeId: string): void {
1598
- for (const tab of ctx.state.tabs.filter((entry) => entry.worktreeId === worktreeId)) {
1599
- ctx.clearIdleTimer(tab.id)
1600
- ctx.clearStartupGrace(tab.id)
1601
- ctx.backend.disposeSession(tab.id)
1602
- ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
1603
- }
1604
- }
1605
-
1606
- async function runDeleteWorktree(
1607
- ctx: SideEffectContext,
1608
- sessionId: string,
1609
- worktreeId: string,
1610
- force: boolean,
1611
- closeTabs = false
1612
- ): Promise<void> {
1613
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1614
- const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1615
- if (!session) throw new Error('session not found')
1616
- if (!worktree) throw new Error('worktree not found')
1617
- if ((session.worktrees?.length ?? 0) <= 1) throw new Error('at least one worktree must remain')
1618
- if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
1619
-
1620
- const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
1621
- // The active-tabs guard asks the modal user to confirm before closing tabs.
1622
- // `closeTabs` (the sidebar's "Remove worktree") opts into closing them
1623
- // directly without forcing the git removal, so dirty temp worktrees are still
1624
- // protected by the non-force `git worktree remove`.
1625
- if (tabsInWorktree.length > 0 && !force && !closeTabs) {
1626
- throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1627
- }
1628
-
1629
- const repoPath = resolveWorktreeGitDir(session, worktree)
1630
- const isAimuxTemp = worktree.source === 'aimux-temp' && worktree.createdByAimux
1631
- // Drop the throwaway aimux branch alongside the worktree so deleted temp
1632
- // worktrees don't accumulate in the repo or haunt the base picker. Scoped to
1633
- // the `aimux/` namespace (matches the picker filter); best-effort.
1634
- const cleanupAimuxBranch = async (): Promise<void> => {
1635
- const branch = worktree.branch
1636
- if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
1637
- await deleteGitBranch(repoPath, branch)
693
+ case 'adjust-settings-row': {
694
+ changeSelectedSetting(ctx, effect.delta)
695
+ return
1638
696
  }
1639
- }
1640
-
1641
- // Run git ops FIRST. If any throws (dirty worktree, etc.) the catch in the
1642
- // delete-worktree side effect handler re-prompts force or toasts the error —
1643
- // tabs stay open and the row stays in the sidebar, so the UI matches reality
1644
- // instead of leaving tabs closed against a worktree that still exists.
1645
- if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path) && !existsSync(worktree.path)) {
1646
- // The dir vanished but git may still pin the branch to a stale worktree entry.
1647
- await pruneGitWorktrees(repoPath)
1648
- } else if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path)) {
1649
- await assertSafeAimuxWorktreePath(worktree.path)
1650
- await removeGitWorktree({ force, repoPath, targetPath: worktree.path })
1651
- } else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
1652
- throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
1653
- }
1654
-
1655
- await cleanupAimuxBranch()
1656
-
1657
- // Only after git success: close tabs + retire the record. Re-read state so
1658
- // we don't operate on the snapshot captured before the awaits above.
1659
- disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, worktreeId)
1660
- const latest = ctx.getState()
1661
- const latestSession = latest.sessions.find((entry) => entry.id === sessionId)
1662
- if (latestSession) {
1663
- removeWorktreeRecordFromSession({ ...ctx, state: latest }, sessionId, latestSession, worktreeId)
1664
- }
1665
- }
1666
-
1667
- // A worktree's stored repoRoot can point at a sibling worktree that was since
1668
- // deleted. Git commands only need *some* existing worktree of the repo (they
1669
- // share the common git dir), so fall back to the main checkout or any live
1670
- // worktree path rather than letting `git -C` fail on a vanished directory.
1671
- function resolveWorktreeGitDir(
1672
- session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1673
- worktree: WorktreeRecord
1674
- ): string {
1675
- const candidates = [
1676
- session.worktrees?.find((entry) => entry.source === 'primary')?.path,
1677
- worktree.repoRoot,
1678
- ...(session.worktrees ?? [])
1679
- .filter((entry) => entry.id !== worktree.id)
1680
- .map((entry) => entry.path),
1681
- ]
1682
- return candidates.find((path) => path !== undefined && existsSync(path)) ?? worktree.repoRoot
1683
- }
1684
-
1685
- async function runMoveWorktree(
1686
- ctx: SideEffectContext,
1687
- sessionId: string,
1688
- sourceWorktreeId: string,
1689
- targetWorktreeId: string,
1690
- deleteSource: boolean,
1691
- stashTarget: boolean,
1692
- keepConflicts: boolean
1693
- ): Promise<void> {
1694
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1695
- const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
1696
- const target = session?.worktrees?.find((entry) => entry.id === targetWorktreeId)
1697
- if (!session) throw new Error('session not found')
1698
- if (!source || !target) throw new Error('worktree not found')
1699
- if (source.id === target.id) throw new Error('source and target are the same worktree')
1700
- if (source.branch == null || source.branch === '') {
1701
- throw new Error('source worktree has no branch to move')
1702
- }
1703
- if (deleteSource && source.source === 'primary') {
1704
- throw new Error('the primary worktree cannot be deleted')
1705
- }
1706
-
1707
- const sourceLabel = source.branch ?? source.name
1708
- const targetLabel = target.branch ?? target.name
1709
- const result = await moveWorktree({
1710
- keepConflicts,
1711
- sourceBranch: source.branch,
1712
- sourcePath: source.path,
1713
- stashTarget,
1714
- targetPath: target.path,
1715
- })
1716
-
1717
- // Recoverable failures open a confirm dialog carrying retry params; both
1718
- // worktrees are already back in their original state, so confirming simply
1719
- // re-dispatches move-worktree with the matching flag.
1720
- if (result.kind === 'needs-stash' || result.kind === 'conflict') {
1721
- ctx.dispatch({
1722
- deleteSource,
1723
- files: result.files,
1724
- sessionId,
1725
- sourceLabel,
1726
- sourceWorktreeId,
1727
- targetLabel,
1728
- targetWorktreeId,
1729
- type: 'open-worktree-move-confirm',
1730
- variant: result.kind === 'needs-stash' ? 'stash-target' : 'keep-conflicts',
1731
- })
1732
- return
1733
- }
1734
- if (result.kind === 'conflict-kept') {
1735
- // Never delete the source here — its work only landed half-resolved. The
1736
- // auto-commit driver is safe against this state: git refuses to commit
1737
- // with unmerged index entries, so it fails loudly instead of committing
1738
- // conflict markers.
1739
- handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1740
- toast.warning(
1741
- `Left conflict markers in ${targetLabel} (${result.files.length} file(s)) — resolve & commit there; ${sourceLabel} kept`
1742
- )
1743
- return
1744
- }
1745
- if (result.kind === 'error') {
1746
- toast.error(`Move failed: ${result.message}`)
1747
- return
1748
- }
1749
-
1750
- // Land on the target. When deleting the source, close its terminals and
1751
- // switch to the target up front, synchronously, BEFORE the slow
1752
- // `git worktree remove`. Closing the source's active tab re-syncs the active
1753
- // worktree to a default tab (withActiveTabWorktree); doing the close+switch in
1754
- // one batch lands on the target with no intermediate render, so the removal
1755
- // runs with the target already active instead of flashing/sticking to a
1756
- // default worktree. Re-read state each step so we never resurrect the source.
1757
- if (deleteSource) {
1758
- disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, sourceWorktreeId)
1759
- handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1760
- await runDeleteWorktree({ ...ctx, state: ctx.getState() }, sessionId, sourceWorktreeId, true)
1761
- } else {
1762
- handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1763
- }
1764
- const stashNote = result.stashedTarget
1765
- ? ` · target's previous changes stashed (recover with git stash pop)`
1766
- : ''
1767
- toast.success(
1768
- `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit${stashNote}`
1769
- )
1770
- }
1771
-
1772
- function removeWorktreeRecordFromSession(
1773
- ctx: SideEffectContext,
1774
- sessionId: string,
1775
- session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1776
- worktreeId: string
1777
- ): void {
1778
- const remaining = (session.worktrees ?? []).filter((entry) => entry.id !== worktreeId)
1779
- const nextActive =
1780
- session.activeWorktreeId === worktreeId ? remaining[0] : getActiveWorktree(session)
1781
- const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1782
- ...entry,
1783
- activeWorktreeId: nextActive?.id,
1784
- projectPath: nextActive?.path ?? entry.projectPath,
1785
- updatedAt: new Date().toISOString(),
1786
- workspaceSnapshot: pruneSnapshotOfWorktree(entry.workspaceSnapshot, worktreeId),
1787
- worktrees: remaining,
1788
- }))
1789
- saveSessionCatalog(sessions)
1790
- ctx.dispatch({ sessions, type: 'set-sessions' })
1791
- if (ctx.state.modal.type === 'new-tab' && ctx.state.modal.step === 'worktree') {
1792
- ctx.dispatch({
1793
- index: Math.min(ctx.state.modal.selectedIndex, Math.max(0, remaining.length - 1)),
1794
- type: 'set-modal-selection-index',
1795
- })
1796
- }
1797
- ctx.dispatch({ prompt: null, type: 'set-new-tab-worktree-delete-prompt' })
1798
- }
1799
-
1800
- function isForceableWorktreeDeleteError(message: string): boolean {
1801
- return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
1802
- message
1803
- )
1804
- }
1805
-
1806
- class ActiveWorktreeTabsError extends Error {
1807
- constructor(tabCount: number) {
1808
- super(
1809
- `active assistant tabs are using this worktree (${tabCount}) — they will be closed if you confirm.`
1810
- )
697
+ case 'confirm-settings-search': {
698
+ confirmSettingsSearch(ctx)
699
+ return
700
+ }
701
+ case 'reset-settings-row': {
702
+ resetSelectedSetting(ctx)
703
+ return
704
+ }
705
+ case 'commit-setting-text': {
706
+ commitSettingText(ctx, effect.settingId, effect.value)
707
+ return
708
+ }
709
+ default:
710
+ effect satisfies never
1811
711
  }
1812
712
  }
1813
713
 
@@ -1825,7 +725,7 @@ function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
1825
725
  }
1826
726
 
1827
727
  function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
1828
- saveCurrentWorkspace(ctx.state)
728
+ saveCurrentProject(ctx.state)
1829
729
  void ctx.backend.destroy(true)
1830
730
  ctx.renderer.destroy()
1831
731
  process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
@@ -1844,187 +744,3 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
1844
744
  process.exit(code ?? 1)
1845
745
  })()
1846
746
  }
1847
-
1848
- async function runGitAction(
1849
- ctx: SideEffectContext,
1850
- args: string[],
1851
- pathToInvalidate?: string
1852
- ): Promise<void> {
1853
- const fallback = ctx.getCurrentSessionProjectPath()
1854
- const repoPath =
1855
- pathToInvalidate != null && pathToInvalidate !== ''
1856
- ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
1857
- : undefined
1858
- const cwd = repoPath ?? fallback
1859
- if (!(cwd != null && cwd !== '')) return
1860
- const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
1861
- if (result.exitCode !== 0) {
1862
- const stderr = result.stderr.toString().trim()
1863
- ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
1864
- return
1865
- }
1866
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1867
- if (pathToInvalidate != null && pathToInvalidate !== '') {
1868
- ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
1869
- }
1870
- }
1871
-
1872
- async function runGitActionAll(
1873
- ctx: SideEffectContext,
1874
- args: string[],
1875
- pathsToInvalidate: string[]
1876
- ): Promise<void> {
1877
- const cwd = ctx.getCurrentSessionProjectPath()
1878
- if (!(cwd != null && cwd !== '')) return
1879
- const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
1880
- if (result.exitCode !== 0) {
1881
- const stderr = result.stderr.toString().trim()
1882
- ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
1883
- return
1884
- }
1885
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1886
- if (pathsToInvalidate.length > 0) {
1887
- ctx.dispatch({ paths: pathsToInvalidate, type: 'git-mode-invalidate-diffs' })
1888
- }
1889
- }
1890
-
1891
- async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
1892
- const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
1893
- const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
1894
- if (!(cwd != null && cwd !== '')) return
1895
- const absolute = `${cwd}/${path}`
1896
- try {
1897
- const stat = await Bun.file(absolute).stat()
1898
- await (stat.isDirectory()
1899
- ? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
1900
- : Bun.file(absolute).unlink())
1901
- } catch (error) {
1902
- const message = error instanceof Error ? error.message : 'failed to delete file'
1903
- ctx.dispatch({ message, type: 'git-mode-set-message' })
1904
- return
1905
- }
1906
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1907
- ctx.dispatch({ path, type: 'git-mode-clear-diff-cache' })
1908
- }
1909
-
1910
- async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
1911
- const cwd = ctx.getCurrentSessionProjectPath()
1912
- if (!(cwd != null && cwd !== '')) return
1913
- if (!title) {
1914
- ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
1915
- return
1916
- }
1917
- const result = body
1918
- ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
1919
- : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
1920
- if (result.exitCode !== 0) {
1921
- const stderr = result.stderr.toString().trim()
1922
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1923
- toast.error(stderr || 'Commit failed')
1924
- return
1925
- }
1926
- clearAutoCommitForCurrentSession(ctx)
1927
- // Match the push flow: clear any inline git-pane message and surface the
1928
- // result as a toast so it's seen even after leaving git mode.
1929
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1930
- toast.success(`Committed: ${title}`)
1931
- }
1932
-
1933
- async function runGitCommitAuto(
1934
- ctx: SideEffectContext,
1935
- title: string,
1936
- body: string
1937
- ): Promise<void> {
1938
- if (!title) {
1939
- ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
1940
- return
1941
- }
1942
- const cwd = ctx.getCurrentSessionProjectPath()
1943
- if (!(cwd != null && cwd !== '')) return
1944
-
1945
- // If the user has manually staged files, respect that intent and commit
1946
- // only the staged set — don't run `git add -A` which would sweep up
1947
- // unrelated unstaged/untracked changes. With nothing staged, `add -A`
1948
- // keeps the "commit everything" behaviour the user expects from auto-commit.
1949
- const hasStaged = ctx.state.gitPanel.files.some((f) => f.section === 'staged')
1950
- if (!hasStaged) {
1951
- const addArgs = ['add', '-A']
1952
- const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
1953
- if (addResult.exitCode !== 0) {
1954
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1955
- toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
1956
- return
1957
- }
1958
- }
1959
-
1960
- const commitResult = body
1961
- ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
1962
- : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
1963
-
1964
- if (commitResult.exitCode !== 0) {
1965
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1966
- toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
1967
- return
1968
- }
1969
-
1970
- clearAutoCommitForCurrentSession(ctx)
1971
- ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
1972
- }
1973
-
1974
- function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
1975
- const sessionId = ctx.state.currentSessionId
1976
- if (!(sessionId != null && sessionId !== '')) return
1977
- ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1978
- }
1979
-
1980
- async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: string): Promise<void> {
1981
- const session = ctx.state.sessions.find((s) => s.id === sessionId)
1982
- const panel = ctx.state.gitPanel
1983
- if (panel.error !== null) {
1984
- toast.warning('Auto-commit: git panel unavailable')
1985
- ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1986
- return
1987
- }
1988
- const tab = ctx.activeTab
1989
- if (!tab) {
1990
- toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
1991
- ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1992
- return
1993
- }
1994
- await triggerAutoCommitNow({
1995
- assistant: tab.assistant,
1996
- git: {
1997
- ahead: panel.ahead,
1998
- behind: panel.behind,
1999
- branch: panel.branch,
2000
- files: panel.files,
2001
- },
2002
- projectPath: session?.projectPath,
2003
- sessionId,
2004
- tabId: tab.id,
2005
- })
2006
- }
2007
-
2008
- async function runGitPush(ctx: SideEffectContext): Promise<void> {
2009
- const cwd = ctx.getCurrentSessionProjectPath()
2010
- if (!(cwd != null && cwd !== '')) return
2011
- ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
2012
-
2013
- const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
2014
- .quiet()
2015
- .nothrow()
2016
- const hasUpstream = upstream.exitCode === 0
2017
-
2018
- const result = hasUpstream
2019
- ? await $`git -C ${cwd} push`.quiet().nothrow()
2020
- : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
2021
-
2022
- // Clear the inline "pushing…" progress; surface the result as a toast so it's
2023
- // visible even after leaving git mode (and so push failures aren't missed).
2024
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
2025
- if (result.exitCode !== 0) {
2026
- toast.error(result.stderr.toString().trim() || 'Push failed')
2027
- return
2028
- }
2029
- toast.success('Pushed')
2030
- }