@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.
- package/README.md +27 -25
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +22 -27
- package/src/app-runtime/backend-attach-runtime.ts +43 -36
- package/src/app-runtime/backend-runtime-events.ts +107 -88
- package/src/app-runtime/editor-actions.ts +201 -0
- package/src/app-runtime/git-actions.ts +197 -0
- package/src/app-runtime/measure-ref.ts +19 -0
- package/src/app-runtime/navigation-actions.ts +213 -0
- package/src/app-runtime/project-actions.ts +188 -0
- package/src/app-runtime/prompt-injection.ts +123 -0
- package/src/app-runtime/selection.ts +29 -0
- package/src/app-runtime/settings-actions.ts +156 -0
- package/src/app-runtime/setup-actions.ts +237 -0
- package/src/app-runtime/side-effect-context.ts +33 -0
- package/src/app-runtime/side-effects.ts +302 -1541
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/tab-actions.ts +218 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
- package/src/app-runtime/use-auto-commit-driver.ts +14 -13
- package/src/app-runtime/use-backend-runtime.ts +12 -11
- package/src/app-runtime/use-directory-search.ts +5 -4
- package/src/app-runtime/use-mouse-handlers.ts +2 -1
- package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
- package/src/app-runtime/use-renderer-bindings.ts +2 -1
- package/src/app-runtime/use-setup-runner.ts +96 -0
- package/src/app-runtime/use-terminal-resize.ts +18 -14
- package/src/app-runtime/workspace-actions.ts +366 -0
- package/src/app-runtime/workspace-activity.ts +138 -0
- package/src/app-runtime/workspace-naming.ts +90 -0
- package/src/app.tsx +93 -48
- package/src/assets/sounds/bell.wav +0 -0
- package/src/assets/sounds/ding.wav +0 -0
- package/src/assets/sounds/plane.wav +0 -0
- package/src/cli/client/daemon-client.ts +2 -2
- package/src/cli/client/project-resolver.ts +112 -0
- package/src/cli/commands/project/close.ts +32 -0
- package/src/cli/commands/project/create.ts +93 -0
- package/src/cli/commands/project/list.ts +25 -0
- package/src/cli/commands/project/show.ts +32 -0
- package/src/cli/commands/{workspace → project}/switch.ts +19 -19
- package/src/cli/commands/tab/await.ts +2 -2
- package/src/cli/commands/tab/close.ts +3 -3
- package/src/cli/commands/tab/create.ts +74 -73
- package/src/cli/commands/tab/focus.ts +3 -3
- package/src/cli/commands/tab/list.ts +6 -6
- package/src/cli/commands/tab/run.ts +2 -2
- package/src/cli/commands/tab/send.ts +2 -2
- package/src/cli/commands/tab/snapshot.ts +2 -2
- package/src/cli/commands/tab/tail.ts +2 -2
- package/src/cli/commands/tab/wait.ts +2 -2
- package/src/cli/commands/worker/await.ts +3 -3
- package/src/cli/commands/worker/doctor.ts +30 -30
- package/src/cli/commands/worker/list.ts +15 -15
- package/src/cli/commands/worker/prompt.ts +3 -3
- package/src/cli/commands/worker/run.ts +23 -23
- package/src/cli/commands/worker/shared.ts +53 -59
- package/src/cli/commands/worker/stop.ts +29 -29
- package/src/cli/commands/worker/submit.ts +3 -3
- package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
- package/src/cli/commands/workspace/create.ts +30 -68
- package/src/cli/commands/workspace/list.ts +52 -9
- package/src/cli/commands/workspace/remove.ts +81 -0
- package/src/cli/completion/plan.ts +2 -2
- package/src/cli/completion/sources.ts +18 -18
- package/src/cli/context.ts +13 -13
- package/src/cli/flags.ts +30 -10
- package/src/cli/index.ts +26 -16
- package/src/cli/output.ts +1 -1
- package/src/cli/registry.ts +12 -12
- package/src/config/loader.ts +16 -5
- package/src/config.ts +70 -112
- package/src/daemon/catalog-writer.ts +67 -67
- package/src/daemon/daemon.ts +205 -201
- package/src/daemon/session-manager.ts +39 -39
- package/src/daemon/session-registry.ts +14 -14
- package/src/git/divergence.ts +33 -2
- package/src/git/git-poller.ts +1 -1
- package/src/git/git-status.ts +2 -2
- package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
- package/src/git/pr-status.ts +1 -1
- package/src/git/repo-discovery.ts +1 -1
- package/src/git/use-repo-discovery.ts +1 -1
- package/src/git/workspace-branch-poller.ts +54 -0
- package/src/git/workspace-divergence-poller.ts +65 -0
- package/src/git/worktree.ts +29 -0
- package/src/index.tsx +4 -4
- package/src/input/keymap/help-entries.ts +7 -5
- package/src/input/modes/bridge.ts +13 -12
- package/src/input/modes/handlers/shared.ts +1 -1
- package/src/input/modes/transitions.ts +34 -21
- package/src/input/modes/types.ts +48 -31
- package/src/ipc/manager-protocol.ts +44 -44
- package/src/ipc/protocol.ts +192 -166
- package/src/platform/play-sound.ts +184 -0
- package/src/platform/project-search.ts +8 -8
- package/src/platform/worktree-paths.ts +8 -6
- package/src/pty/assistant-question-extractor.ts +1 -1
- package/src/pty/assistant-status-detection-loop.ts +79 -55
- package/src/pty/assistant-status-detector.ts +40 -15
- package/src/pty/command-registry.ts +81 -1
- package/src/restart-terminal-manager.ts +1 -1
- package/src/services/ai-usage/provider.ts +9 -2
- package/src/services/ai-usage/spawn.ts +19 -6
- package/src/session-backend/bootstrap.ts +4 -4
- package/src/session-backend/local-session-backend.ts +78 -75
- package/src/session-backend/remote-session-backend.ts +64 -52
- package/src/session-backend/types.ts +43 -32
- package/src/settings/live.ts +90 -0
- package/src/settings/search.ts +43 -0
- package/src/settings/sections/about.ts +49 -0
- package/src/settings/sections/appearance.ts +54 -0
- package/src/settings/sections/automation.ts +105 -0
- package/src/settings/sections/commands.ts +72 -0
- package/src/settings/sections/editor.ts +56 -0
- package/src/settings/sections/experimental.ts +57 -0
- package/src/settings/sections/git.ts +112 -0
- package/src/settings/sections/index.ts +95 -0
- package/src/settings/sections/integrations.ts +23 -0
- package/src/settings/sections/layout.ts +72 -0
- package/src/settings/sections/notifications.ts +99 -0
- package/src/settings/sections/setup.ts +54 -0
- package/src/settings/sections/status-bar.ts +63 -0
- package/src/settings/settings-store.ts +207 -0
- package/src/settings/types.ts +108 -0
- package/src/snippets/run-shell-var.ts +48 -22
- package/src/state/actions.ts +342 -0
- package/src/state/app-store.ts +2 -1
- package/src/state/bars.ts +1 -1
- package/src/state/dispatch-ref.ts +1 -1
- package/src/state/layout-resize.ts +2 -2
- package/src/state/project-catalog.ts +275 -0
- package/src/state/project-data.ts +143 -0
- package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
- package/src/state/project-save.ts +46 -0
- package/src/state/project-workspaces.ts +392 -0
- package/src/state/reducers/auto-commit-state.ts +11 -10
- package/src/state/reducers/git-commit-modal-state.ts +122 -0
- package/src/state/reducers/git-mode-state.ts +1 -1
- package/src/state/reducers/git-panel-state.ts +10 -6
- package/src/state/reducers/modal-state.ts +290 -681
- package/src/state/reducers/multi-repo-state.ts +3 -6
- package/src/state/reducers/project-state.ts +312 -0
- package/src/state/reducers/settings-state.ts +80 -0
- package/src/state/reducers/tab-state.ts +107 -60
- package/src/state/reducers/ui-state.ts +4 -3
- package/src/state/selectors.ts +49 -37
- package/src/state/store.ts +57 -24
- package/src/state/types.ts +244 -405
- package/src/state/validation.ts +14 -12
- package/src/terminal-manager/manager-client.ts +32 -32
- package/src/terminal-manager/terminal-manager.ts +24 -24
- package/src/ui/components/git/git-panel.tsx +2 -2
- package/src/ui/components/git/git-view.tsx +12 -12
- package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
- package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
- package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
- package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
- package/src/ui/components/layout/status-bar.tsx +6 -2
- package/src/ui/components/layout/terminal-pane.tsx +34 -29
- package/src/ui/components/layout/top-tab-bar.tsx +46 -36
- package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
- package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
- package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
- package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
- package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
- package/src/ui/components/modals/shared/form.tsx +34 -22
- package/src/ui/components/modals/shared/picker.tsx +10 -10
- package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
- package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
- package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
- package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
- package/src/ui/components/primitives/input-field.tsx +7 -3
- package/src/ui/components/primitives/surface.tsx +3 -0
- package/src/ui/components/settings/row-value.tsx +74 -0
- package/src/ui/components/settings/settings-row.tsx +78 -0
- package/src/ui/components/settings/settings-view.tsx +169 -0
- package/src/ui/components/setup/setup-widget.tsx +152 -0
- package/src/ui/flash/build-labels.ts +27 -27
- package/src/ui/hooks/use-activity-sprite.ts +73 -0
- package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
- package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
- package/src/ui/root.tsx +139 -121
- package/src/ui/status-bar-model.ts +48 -34
- package/src/ui/terminal-graphics/kitty.ts +28 -2
- package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
- package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
- package/src/ui/terminal-graphics/sprites.ts +246 -0
- package/src/ui/truncate.ts +7 -0
- package/src/ui/widgets/registry.tsx +6 -3
- package/src/ui/widgets/widget-context-menu.ts +26 -10
- package/src/update.ts +2 -2
- package/src/app-runtime/session-actions.ts +0 -187
- package/src/cli/client/workspace-resolver.ts +0 -110
- package/src/cli/commands/workspace/close.ts +0 -32
- package/src/cli/commands/workspace/show.ts +0 -32
- package/src/cli/commands/worktree/create.ts +0 -55
- package/src/cli/commands/worktree/list.ts +0 -68
- package/src/cli/commands/worktree/remove.ts +0 -81
- package/src/git/worktree-branch-poller.ts +0 -54
- package/src/git/worktree-divergence-poller.ts +0 -59
- package/src/state/reducers/session-state.ts +0 -262
- package/src/state/session-catalog.ts +0 -143
- package/src/state/session-worktrees.ts +0 -260
- package/src/state/workspace-save.ts +0 -43
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
- package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
- package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
- /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
|
@@ -1,185 +1,127 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
16
|
-
import type {
|
|
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
|
|
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
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
84
|
-
import {
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
36
|
+
handleCreateProjectEffect,
|
|
37
|
+
handleDeleteProjectEffect,
|
|
38
|
+
handleRenameProjectEffect,
|
|
39
|
+
handleSwitchProjectEffect,
|
|
90
40
|
restartTabSession,
|
|
91
|
-
|
|
92
|
-
} from './
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
139
|
-
logInputDebug('app.
|
|
140
|
-
creatingNew: !
|
|
80
|
+
const selectedProject = getSelectedProject(state)
|
|
81
|
+
logInputDebug('app.projectPicker.confirm', {
|
|
82
|
+
creatingNew: !selectedProject,
|
|
141
83
|
selectedIndex: state.modal.selectedIndex,
|
|
142
|
-
|
|
84
|
+
selectedProjectId: selectedProject?.id ?? null,
|
|
143
85
|
})
|
|
144
86
|
|
|
145
|
-
if (
|
|
146
|
-
|
|
87
|
+
if (selectedProject) {
|
|
88
|
+
handleSwitchProjectEffect(state, backend, dispatch, selectedProject)
|
|
147
89
|
return
|
|
148
90
|
}
|
|
149
91
|
|
|
150
|
-
dispatch({
|
|
92
|
+
dispatch({ returnToProjectPicker: true, type: 'open-create-project-modal' })
|
|
151
93
|
}
|
|
152
94
|
|
|
153
|
-
function
|
|
95
|
+
function handleSelectedProjectDelete(ctx: SideEffectContext): void {
|
|
154
96
|
const { backend, dispatch, state } = ctx
|
|
155
|
-
const
|
|
156
|
-
logInputDebug('app.
|
|
97
|
+
const selectedProject = getSelectedProject(state)
|
|
98
|
+
logInputDebug('app.projectPicker.deleteSelected', {
|
|
157
99
|
selectedIndex: state.modal.selectedIndex,
|
|
158
|
-
|
|
100
|
+
selectedProjectId: selectedProject?.id ?? null,
|
|
159
101
|
})
|
|
160
102
|
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
103
|
+
if (selectedProject) {
|
|
104
|
+
handleDeleteProjectEffect(state, backend, dispatch, selectedProject.id, {
|
|
105
|
+
openProjectPicker: true,
|
|
164
106
|
})
|
|
165
107
|
}
|
|
166
108
|
}
|
|
167
109
|
|
|
168
|
-
function
|
|
110
|
+
function openSelectedProjectRename(ctx: SideEffectContext): void {
|
|
169
111
|
const { dispatch, state } = ctx
|
|
170
|
-
const
|
|
171
|
-
if (!
|
|
112
|
+
const selectedProject = getSelectedProject(state)
|
|
113
|
+
if (!selectedProject) {
|
|
172
114
|
return
|
|
173
115
|
}
|
|
174
116
|
|
|
175
|
-
logInputDebug('app.
|
|
117
|
+
logInputDebug('app.projectPicker.openRenameModal', {
|
|
176
118
|
selectedIndex: state.modal.selectedIndex,
|
|
177
|
-
|
|
119
|
+
selectedProjectId: selectedProject.id,
|
|
178
120
|
})
|
|
179
121
|
dispatch({
|
|
180
|
-
initialName:
|
|
181
|
-
|
|
182
|
-
type: 'open-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
230
|
+
pending: PendingWorkspaceLaunch,
|
|
231
|
+
assistant: AssistantId
|
|
390
232
|
): void {
|
|
391
|
-
const
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
templateId?: string
|
|
254
|
+
projectId: string,
|
|
255
|
+
params: {
|
|
256
|
+
prompt: string
|
|
257
|
+
baseRef?: string
|
|
258
|
+
}
|
|
431
259
|
): Promise<void> {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
baseRef
|
|
440
|
-
sourceWorktreeId
|
|
267
|
+
projectId,
|
|
268
|
+
placeholderWorkspaceName(params.prompt),
|
|
269
|
+
undefined,
|
|
270
|
+
params.baseRef
|
|
441
271
|
)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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 '
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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-
|
|
357
|
+
case 'load-create-workspace-base-branches': {
|
|
715
358
|
void (async () => {
|
|
716
|
-
const
|
|
717
|
-
const sourcePath =
|
|
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
|
|
720
|
-
|
|
721
|
-
|
|
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 '
|
|
726
|
-
|
|
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 '
|
|
730
|
-
|
|
390
|
+
case 'confirm-selected-project': {
|
|
391
|
+
handleProjectSelection(ctx)
|
|
731
392
|
return
|
|
732
393
|
}
|
|
733
|
-
case 'delete-
|
|
734
|
-
|
|
394
|
+
case 'delete-selected-project': {
|
|
395
|
+
handleSelectedProjectDelete(ctx)
|
|
735
396
|
return
|
|
736
397
|
}
|
|
737
|
-
case 'delete-
|
|
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
|
-
|
|
406
|
+
runDeleteWorkspace(
|
|
742
407
|
{ ...ctx, state: ctx.getState() },
|
|
743
|
-
effect.
|
|
744
|
-
effect.
|
|
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
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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-
|
|
439
|
+
case 'move-workspace': {
|
|
784
440
|
void (async () => {
|
|
785
441
|
try {
|
|
786
442
|
await enqueueGitOp(async () =>
|
|
787
|
-
|
|
443
|
+
runMoveWorkspace(
|
|
788
444
|
{ ...ctx, state: ctx.getState() },
|
|
789
|
-
effect.
|
|
790
|
-
effect.
|
|
791
|
-
effect.
|
|
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-
|
|
459
|
+
case 'load-workspace-move-stats': {
|
|
804
460
|
void (async () => {
|
|
805
|
-
const
|
|
806
|
-
const
|
|
807
|
-
if (
|
|
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
|
-
|
|
465
|
+
workspaces.map(async (workspace) => [workspace.id, await countDirtyFiles(workspace.path)])
|
|
810
466
|
)
|
|
811
|
-
if (ctx.getState().modal.type !== '
|
|
467
|
+
if (ctx.getState().modal.type !== 'workspace-move') return
|
|
812
468
|
ctx.dispatch({
|
|
813
469
|
dirtyFiles: Object.fromEntries(counts),
|
|
814
|
-
type: 'set-
|
|
470
|
+
type: 'set-workspace-move-stats',
|
|
815
471
|
})
|
|
816
472
|
})()
|
|
817
473
|
return
|
|
818
474
|
}
|
|
819
|
-
case 'open-rename-selected-
|
|
820
|
-
|
|
475
|
+
case 'open-rename-selected-project': {
|
|
476
|
+
openSelectedProjectRename(ctx)
|
|
821
477
|
return
|
|
822
478
|
}
|
|
823
|
-
case 'create-
|
|
824
|
-
|
|
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-
|
|
877
|
-
|
|
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?.
|
|
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.
|
|
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-
|
|
982
|
-
|
|
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
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1180
|
-
|
|
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
|
-
|
|
1285
|
-
|
|
685
|
+
case 'promote-setup-tab': {
|
|
686
|
+
handlePromoteSetupTabEffect(ctx)
|
|
687
|
+
return
|
|
1286
688
|
}
|
|
1287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
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
|
-
|
|
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
|
-
}
|