@brimveyn/aimux 1.21.0 → 1.22.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -1,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
+ })()
727
388
  return
728
389
  }
729
- case 'delete-selected-session': {
730
- handleSelectedSessionDelete(ctx)
390
+ case 'confirm-selected-project': {
391
+ handleProjectSelection(ctx)
731
392
  return
732
393
  }
733
- case 'delete-session': {
734
- handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
394
+ case 'delete-selected-project': {
395
+ handleSelectedProjectDelete(ctx)
735
396
  return
736
397
  }
737
- case 'delete-worktree': {
398
+ case 'delete-project': {
399
+ handleDeleteProjectEffect(state, backend, dispatch, effect.projectId)
400
+ return
401
+ }
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
@@ -967,7 +623,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
967
623
  }
968
624
  case 'generate-auto-commit-now': {
969
625
  if (!isAutoCommitEnabled()) return
970
- void runGenerateAutoCommitNow(ctx, effect.sessionId)
626
+ void runGenerateAutoCommitNow(ctx, effect.projectId)
971
627
  return
972
628
  }
973
629
  case 'git-push': {
@@ -978,8 +634,8 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
978
634
  handleConfirmUpdateSelection(ctx)
979
635
  return
980
636
  }
981
- case 'switch-session-by-index': {
982
- handleSwitchSessionByIndex(ctx, effect.index, effect.worktreeId)
637
+ case 'switch-project-by-index': {
638
+ handleSwitchProjectByIndex(ctx, effect.index, effect.workspaceId)
983
639
  return
984
640
  }
985
641
  case 'cycle-sidebar-item': {
@@ -1010,759 +666,48 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
1010
666
  openSelectedSnippetSourceInEditor(ctx)
1011
667
  return
1012
668
  }
1013
- default:
1014
- effect satisfies never
1015
- }
1016
- }
1017
-
1018
- /**
1019
- * Open the file backing the currently selected snippet in the user's editor.
1020
- * Config-pinned snippets (id starts with `config:`) live in `aimux.config.ts`
1021
- * (or `.js`); user-edited snippets live in `aimux-snippets.json`.
1022
- *
1023
- * On error (no editor, editor not in PATH) the failure is silent: there is no
1024
- * snippet-picker status line. The user can check the debug log.
1025
- */
1026
- function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
1027
- const snippet = getSelectedSnippet(ctx.state)
1028
- if (!snippet) return
1029
-
1030
- const configDir = getProfileConfigDir()
1031
- let absolutePath: string
1032
-
1033
- if (isConfigSnippetId(snippet.id)) {
1034
- const tsPath = joinPath(configDir, 'aimux.config.ts')
1035
- const jsPath = joinPath(configDir, 'aimux.config.js')
1036
- absolutePath = existsSync(jsPath) && !existsSync(tsPath) ? jsPath : tsPath
1037
- } else {
1038
- absolutePath = getSnippetsCatalogPath()
1039
- }
1040
-
1041
- launchEditorOnFile(ctx, absolutePath, configDir, (message) => {
1042
- logInputDebug('snippets.openInEditor.error', { message, path: absolutePath })
1043
- ctx.dispatch({ message, type: 'snippet-picker-set-message' })
1044
- })
1045
- }
1046
-
1047
- function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
1048
- const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
1049
- const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
1050
- if (!(cwd != null && cwd !== '')) {
1051
- ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
1052
- return
1053
- }
1054
- const absolutePath = resolvePath(cwd, relPath)
1055
- launchEditorOnFile(ctx, absolutePath, cwd, (message) =>
1056
- ctx.dispatch({ message, type: 'git-mode-set-message' })
1057
- )
1058
- }
1059
-
1060
- function launchEditorOnFile(
1061
- ctx: SideEffectContext,
1062
- absolutePath: string,
1063
- cwd: string,
1064
- onError: (message: string) => void
1065
- ): void {
1066
- const config = getExternalEditorConfig()
1067
- const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
1068
- if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
1069
- onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
1070
- return
1071
- }
1072
-
1073
- const cmdParts = shellSplit(rawCommand)
1074
- const executable = cmdParts[0]
1075
- if (!(executable != null && executable !== '')) {
1076
- onError('invalid editor command')
1077
- return
1078
- }
1079
- const baseName = executable.split('/').pop() ?? executable
1080
- const extraCmdArgs = cmdParts.slice(1)
1081
-
1082
- const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
1083
-
1084
- const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
1085
- // No line target — let substitution strip `{line}` placeholders so we don't
1086
- // defeat the editor's "restore last cursor position" feature.
1087
- const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
1088
-
1089
- if (!isCommandAvailable(executable)) {
1090
- onError(`editor not found in PATH: ${executable}`)
1091
- return
1092
- }
1093
-
1094
- if (kind === 'gui') {
1095
- spawnDetached(ctx, [executable, ...resolvedArgs], cwd)
1096
- return
1097
- }
1098
-
1099
- if (config.terminal && config.terminal.length > 0) {
1100
- const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
1101
- const argv = config.terminal.map((a) =>
1102
- a.replaceAll('{cmd}', shellCmd).replaceAll('{cwd}', cwd)
1103
- )
1104
- spawnDetached(ctx, argv, cwd)
1105
- return
1106
- }
1107
-
1108
- void openEditorInline(ctx, executable, resolvedArgs, cwd)
1109
- }
1110
-
1111
- /**
1112
- * Substitute `{file}` and `{line}` placeholders in an editor-arg template.
1113
- *
1114
- * When `line` is `undefined` we drop the line bits cleanly so we don't pass a
1115
- * misleading `:1` / `+1` that would defeat the editor's "restore last cursor
1116
- * position" feature:
1117
- * `['--line', '{line}', '{file}']` → `['{file}']`
1118
- * `['+{line}', '{file}']` → `['{file}']`
1119
- * `['-g', '{file}:{line}']` → `['-g', '{file}']`
1120
- * `['{file}:{line}']` → `['{file}']`
1121
- */
1122
- function substituteEditorArgs(template: string[], file: string, line?: string): string[] {
1123
- if (line !== undefined) {
1124
- return template.map((a) => a.replaceAll('{file}', file).replaceAll('{line}', line))
1125
- }
1126
- const out: string[] = []
1127
- for (let i = 0; i < template.length; i++) {
1128
- const arg = template[i] ?? ''
1129
- // Drop a flag immediately followed by a bare `{line}` arg (--line, -line, etc.).
1130
- if (template[i + 1] === '{line}') {
1131
- i++
1132
- continue
669
+ case 'run-setup': {
670
+ handleRunSetupEffect(ctx)
671
+ return
1133
672
  }
1134
- // Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
1135
- if (/^[+:]?\{line\}$/.test(arg)) continue
1136
- // Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
1137
- out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
1138
- }
1139
- return out
1140
- }
1141
-
1142
- function shellQuote(s: string): string {
1143
- return `'${s.replaceAll("'", `'\\''`)}'`
1144
- }
1145
-
1146
- /**
1147
- * Minimal POSIX shell-word splitter — respects single/double quotes and
1148
- * backslash escapes so values like `EDITOR='/Applications/My Editor/bin/code'`
1149
- * or `EDITOR="code --user-data-dir \"/tmp/foo bar\""` tokenize correctly.
1150
- * Does not expand variables or globs.
1151
- */
1152
- function shellSplit(input: string): string[] {
1153
- const out: string[] = []
1154
- let current = ''
1155
- let inSingle = false
1156
- let inDouble = false
1157
- let hasToken = false
1158
- for (let i = 0; i < input.length; i++) {
1159
- const c = input[i] ?? ''
1160
- if (!inSingle && !inDouble && /\s/.test(c)) {
1161
- if (hasToken) {
1162
- out.push(current)
1163
- current = ''
1164
- hasToken = false
1165
- }
1166
- continue
673
+ case 'stop-setup': {
674
+ handleStopSetupEffect(ctx)
675
+ return
1167
676
  }
1168
- hasToken = true
1169
- if (c === "'" && !inDouble) {
1170
- inSingle = !inSingle
1171
- } else if (c === '"' && !inSingle) {
1172
- inDouble = !inDouble
1173
- } else if (c === '\\' && !inSingle && i + 1 < input.length) {
1174
- current += input[++i]
1175
- } else {
1176
- current += c
677
+ case 'configure-setup-script': {
678
+ handleConfigureSetupScriptEffect(ctx, effect.projectId)
679
+ return
1177
680
  }
1178
- }
1179
- if (hasToken) out.push(current)
1180
- return out
1181
- }
1182
-
1183
- function buildShellCmd(cwd: string, executable: string, args: string[]): string {
1184
- const quoted = [executable, ...args].map(shellQuote).join(' ')
1185
- return `cd ${shellQuote(cwd)} && ${quoted}`
1186
- }
1187
-
1188
- function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): void {
1189
- try {
1190
- const child = Bun.spawn(argv, {
1191
- cwd,
1192
- stderr: 'pipe',
1193
- stdin: 'ignore',
1194
- stdout: 'ignore',
1195
- })
1196
- void (async () => {
1197
- const stderr = await new Response(child.stderr).text()
1198
- const code = await child.exited
1199
- if (code !== 0) {
1200
- const firstStderrLine = stderr.trim().split('\n')[0]
1201
- const firstLine =
1202
- firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
1203
- ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
1204
- }
1205
- })()
1206
- child.unref()
1207
- } catch (error) {
1208
- const msg = error instanceof Error ? error.message : 'failed to spawn'
1209
- ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
1210
- }
1211
- }
1212
-
1213
- /**
1214
- * Suspend the opentui renderer, hand the TTY to the editor (inheriting
1215
- * stdin/stdout/stderr), then resume and force a redraw on exit. Matches the
1216
- * shellout pattern used by opencode (packages/opencode/src/cli/cmd/tui/util/editor.ts).
1217
- */
1218
- async function openEditorInline(
1219
- ctx: SideEffectContext,
1220
- executable: string,
1221
- args: string[],
1222
- cwd: string
1223
- ): Promise<void> {
1224
- const { renderer } = ctx
1225
- try {
1226
- renderer.suspend()
1227
- renderer.currentRenderBuffer.clear()
1228
- const proc = Bun.spawn([executable, ...args], {
1229
- cwd,
1230
- stderr: 'inherit',
1231
- stdin: 'inherit',
1232
- stdout: 'inherit',
1233
- })
1234
- await proc.exited
1235
- } catch (error) {
1236
- const msg = error instanceof Error ? error.message : 'failed to spawn editor'
1237
- ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
1238
- } finally {
1239
- renderer.currentRenderBuffer.clear()
1240
- renderer.resume()
1241
- renderer.requestRender()
1242
- }
1243
- }
1244
-
1245
- function handleSwitchSessionByIndex(
1246
- ctx: SideEffectContext,
1247
- index: number,
1248
- worktreeId?: string
1249
- ): void {
1250
- const { backend, dispatch } = ctx
1251
- // Read fresh state. ctx.state is the snapshot from the previous render and
1252
- // lags behind dispatches that happened in the same JS turn.
1253
- const state = ctx.getState()
1254
- const ordered = [...state.sessions].sort(
1255
- (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1256
- )
1257
- const target = ordered[index - 1]
1258
- if (!target) {
1259
- logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
1260
- return
1261
- }
1262
-
1263
- // Resolve which worktree to land on. If the caller passed an explicit
1264
- // `worktreeId` (workspace-row tap → its primary, worktree-row tap → that
1265
- // worktree), honor it; otherwise let the target session keep its persisted
1266
- // activeWorktreeId.
1267
- const resolvedWorktreeId =
1268
- worktreeId != null &&
1269
- worktreeId !== '' &&
1270
- (target.worktrees?.some((w) => w.id === worktreeId) ?? false)
1271
- ? worktreeId
1272
- : undefined
1273
- const needsWorktreeChange =
1274
- resolvedWorktreeId != null && resolvedWorktreeId !== target.activeWorktreeId
1275
-
1276
- if (target.id === state.currentSessionId) {
1277
- if (needsWorktreeChange) {
1278
- dispatch({
1279
- sessionId: target.id,
1280
- type: 'set-active-worktree',
1281
- worktreeId: resolvedWorktreeId,
1282
- })
681
+ case 'ask-agent-for-setup-script': {
682
+ handleAskAgentForSetupScriptEffect(ctx)
683
+ return
1283
684
  }
1284
- if (state.focusMode === 'git') {
1285
- dispatch({ type: 'exit-git-mode' })
685
+ case 'promote-setup-tab': {
686
+ handlePromoteSetupTabEffect(ctx)
687
+ return
1286
688
  }
1287
- return
1288
- }
1289
-
1290
- // Cross-workspace: bundle the worktree change into the session record AND
1291
- // fold set-sessions + load-session into a SINGLE setState call. Otherwise
1292
- // any subscriber notification (re-render, useEffect, backend re-attach)
1293
- // between dispatches can re-assert the session's previously-persisted
1294
- // activeWorktreeId, dropping the user back on the last-visited worktree.
1295
- const patchedSession = needsWorktreeChange
1296
- ? withActiveWorktree(target, resolvedWorktreeId)
1297
- : target
1298
- const patchedState: AppState = needsWorktreeChange
1299
- ? {
1300
- ...state,
1301
- sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1302
- }
1303
- : state
1304
- const sessions = switchSessionRecords(patchedState, patchedSession)
1305
- saveSessionCatalog(sessions)
1306
- void backend.destroy(true)
1307
- appStore.setState((current) => {
1308
- const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1309
- return appReducer(afterSet, {
1310
- forceDisconnected: false,
1311
- sessionId: patchedSession.id,
1312
- type: 'load-session',
1313
- workspaceSnapshot: patchedSession.workspaceSnapshot,
1314
- })
1315
- })
1316
- }
1317
-
1318
- interface SidebarItem {
1319
- sessionId: string
1320
- worktreeId: string | null
1321
- }
1322
-
1323
- function buildSidebarItems(state: AppState): SidebarItem[] {
1324
- const ordered = [...state.sessions].sort(
1325
- (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1326
- )
1327
- const items: SidebarItem[] = []
1328
- for (const session of ordered) {
1329
- items.push({ sessionId: session.id, worktreeId: null })
1330
- const worktrees = session.worktrees ?? []
1331
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1332
- for (const wt of worktrees) {
1333
- if (wt.id === primary?.id) continue
1334
- items.push({ sessionId: session.id, worktreeId: wt.id })
689
+ case 'activate-settings-row': {
690
+ changeSelectedSetting(ctx)
691
+ return
1335
692
  }
1336
- }
1337
- return items
1338
- }
1339
-
1340
- function findCurrentSidebarItem(state: AppState, items: SidebarItem[]): number {
1341
- const sessionId = state.currentSessionId
1342
- if (sessionId == null || sessionId === '') return -1
1343
- const session = state.sessions.find((s) => s.id === sessionId)
1344
- const worktrees = session?.worktrees ?? []
1345
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1346
- const activeWtId = session?.activeWorktreeId ?? null
1347
- // The workspace row IS the primary worktree (no separate row), so an active
1348
- // primary or undefined active maps to the workspace-item.
1349
- const targetWorktreeId = activeWtId == null || activeWtId === primary?.id ? null : activeWtId
1350
- return items.findIndex(
1351
- (item) => item.sessionId === sessionId && item.worktreeId === targetWorktreeId
1352
- )
1353
- }
1354
-
1355
- function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void {
1356
- const { backend, dispatch } = ctx
1357
- // Read fresh from the store, not ctx.state (which is a per-render
1358
- // snapshot). Rapid key presses fire before React re-renders, so ctx.state
1359
- // can lag the actual store.
1360
- const state = appStore.getState()
1361
- const items = buildSidebarItems(state)
1362
- if (items.length === 0) return
1363
- const currentIdx = findCurrentSidebarItem(state, items)
1364
- // If we don't know the current, jump to first/last depending on direction.
1365
- let startIdx: number
1366
- if (currentIdx >= 0) {
1367
- startIdx = currentIdx
1368
- } else {
1369
- startIdx = direction === 1 ? -1 : 0
1370
- }
1371
- const len = items.length
1372
- const target = items[(((startIdx + direction) % len) + len) % len]
1373
- if (!target) return
1374
-
1375
- const session = state.sessions.find((s) => s.id === target.sessionId)
1376
- if (!session) return
1377
-
1378
- // Determine the worktree to activate. For workspace-items, that's the
1379
- // primary; for worktree-items, the specific worktree.
1380
- const worktrees = session.worktrees ?? []
1381
- const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1382
- const targetWorktreeId = target.worktreeId ?? primary?.id
1383
-
1384
- const isCrossWorkspace = session.id !== state.currentSessionId
1385
- const needsWorktreeChange =
1386
- targetWorktreeId != null && targetWorktreeId !== session.activeWorktreeId
1387
-
1388
- if (isCrossWorkspace) {
1389
- // Bundle the worktree change into the session record AND fold the
1390
- // session switch's two dispatches (set-sessions + load-session) into a
1391
- // SINGLE Zustand setState call — otherwise each dispatch fires a
1392
- // separate subscription notification and the @opentui/react reconciler
1393
- // paints an intermediate frame where the new session is current but
1394
- // the old activeWorktreeId still holds, producing the visible flicker.
1395
- const patchedSession = needsWorktreeChange
1396
- ? withActiveWorktree(session, targetWorktreeId)
1397
- : session
1398
- const patchedState: AppState = needsWorktreeChange
1399
- ? {
1400
- ...state,
1401
- sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1402
- }
1403
- : state
1404
- const sessions = switchSessionRecords(patchedState, patchedSession)
1405
- saveSessionCatalog(sessions)
1406
- void backend.destroy(true)
1407
- appStore.setState((current) => {
1408
- const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1409
- return appReducer(afterSet, {
1410
- // Daemon is alive and attach() will hydrate real statuses within a
1411
- // frame, so skip the snapshot's running→disconnected downgrade —
1412
- // otherwise the "Restored snapshot" hint flashes on every j/k cycle.
1413
- forceDisconnected: false,
1414
- sessionId: patchedSession.id,
1415
- type: 'load-session',
1416
- workspaceSnapshot: patchedSession.workspaceSnapshot,
1417
- })
1418
- })
1419
- return
1420
- }
1421
-
1422
- if (needsWorktreeChange) {
1423
- dispatch({
1424
- sessionId: session.id,
1425
- type: 'set-active-worktree',
1426
- worktreeId: targetWorktreeId,
1427
- })
1428
- }
1429
- }
1430
-
1431
- function handleSwitchTabByIndex(ctx: SideEffectContext, index: number): void {
1432
- const { dispatch, state } = ctx
1433
- const currentSession =
1434
- state.currentSessionId != null && state.currentSessionId !== ''
1435
- ? state.sessions.find((s) => s.id === state.currentSessionId)
1436
- : undefined
1437
- const visible = filterTabsForActiveWorktree(state.tabs, currentSession)
1438
- const entries = buildTabEntries(visible, state.layoutTrees, state.tabGroupMap, state.activeTabId)
1439
- const target = entries[index - 1]
1440
- if (!target) {
1441
- logInputDebug('app.tabBar.switchOutOfRange', { index, total: entries.length })
1442
- return
1443
- }
1444
- const targetTabId = target.kind === 'single' ? target.tab.id : target.activeLeafId
1445
- if (targetTabId === state.activeTabId) return
1446
- dispatch({ tabId: targetTabId, type: 'set-active-tab' })
1447
- }
1448
-
1449
- function replaceSession(
1450
- state: AppState,
1451
- sessionId: string,
1452
- next: (session: AppState['sessions'][number]) => AppState['sessions'][number]
1453
- ): AppState['sessions'] {
1454
- return state.sessions.map((session) => (session.id === sessionId ? next(session) : session))
1455
- }
1456
-
1457
- function handleSwitchWorktree(ctx: SideEffectContext, sessionId: string, worktreeId: string): void {
1458
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1459
- const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1460
- if (!session || !worktree) return
1461
- const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1462
- ...entry,
1463
- activeWorktreeId: worktreeId,
1464
- projectPath: worktree.path,
1465
- updatedAt: new Date().toISOString(),
1466
- }))
1467
- saveSessionCatalog(sessions)
1468
- ctx.dispatch({ sessions, type: 'set-sessions' })
1469
- }
1470
-
1471
- function normalizeBranchName(branch: string | undefined): string | undefined {
1472
- return branch?.replace(/^refs\/heads\//, '').trim()
1473
- }
1474
-
1475
- async function createAimuxTempWorktree(
1476
- ctx: SideEffectContext,
1477
- sessionId: string,
1478
- requestedName?: string,
1479
- requestedBranchName?: string,
1480
- requestedBaseRef?: string,
1481
- sourceWorktreeId?: string
1482
- ): Promise<WorktreeRecord | undefined> {
1483
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1484
- const source =
1485
- session?.worktrees?.find((entry) => entry.id === sourceWorktreeId) ?? getActiveWorktree(session)
1486
- const sourcePath = source?.path ?? getSessionProjectPath(session)
1487
- if (!session || !(sourcePath != null && sourcePath !== '')) return undefined
1488
-
1489
- // Resolve the *main* repo checkout, never the active linked worktree, so the
1490
- // record's repoRoot stays valid after sibling worktrees are deleted.
1491
- const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
1492
- const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
1493
- const baseRef = requestedBaseRef ?? baseBranch
1494
- const worktreeId = createPrefixedId('worktree')
1495
- const trimmedName = requestedName?.trim()
1496
- const worktreeName =
1497
- trimmedName != null && trimmedName !== ''
1498
- ? trimmedName
1499
- : `wt-${sanitizePathSegment(session.name, 12)}`
1500
- const trimmedBranch = requestedBranchName?.trim()
1501
- const branchName =
1502
- trimmedBranch != null && trimmedBranch !== ''
1503
- ? trimmedBranch
1504
- : `aimux/${sanitizePathSegment(worktreeName, 40)}-${Date.now().toString(36)}`
1505
- const targetPath = makeWorktreePath({ repoRoot, worktreeId, worktreeName })
1506
-
1507
- const existingWorktree = (await listGitWorktrees(repoRoot)).find(
1508
- (entry) =>
1509
- entry.prunable !== true &&
1510
- normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
1511
- )
1512
- if (existingWorktree) {
1513
- ctx.dispatch({
1514
- message: `Branch already checked out in another worktree: ${existingWorktree.path}`,
1515
- type: 'set-new-tab-branch-error',
1516
- })
1517
- return undefined
1518
- }
1519
-
1520
- await mkdir(dirname(targetPath), { recursive: true })
1521
- await assertSafeAimuxWorktreePath(targetPath)
1522
- await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
1523
- const now = new Date().toISOString()
1524
-
1525
- const worktree: WorktreeRecord = {
1526
- baseRef,
1527
- branch: branchName,
1528
- commitSha: await getHeadSha(targetPath),
1529
- createdAt: now,
1530
- createdByAimux: true,
1531
- id: worktreeId,
1532
- name: worktreeName,
1533
- path: targetPath,
1534
- repoRoot,
1535
- source: 'aimux-temp',
1536
- updatedAt: now,
1537
- }
1538
- const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1539
- ...entry,
1540
- activeWorktreeId: worktree.id,
1541
- projectPath: worktree.path,
1542
- updatedAt: now,
1543
- worktrees: [...(entry.worktrees ?? []), worktree],
1544
- }))
1545
- saveSessionCatalog(sessions)
1546
- ctx.dispatch({ sessions, type: 'set-sessions' })
1547
- toast.success(`Created worktree ${branchName}`)
1548
- return worktree
1549
- }
1550
-
1551
- // Dispose and close every tab pinned to a worktree (timers, pty session, state).
1552
- function disposeWorktreeTabs(ctx: SideEffectContext, worktreeId: string): void {
1553
- for (const tab of ctx.state.tabs.filter((entry) => entry.worktreeId === worktreeId)) {
1554
- ctx.clearIdleTimer(tab.id)
1555
- ctx.clearStartupGrace(tab.id)
1556
- ctx.backend.disposeSession(tab.id)
1557
- ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
1558
- }
1559
- }
1560
-
1561
- async function runDeleteWorktree(
1562
- ctx: SideEffectContext,
1563
- sessionId: string,
1564
- worktreeId: string,
1565
- force: boolean,
1566
- closeTabs = false
1567
- ): Promise<void> {
1568
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1569
- const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1570
- if (!session) throw new Error('session not found')
1571
- if (!worktree) throw new Error('worktree not found')
1572
- if ((session.worktrees?.length ?? 0) <= 1) throw new Error('at least one worktree must remain')
1573
- if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
1574
-
1575
- const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
1576
- // The active-tabs guard asks the modal user to confirm before closing tabs.
1577
- // `closeTabs` (the sidebar's "Remove worktree") opts into closing them
1578
- // directly without forcing the git removal, so dirty temp worktrees are still
1579
- // protected by the non-force `git worktree remove`.
1580
- if (tabsInWorktree.length > 0 && !force && !closeTabs) {
1581
- throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1582
- }
1583
-
1584
- const repoPath = resolveWorktreeGitDir(session, worktree)
1585
- const isAimuxTemp = worktree.source === 'aimux-temp' && worktree.createdByAimux
1586
- // Drop the throwaway aimux branch alongside the worktree so deleted temp
1587
- // worktrees don't accumulate in the repo or haunt the base picker. Scoped to
1588
- // the `aimux/` namespace (matches the picker filter); best-effort.
1589
- const cleanupAimuxBranch = async (): Promise<void> => {
1590
- const branch = worktree.branch
1591
- if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
1592
- await deleteGitBranch(repoPath, branch)
693
+ case 'adjust-settings-row': {
694
+ changeSelectedSetting(ctx, effect.delta)
695
+ return
1593
696
  }
1594
- }
1595
-
1596
- // Run git ops FIRST. If any throws (dirty worktree, etc.) the catch in the
1597
- // delete-worktree side effect handler re-prompts force or toasts the error —
1598
- // tabs stay open and the row stays in the sidebar, so the UI matches reality
1599
- // instead of leaving tabs closed against a worktree that still exists.
1600
- if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path) && !existsSync(worktree.path)) {
1601
- // The dir vanished but git may still pin the branch to a stale worktree entry.
1602
- await pruneGitWorktrees(repoPath)
1603
- } else if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path)) {
1604
- await assertSafeAimuxWorktreePath(worktree.path)
1605
- await removeGitWorktree({ force, repoPath, targetPath: worktree.path })
1606
- } else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
1607
- throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
1608
- }
1609
-
1610
- await cleanupAimuxBranch()
1611
-
1612
- // Only after git success: close tabs + retire the record. Re-read state so
1613
- // we don't operate on the snapshot captured before the awaits above.
1614
- disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, worktreeId)
1615
- const latest = ctx.getState()
1616
- const latestSession = latest.sessions.find((entry) => entry.id === sessionId)
1617
- if (latestSession) {
1618
- removeWorktreeRecordFromSession({ ...ctx, state: latest }, sessionId, latestSession, worktreeId)
1619
- }
1620
- }
1621
-
1622
- // A worktree's stored repoRoot can point at a sibling worktree that was since
1623
- // deleted. Git commands only need *some* existing worktree of the repo (they
1624
- // share the common git dir), so fall back to the main checkout or any live
1625
- // worktree path rather than letting `git -C` fail on a vanished directory.
1626
- function resolveWorktreeGitDir(
1627
- session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1628
- worktree: WorktreeRecord
1629
- ): string {
1630
- const candidates = [
1631
- session.worktrees?.find((entry) => entry.source === 'primary')?.path,
1632
- worktree.repoRoot,
1633
- ...(session.worktrees ?? [])
1634
- .filter((entry) => entry.id !== worktree.id)
1635
- .map((entry) => entry.path),
1636
- ]
1637
- return candidates.find((path) => path !== undefined && existsSync(path)) ?? worktree.repoRoot
1638
- }
1639
-
1640
- async function runMoveWorktree(
1641
- ctx: SideEffectContext,
1642
- sessionId: string,
1643
- sourceWorktreeId: string,
1644
- targetWorktreeId: string,
1645
- deleteSource: boolean,
1646
- stashTarget: boolean,
1647
- keepConflicts: boolean
1648
- ): Promise<void> {
1649
- const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1650
- const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
1651
- const target = session?.worktrees?.find((entry) => entry.id === targetWorktreeId)
1652
- if (!session) throw new Error('session not found')
1653
- if (!source || !target) throw new Error('worktree not found')
1654
- if (source.id === target.id) throw new Error('source and target are the same worktree')
1655
- if (source.branch == null || source.branch === '') {
1656
- throw new Error('source worktree has no branch to move')
1657
- }
1658
- if (deleteSource && source.source === 'primary') {
1659
- throw new Error('the primary worktree cannot be deleted')
1660
- }
1661
-
1662
- const sourceLabel = source.branch ?? source.name
1663
- const targetLabel = target.branch ?? target.name
1664
- const result = await moveWorktree({
1665
- keepConflicts,
1666
- sourceBranch: source.branch,
1667
- sourcePath: source.path,
1668
- stashTarget,
1669
- targetPath: target.path,
1670
- })
1671
-
1672
- // Recoverable failures open a confirm dialog carrying retry params; both
1673
- // worktrees are already back in their original state, so confirming simply
1674
- // re-dispatches move-worktree with the matching flag.
1675
- if (result.kind === 'needs-stash' || result.kind === 'conflict') {
1676
- ctx.dispatch({
1677
- deleteSource,
1678
- files: result.files,
1679
- sessionId,
1680
- sourceLabel,
1681
- sourceWorktreeId,
1682
- targetLabel,
1683
- targetWorktreeId,
1684
- type: 'open-worktree-move-confirm',
1685
- variant: result.kind === 'needs-stash' ? 'stash-target' : 'keep-conflicts',
1686
- })
1687
- return
1688
- }
1689
- if (result.kind === 'conflict-kept') {
1690
- // Never delete the source here — its work only landed half-resolved. The
1691
- // auto-commit driver is safe against this state: git refuses to commit
1692
- // with unmerged index entries, so it fails loudly instead of committing
1693
- // conflict markers.
1694
- handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1695
- toast.warning(
1696
- `Left conflict markers in ${targetLabel} (${result.files.length} file(s)) — resolve & commit there; ${sourceLabel} kept`
1697
- )
1698
- return
1699
- }
1700
- if (result.kind === 'error') {
1701
- toast.error(`Move failed: ${result.message}`)
1702
- return
1703
- }
1704
-
1705
- // Land on the target. When deleting the source, close its terminals and
1706
- // switch to the target up front, synchronously, BEFORE the slow
1707
- // `git worktree remove`. Closing the source's active tab re-syncs the active
1708
- // worktree to a default tab (withActiveTabWorktree); doing the close+switch in
1709
- // one batch lands on the target with no intermediate render, so the removal
1710
- // runs with the target already active instead of flashing/sticking to a
1711
- // default worktree. Re-read state each step so we never resurrect the source.
1712
- if (deleteSource) {
1713
- disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, sourceWorktreeId)
1714
- handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1715
- await runDeleteWorktree({ ...ctx, state: ctx.getState() }, sessionId, sourceWorktreeId, true)
1716
- } else {
1717
- handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1718
- }
1719
- const stashNote = result.stashedTarget
1720
- ? ` · target's previous changes stashed (recover with git stash pop)`
1721
- : ''
1722
- toast.success(
1723
- `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit${stashNote}`
1724
- )
1725
- }
1726
-
1727
- function removeWorktreeRecordFromSession(
1728
- ctx: SideEffectContext,
1729
- sessionId: string,
1730
- session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1731
- worktreeId: string
1732
- ): void {
1733
- const remaining = (session.worktrees ?? []).filter((entry) => entry.id !== worktreeId)
1734
- const nextActive =
1735
- session.activeWorktreeId === worktreeId ? remaining[0] : getActiveWorktree(session)
1736
- const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1737
- ...entry,
1738
- activeWorktreeId: nextActive?.id,
1739
- projectPath: nextActive?.path ?? entry.projectPath,
1740
- updatedAt: new Date().toISOString(),
1741
- workspaceSnapshot: pruneSnapshotOfWorktree(entry.workspaceSnapshot, worktreeId),
1742
- worktrees: remaining,
1743
- }))
1744
- saveSessionCatalog(sessions)
1745
- ctx.dispatch({ sessions, type: 'set-sessions' })
1746
- if (ctx.state.modal.type === 'new-tab' && ctx.state.modal.step === 'worktree') {
1747
- ctx.dispatch({
1748
- index: Math.min(ctx.state.modal.selectedIndex, Math.max(0, remaining.length - 1)),
1749
- type: 'set-modal-selection-index',
1750
- })
1751
- }
1752
- ctx.dispatch({ prompt: null, type: 'set-new-tab-worktree-delete-prompt' })
1753
- }
1754
-
1755
- function isForceableWorktreeDeleteError(message: string): boolean {
1756
- return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
1757
- message
1758
- )
1759
- }
1760
-
1761
- class ActiveWorktreeTabsError extends Error {
1762
- constructor(tabCount: number) {
1763
- super(
1764
- `active assistant tabs are using this worktree (${tabCount}) — they will be closed if you confirm.`
1765
- )
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
1766
711
  }
1767
712
  }
1768
713
 
@@ -1780,7 +725,7 @@ function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
1780
725
  }
1781
726
 
1782
727
  function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
1783
- saveCurrentWorkspace(ctx.state)
728
+ saveCurrentProject(ctx.state)
1784
729
  void ctx.backend.destroy(true)
1785
730
  ctx.renderer.destroy()
1786
731
  process.stdout.write(`\nUpdating aimux to ${latestVersion}...\n`)
@@ -1799,187 +744,3 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
1799
744
  process.exit(code ?? 1)
1800
745
  })()
1801
746
  }
1802
-
1803
- async function runGitAction(
1804
- ctx: SideEffectContext,
1805
- args: string[],
1806
- pathToInvalidate?: string
1807
- ): Promise<void> {
1808
- const fallback = ctx.getCurrentSessionProjectPath()
1809
- const repoPath =
1810
- pathToInvalidate != null && pathToInvalidate !== ''
1811
- ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
1812
- : undefined
1813
- const cwd = repoPath ?? fallback
1814
- if (!(cwd != null && cwd !== '')) return
1815
- const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
1816
- if (result.exitCode !== 0) {
1817
- const stderr = result.stderr.toString().trim()
1818
- ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
1819
- return
1820
- }
1821
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1822
- if (pathToInvalidate != null && pathToInvalidate !== '') {
1823
- ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
1824
- }
1825
- }
1826
-
1827
- async function runGitActionAll(
1828
- ctx: SideEffectContext,
1829
- args: string[],
1830
- pathsToInvalidate: string[]
1831
- ): Promise<void> {
1832
- const cwd = ctx.getCurrentSessionProjectPath()
1833
- if (!(cwd != null && cwd !== '')) return
1834
- const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
1835
- if (result.exitCode !== 0) {
1836
- const stderr = result.stderr.toString().trim()
1837
- ctx.dispatch({ message: stderr || 'git action failed', type: 'git-mode-set-message' })
1838
- return
1839
- }
1840
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1841
- if (pathsToInvalidate.length > 0) {
1842
- ctx.dispatch({ paths: pathsToInvalidate, type: 'git-mode-invalidate-diffs' })
1843
- }
1844
- }
1845
-
1846
- async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
1847
- const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
1848
- const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
1849
- if (!(cwd != null && cwd !== '')) return
1850
- const absolute = `${cwd}/${path}`
1851
- try {
1852
- const stat = await Bun.file(absolute).stat()
1853
- await (stat.isDirectory()
1854
- ? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
1855
- : Bun.file(absolute).unlink())
1856
- } catch (error) {
1857
- const message = error instanceof Error ? error.message : 'failed to delete file'
1858
- ctx.dispatch({ message, type: 'git-mode-set-message' })
1859
- return
1860
- }
1861
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1862
- ctx.dispatch({ path, type: 'git-mode-clear-diff-cache' })
1863
- }
1864
-
1865
- async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
1866
- const cwd = ctx.getCurrentSessionProjectPath()
1867
- if (!(cwd != null && cwd !== '')) return
1868
- if (!title) {
1869
- ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
1870
- return
1871
- }
1872
- const result = body
1873
- ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
1874
- : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
1875
- if (result.exitCode !== 0) {
1876
- const stderr = result.stderr.toString().trim()
1877
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1878
- toast.error(stderr || 'Commit failed')
1879
- return
1880
- }
1881
- clearAutoCommitForCurrentSession(ctx)
1882
- // Match the push flow: clear any inline git-pane message and surface the
1883
- // result as a toast so it's seen even after leaving git mode.
1884
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1885
- toast.success(`Committed: ${title}`)
1886
- }
1887
-
1888
- async function runGitCommitAuto(
1889
- ctx: SideEffectContext,
1890
- title: string,
1891
- body: string
1892
- ): Promise<void> {
1893
- if (!title) {
1894
- ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
1895
- return
1896
- }
1897
- const cwd = ctx.getCurrentSessionProjectPath()
1898
- if (!(cwd != null && cwd !== '')) return
1899
-
1900
- // If the user has manually staged files, respect that intent and commit
1901
- // only the staged set — don't run `git add -A` which would sweep up
1902
- // unrelated unstaged/untracked changes. With nothing staged, `add -A`
1903
- // keeps the "commit everything" behaviour the user expects from auto-commit.
1904
- const hasStaged = ctx.state.gitPanel.files.some((f) => f.section === 'staged')
1905
- if (!hasStaged) {
1906
- const addArgs = ['add', '-A']
1907
- const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
1908
- if (addResult.exitCode !== 0) {
1909
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1910
- toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
1911
- return
1912
- }
1913
- }
1914
-
1915
- const commitResult = body
1916
- ? await $`git -C ${cwd} commit -m ${title} -m ${body}`.quiet().nothrow()
1917
- : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
1918
-
1919
- if (commitResult.exitCode !== 0) {
1920
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1921
- toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
1922
- return
1923
- }
1924
-
1925
- clearAutoCommitForCurrentSession(ctx)
1926
- ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
1927
- }
1928
-
1929
- function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
1930
- const sessionId = ctx.state.currentSessionId
1931
- if (!(sessionId != null && sessionId !== '')) return
1932
- ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1933
- }
1934
-
1935
- async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: string): Promise<void> {
1936
- const session = ctx.state.sessions.find((s) => s.id === sessionId)
1937
- const panel = ctx.state.gitPanel
1938
- if (panel.error !== null) {
1939
- toast.warning('Auto-commit: git panel unavailable')
1940
- ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1941
- return
1942
- }
1943
- const tab = ctx.activeTab
1944
- if (!tab) {
1945
- toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
1946
- ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1947
- return
1948
- }
1949
- await triggerAutoCommitNow({
1950
- assistant: tab.assistant,
1951
- git: {
1952
- ahead: panel.ahead,
1953
- behind: panel.behind,
1954
- branch: panel.branch,
1955
- files: panel.files,
1956
- },
1957
- projectPath: session?.projectPath,
1958
- sessionId,
1959
- tabId: tab.id,
1960
- })
1961
- }
1962
-
1963
- async function runGitPush(ctx: SideEffectContext): Promise<void> {
1964
- const cwd = ctx.getCurrentSessionProjectPath()
1965
- if (!(cwd != null && cwd !== '')) return
1966
- ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
1967
-
1968
- const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
1969
- .quiet()
1970
- .nothrow()
1971
- const hasUpstream = upstream.exitCode === 0
1972
-
1973
- const result = hasUpstream
1974
- ? await $`git -C ${cwd} push`.quiet().nothrow()
1975
- : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
1976
-
1977
- // Clear the inline "pushing…" progress; surface the result as a toast so it's
1978
- // visible even after leaving git mode (and so push failures aren't missed).
1979
- ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1980
- if (result.exitCode !== 0) {
1981
- toast.error(result.stderr.toString().trim() || 'Push failed')
1982
- return
1983
- }
1984
- toast.success('Pushed')
1985
- }