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