@brimveyn/aimux 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/pty-write.ts +52 -0
  8. package/src/app-runtime/selection-scroll.ts +2 -2
  9. package/src/app-runtime/session-actions.ts +16 -4
  10. package/src/app-runtime/side-effects.ts +598 -102
  11. package/src/app-runtime/snippet-actions.ts +25 -11
  12. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  13. package/src/app-runtime/use-backend-runtime.ts +3 -3
  14. package/src/app-runtime/use-directory-search.ts +7 -5
  15. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  16. package/src/app-runtime/use-pane-size-report.ts +1 -1
  17. package/src/app-runtime/use-renderer-bindings.ts +143 -8
  18. package/src/app.tsx +25 -9
  19. package/src/auto-commit/headless-commands.ts +4 -6
  20. package/src/daemon/daemon.ts +3 -3
  21. package/src/daemon/runtime-paths.ts +1 -1
  22. package/src/daemon/session-manager.ts +1 -1
  23. package/src/daemon/session-registry.ts +10 -3
  24. package/src/diff-parser/parse-patch-files.ts +18 -18
  25. package/src/git/command-queue.ts +1 -1
  26. package/src/git/divergence.ts +46 -0
  27. package/src/git/git-diff.ts +12 -5
  28. package/src/git/git-poller.ts +33 -11
  29. package/src/git/git-status.ts +14 -3
  30. package/src/git/move-worktree.ts +96 -0
  31. package/src/git/use-repo-discovery.ts +2 -1
  32. package/src/git/worktree-divergence-poller.ts +59 -0
  33. package/src/git/worktree.ts +99 -0
  34. package/src/index.tsx +1 -1
  35. package/src/input/keymap/describe-bindings.ts +27 -9
  36. package/src/input/keymap/key-chord.ts +4 -4
  37. package/src/input/keymap/key-format.ts +2 -2
  38. package/src/input/keymap/sequence-resolver.ts +1 -1
  39. package/src/input/keymap/trie.ts +1 -1
  40. package/src/input/modes/bridge.ts +7 -0
  41. package/src/input/modes/transitions.ts +2 -1
  42. package/src/input/modes/types.ts +13 -1
  43. package/src/input/raw-input-handler.ts +34 -1
  44. package/src/input/terminal-text-extraction.ts +9 -5
  45. package/src/integrations/claude-syntax-overlay.ts +8 -8
  46. package/src/integrations/claude-theme-sync.ts +12 -12
  47. package/src/ipc/protocol.ts +3 -3
  48. package/src/platform/clipboard.ts +18 -2
  49. package/src/platform/worktree-deps.ts +22 -0
  50. package/src/platform/worktree-paths.ts +67 -0
  51. package/src/profile-paths.ts +3 -3
  52. package/src/pty/assistant-status-detection-loop.ts +2 -2
  53. package/src/pty/assistant-status-detector.ts +15 -6
  54. package/src/pty/command-registry.ts +8 -1
  55. package/src/pty/pty-manager.ts +1 -1
  56. package/src/services/ai-usage/adapters/claude.ts +5 -5
  57. package/src/services/ai-usage/adapters/codex.ts +8 -8
  58. package/src/services/ai-usage/cache.ts +2 -2
  59. package/src/services/ai-usage/pace.ts +3 -6
  60. package/src/services/ai-usage/provider.ts +1 -1
  61. package/src/session-backend/bootstrap.ts +2 -2
  62. package/src/session-backend/local-session-backend.ts +11 -11
  63. package/src/session-backend/remote-session-backend.ts +3 -3
  64. package/src/session-backend/types.ts +2 -2
  65. package/src/snippets/expand-variables.ts +112 -0
  66. package/src/snippets/run-shell-var.ts +93 -0
  67. package/src/snippets/trigger-detector.ts +105 -0
  68. package/src/state/ai-usage-store.ts +1 -1
  69. package/src/state/git-tree.ts +8 -4
  70. package/src/state/layout-tree.ts +8 -5
  71. package/src/state/reducers/git-mode-state.ts +44 -14
  72. package/src/state/reducers/git-panel-state.ts +38 -2
  73. package/src/state/reducers/modal-state.ts +385 -24
  74. package/src/state/reducers/session-state.ts +60 -1
  75. package/src/state/reducers/tab-state.ts +151 -85
  76. package/src/state/selectors.ts +6 -4
  77. package/src/state/session-catalog.ts +20 -5
  78. package/src/state/session-persistence.ts +8 -4
  79. package/src/state/session-worktrees.ts +227 -0
  80. package/src/state/snippet-catalog.ts +65 -4
  81. package/src/state/store.ts +1 -0
  82. package/src/state/toast-store.ts +95 -0
  83. package/src/state/types.ts +108 -16
  84. package/src/state/validation.ts +47 -3
  85. package/src/terminal-manager/manager-client.ts +15 -13
  86. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  87. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  88. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  89. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  90. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  91. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  92. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  93. package/src/ui/components/git/git-panel.tsx +17 -10
  94. package/src/ui/components/git/git-view.tsx +79 -19
  95. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  96. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  97. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  98. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  99. package/src/ui/components/layout/session-bar.tsx +3 -3
  100. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  101. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  102. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  103. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  104. package/src/ui/components/layout/split-layout.tsx +1 -1
  105. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  106. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  107. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  108. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  109. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  110. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  111. package/src/ui/components/modals/shared/form.tsx +4 -5
  112. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  113. package/src/ui/components/modals/shared/picker.tsx +28 -5
  114. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
  115. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
  116. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  117. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  118. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +3 -3
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +65 -14
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -1,20 +1,41 @@
1
+ import type { CliRenderer } from '@opentui/core'
2
+
1
3
  import {
2
4
  DEFAULT_EDITOR_ARGS,
3
5
  getExternalEditorConfig,
4
6
  isAutoCommitEnabled,
5
7
  KNOWN_GUI_EDITORS,
6
8
  } from '@brimveyn/aimux-config'
7
- import { type CliRenderer } from '@opentui/core'
8
9
  import { $ } from 'bun'
9
- import { resolve as resolvePath } from 'node:path'
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'
10
13
 
11
14
  import type { SideEffect } from '../input/modes/types'
12
15
  import type { SessionBackend } from '../session-backend/types'
16
+ import type { ThemeId } from '../ui/themes'
13
17
 
14
18
  import { loadConfig, saveConfig } from '../config'
15
19
  import { logInputDebug } from '../debug/input-log'
16
20
  import { enqueueGitOp } from '../git/command-queue'
21
+ import { moveWorktree } from '../git/move-worktree'
22
+ import {
23
+ createGitWorktree,
24
+ getCurrentBranch,
25
+ getHeadSha,
26
+ getMainWorktreeRoot,
27
+ listGitWorktrees,
28
+ removeGitWorktree,
29
+ } from '../git/worktree'
17
30
  import { createPrefixedId } from '../platform/id'
31
+ import { linkNodeModules } from '../platform/worktree-deps'
32
+ import {
33
+ assertSafeAimuxWorktreePath,
34
+ isInsideAimuxWorktreeRoot,
35
+ makeWorktreePath,
36
+ sanitizePathSegment,
37
+ } from '../platform/worktree-paths'
38
+ import { getProfileConfigDir } from '../profile-paths'
18
39
  import {
19
40
  getAllAssistantOptions,
20
41
  getAssistantOption,
@@ -33,19 +54,23 @@ import {
33
54
  splitNode,
34
55
  } from '../state/layout-tree'
35
56
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
57
+ import { saveSessionCatalog } from '../state/session-catalog'
58
+ import { getActiveWorktree, getSessionProjectPath } from '../state/session-worktrees'
59
+ import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
36
60
  import { createDefaultTerminalModes } from '../state/terminal-modes'
61
+ import { toast } from '../state/toast-store'
37
62
  import {
38
63
  type AppAction,
39
64
  type AppState,
40
65
  type AssistantId,
41
66
  DEFAULT_SCROLL_INTENT,
42
67
  type TabSession,
68
+ type WorktreeRecord,
43
69
  } from '../state/types'
44
70
  import { saveCurrentWorkspace } from '../state/workspace-save'
45
71
  import { filterThemeIds } from '../ui/filter-themes'
46
72
  import { scrollGitDiff } from '../ui/git-view-controls'
47
73
  import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
48
- import { type ThemeId } from '../ui/themes'
49
74
  import { triggerAutoCommitNow } from './auto-commit-ref'
50
75
  import {
51
76
  handleCreateSessionEffect,
@@ -73,11 +98,16 @@ export interface SideEffectContext {
73
98
  clearIdleTimer: (tabId: string) => void
74
99
  clearStartupGrace: (tabId: string) => void
75
100
  startStartupGrace: (tabId: string, timeoutMs: number) => void
101
+ getState: () => AppState
76
102
  getCurrentSessionProjectPath: () => string | undefined
77
103
  }
78
104
 
79
105
  function getSelectedAssistantOption(state: AppState) {
80
106
  const all = getAllAssistantOptions(state.customCommands)
107
+ if (state.modal.type === 'new-tab' && state.modal.selectedAssistantId != null) {
108
+ const selectedAssistantId = state.modal.selectedAssistantId
109
+ return all.find((entry) => entry.id === selectedAssistantId) ?? getAssistantOption(0)
110
+ }
81
111
  const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
82
112
  const list = filterAssistants(all, filter)
83
113
  return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
@@ -136,12 +166,12 @@ function openSelectedSessionRename(ctx: SideEffectContext): void {
136
166
  function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
137
167
  const { activeTab, backend, state } = ctx
138
168
  const snippet = getSelectedSnippet(state)
139
- if (!snippet || !state.activeTabId) {
169
+ if (!snippet || !(state.activeTabId != null && state.activeTabId !== '')) {
140
170
  return
141
171
  }
142
172
 
143
173
  const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
144
- const groupTree = groupId ? state.layoutTrees[groupId] : null
174
+ const groupTree = groupId != null && groupId !== '' ? state.layoutTrees[groupId] : null
145
175
  if (!groupTree) {
146
176
  pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet, ctx.dispatch)
147
177
  return
@@ -196,7 +226,7 @@ function applyThemeEffect(
196
226
  return
197
227
  case 'confirm': {
198
228
  const selectedId = ids[state.modal.selectedIndex]
199
- if (selectedId) {
229
+ if (selectedId != null && selectedId !== '') {
200
230
  applyTheme(selectedId)
201
231
  ctx.setThemeId(selectedId)
202
232
  saveConfig({ ...loadConfig(), themeId: selectedId })
@@ -207,7 +237,7 @@ function applyThemeEffect(
207
237
  if (ids.length === 0) return
208
238
  const nextIndex = (state.modal.selectedIndex + effect.delta + ids.length) % ids.length
209
239
  const previewId = ids[nextIndex]
210
- if (previewId) {
240
+ if (previewId != null && previewId !== '') {
211
241
  applyTheme(previewId)
212
242
  }
213
243
  return
@@ -220,7 +250,16 @@ function confirmSplitSelection(ctx: SideEffectContext): void {
220
250
  const option = getSelectedAssistantOption(state)
221
251
  const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
222
252
  const customCommand = state.customCommands[option.id]
223
- const tab = createTabSession(option.id, customCommand, state.customCommands)
253
+ const tab = createTabSession(
254
+ option.id,
255
+ customCommand,
256
+ state.customCommands,
257
+ getActiveWorktree(
258
+ state.currentSessionId != null && state.currentSessionId !== ''
259
+ ? state.sessions.find((s) => s.id === state.currentSessionId)
260
+ : undefined
261
+ )?.id
262
+ )
224
263
  dispatch({ type: 'close-modal' })
225
264
  executeSplitPane(ctx, direction, tab)
226
265
  dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
@@ -243,7 +282,8 @@ function getSelectedSnippet(state: AppState) {
243
282
  export function createTabSession(
244
283
  assistant: AssistantId,
245
284
  customCommand?: string,
246
- customCommands?: Record<string, string>
285
+ customCommands?: Record<string, string>,
286
+ worktreeId?: string
247
287
  ): TabSession {
248
288
  const allOptions = getAllAssistantOptions(customCommands ?? {})
249
289
  const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
@@ -258,6 +298,7 @@ export function createTabSession(
258
298
  status: 'starting',
259
299
  terminalModes: createDefaultTerminalModes(),
260
300
  title: option.label,
301
+ worktreeId,
261
302
  }
262
303
  }
263
304
 
@@ -305,10 +346,38 @@ export function startTabSession(
305
346
  })
306
347
  }
307
348
 
308
- function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
349
+ function getNewTabTargetWorktreeId(state: AppState): string | undefined {
350
+ if (
351
+ state.modal.type !== 'new-tab' ||
352
+ !(state.currentSessionId != null && state.currentSessionId !== '')
353
+ ) {
354
+ return undefined
355
+ }
356
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
357
+ const index = state.modal.createWorktree
358
+ ? state.modal.targetWorktreeIndex
359
+ : state.modal.selectedIndex
360
+ return session?.worktrees?.[index]?.id
361
+ }
362
+
363
+ function launchAssistant(
364
+ ctx: SideEffectContext,
365
+ assistant: AssistantId,
366
+ worktreeId?: string
367
+ ): void {
309
368
  const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
310
369
  const customCommand = state.customCommands[assistant]
311
- const tab = createTabSession(assistant, customCommand, state.customCommands)
370
+ const tab = createTabSession(
371
+ assistant,
372
+ customCommand,
373
+ state.customCommands,
374
+ worktreeId ??
375
+ getActiveWorktree(
376
+ state.currentSessionId != null && state.currentSessionId !== ''
377
+ ? state.sessions.find((s) => s.id === state.currentSessionId)
378
+ : undefined
379
+ )?.id
380
+ )
312
381
  logInputDebug('app.launchAssistant', {
313
382
  assistant,
314
383
  command: tab.command,
@@ -324,10 +393,60 @@ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
324
393
  tab,
325
394
  state.layout.terminalCols,
326
395
  state.layout.terminalRows,
327
- ctx.getCurrentSessionProjectPath()
396
+ getTabProjectPath(ctx, tab)
397
+ )
398
+ }
399
+
400
+ async function launchAssistantInNewWorktree(
401
+ ctx: SideEffectContext,
402
+ assistant: AssistantId,
403
+ worktreeName: string,
404
+ branchName?: string,
405
+ sourceWorktreeId?: string
406
+ ): Promise<void> {
407
+ const sessionId = ctx.state.currentSessionId
408
+ if (!(sessionId != null && sessionId !== '')) return
409
+ const worktree = await createAimuxTempWorktree(
410
+ ctx,
411
+ sessionId,
412
+ worktreeName,
413
+ branchName,
414
+ undefined,
415
+ sourceWorktreeId
416
+ )
417
+ if (!worktree) return
418
+ const customCommand = ctx.state.customCommands[assistant]
419
+ const tab = createTabSession(assistant, customCommand, ctx.state.customCommands, worktree.id)
420
+ ctx.dispatch({ tab, type: 'add-tab' })
421
+ ctx.dispatch({ type: 'close-modal' })
422
+ ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
423
+ startTabSession(
424
+ ctx.backend,
425
+ ctx.dispatch,
426
+ ctx.clearStartupGrace,
427
+ (tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
428
+ tab,
429
+ ctx.state.layout.terminalCols,
430
+ ctx.state.layout.terminalRows,
431
+ worktree.path
328
432
  )
329
433
  }
330
434
 
435
+ function getTabProjectPath(
436
+ ctx: SideEffectContext,
437
+ tab: Pick<TabSession, 'worktreeId'>
438
+ ): string | undefined {
439
+ const session =
440
+ ctx.state.currentSessionId != null && ctx.state.currentSessionId !== ''
441
+ ? ctx.state.sessions.find((entry) => entry.id === ctx.state.currentSessionId)
442
+ : undefined
443
+ if (tab.worktreeId != null && tab.worktreeId !== '') {
444
+ const worktree = session?.worktrees?.find((entry) => entry.id === tab.worktreeId)
445
+ if (worktree) return worktree.path
446
+ }
447
+ return ctx.getCurrentSessionProjectPath()
448
+ }
449
+
331
450
  function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
332
451
  const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
333
452
  startTabSession(
@@ -338,7 +457,7 @@ function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
338
457
  tab,
339
458
  state.layout.terminalCols,
340
459
  state.layout.terminalRows,
341
- ctx.getCurrentSessionProjectPath()
460
+ getTabProjectPath(ctx, tab)
342
461
  )
343
462
  }
344
463
 
@@ -349,7 +468,7 @@ function executeSplitPane(
349
468
  ): void {
350
469
  const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
351
470
  const activeTabId = state.activeTabId
352
- if (!activeTabId) {
471
+ if (!(activeTabId != null && activeTabId !== '')) {
353
472
  return
354
473
  }
355
474
 
@@ -368,7 +487,7 @@ function executeSplitPane(
368
487
  tab,
369
488
  Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
370
489
  Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
371
- ctx.getCurrentSessionProjectPath()
490
+ getTabProjectPath(ctx, tab)
372
491
  )
373
492
  }
374
493
 
@@ -385,7 +504,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
385
504
  }
386
505
  case 'launch-selected-assistant': {
387
506
  const option = getSelectedAssistantOption(state)
388
- launchAssistant(ctx, option.id)
507
+ if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
508
+ const worktreeName = state.modal.worktreeName
509
+ const branchName = state.modal.branchName
510
+ const sourceWorktreeId = getNewTabTargetWorktreeId(state)
511
+ void enqueueGitOp(async () =>
512
+ launchAssistantInNewWorktree(ctx, option.id, worktreeName, branchName, sourceWorktreeId)
513
+ ).catch((error) => toast.error(error instanceof Error ? error.message : String(error)))
514
+ return
515
+ }
516
+ launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
517
+ return
518
+ }
519
+ case 'edit-selected-assistant': {
520
+ const option = getSelectedAssistantOption(state)
521
+ dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
389
522
  return
390
523
  }
391
524
  case 'confirm-selected-session': {
@@ -400,6 +533,47 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
400
533
  handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
401
534
  return
402
535
  }
536
+ case 'delete-worktree': {
537
+ void enqueueGitOp(async () =>
538
+ runDeleteWorktree(
539
+ { ...ctx, state: ctx.getState() },
540
+ effect.sessionId,
541
+ effect.worktreeId,
542
+ !!(effect.force === true)
543
+ )
544
+ ).catch((error) => {
545
+ const message = error instanceof Error ? error.message : String(error)
546
+ const forceable = isForceableWorktreeDeleteError(message)
547
+ const latest = ctx.getState()
548
+ if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
549
+ const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
550
+ const selected = session?.worktrees?.[latest.modal.selectedIndex]
551
+ if (selected && selected.id !== effect.worktreeId) {
552
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
553
+ return
554
+ }
555
+ }
556
+ ctx.dispatch({
557
+ confirmWorktreeId: forceable ? effect.worktreeId : null,
558
+ message: forceable ? message : `Could not delete worktree: ${message}`,
559
+ type: 'set-new-tab-worktree-delete-state',
560
+ })
561
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
562
+ })
563
+ return
564
+ }
565
+ case 'move-worktree': {
566
+ void enqueueGitOp(async () =>
567
+ runMoveWorktree(
568
+ { ...ctx, state: ctx.getState() },
569
+ effect.sessionId,
570
+ effect.sourceWorktreeId,
571
+ effect.targetWorktreeId,
572
+ effect.deleteSource === true
573
+ )
574
+ ).catch((error) => toast.error(error instanceof Error ? error.message : String(error)))
575
+ return
576
+ }
403
577
  case 'open-rename-selected-session': {
404
578
  openSelectedSessionRename(ctx)
405
579
  return
@@ -468,12 +642,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
468
642
  return
469
643
  }
470
644
  case 'split-pane': {
471
- const sourceTab = effect.sourceTabId
472
- ? state.tabs.find((t) => t.id === effect.sourceTabId)
473
- : undefined
645
+ const sourceTab =
646
+ effect.sourceTabId != null && effect.sourceTabId !== ''
647
+ ? state.tabs.find((t) => t.id === effect.sourceTabId)
648
+ : undefined
474
649
  const assistant = sourceTab?.assistant ?? 'terminal'
475
650
  const customCommand = state.customCommands[assistant]
476
- const tab = createTabSession(assistant, customCommand, state.customCommands)
651
+ const tab = createTabSession(
652
+ assistant,
653
+ customCommand,
654
+ state.customCommands,
655
+ sourceTab?.worktreeId
656
+ )
477
657
  executeSplitPane(ctx, effect.direction, tab)
478
658
  return
479
659
  }
@@ -546,42 +726,42 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
546
726
  return
547
727
  }
548
728
  case 'git-stage': {
549
- void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
729
+ void enqueueGitOp(async () => runGitAction(ctx, ['add', '--', effect.path], effect.path))
550
730
  return
551
731
  }
552
732
  case 'git-unstage': {
553
- void enqueueGitOp(() =>
733
+ void enqueueGitOp(async () =>
554
734
  runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
555
735
  )
556
736
  return
557
737
  }
558
738
  case 'git-stage-all': {
559
739
  const paths = ctx.state.gitPanel.files.map((f) => f.path)
560
- void enqueueGitOp(() => runGitActionAll(ctx, ['add', '-A'], paths))
740
+ void enqueueGitOp(async () => runGitActionAll(ctx, ['add', '-A'], paths))
561
741
  return
562
742
  }
563
743
  case 'git-unstage-all': {
564
744
  const paths = ctx.state.gitPanel.files.map((f) => f.path)
565
- void enqueueGitOp(() => runGitActionAll(ctx, ['reset'], paths))
745
+ void enqueueGitOp(async () => runGitActionAll(ctx, ['reset'], paths))
566
746
  return
567
747
  }
568
748
  case 'git-restore': {
569
- void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
749
+ void enqueueGitOp(async () => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
570
750
  return
571
751
  }
572
752
  case 'git-rm': {
573
- void enqueueGitOp(() => runGitRm(ctx, effect.path))
753
+ void enqueueGitOp(async () => runGitRm(ctx, effect.path))
574
754
  return
575
755
  }
576
756
  case 'git-commit': {
577
757
  const { body, title } = effect
578
- void enqueueGitOp(() => runGitCommit(ctx, title, body))
758
+ void enqueueGitOp(async () => runGitCommit(ctx, title, body))
579
759
  return
580
760
  }
581
761
  case 'git-commit-auto': {
582
762
  if (!isAutoCommitEnabled()) return
583
763
  const { body, title } = effect
584
- void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
764
+ void enqueueGitOp(async () => runGitCommitAuto(ctx, title, body))
585
765
  return
586
766
  }
587
767
  case 'generate-auto-commit-now': {
@@ -590,7 +770,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
590
770
  return
591
771
  }
592
772
  case 'git-push': {
593
- void enqueueGitOp(() => runGitPush(ctx))
773
+ void enqueueGitOp(async () => runGitPush(ctx))
594
774
  return
595
775
  }
596
776
  case 'confirm-update-selection': {
@@ -617,34 +797,74 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
617
797
  openFileInEditor(ctx, effect.path)
618
798
  return
619
799
  }
800
+ case 'open-selected-snippet-source-in-editor': {
801
+ openSelectedSnippetSourceInEditor(ctx)
802
+ return
803
+ }
620
804
  default:
621
805
  effect satisfies never
622
806
  }
623
807
  }
624
808
 
625
- function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
626
- const config = getExternalEditorConfig()
627
- const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
628
- if (!rawCommand || rawCommand.trim() === '') {
629
- ctx.dispatch({
630
- message: 'no $EDITOR/$VISUAL set configure externalEditor in aimux.config.ts',
631
- type: 'git-mode-set-message',
632
- })
633
- return
809
+ /**
810
+ * Open the file backing the currently selected snippet in the user's editor.
811
+ * Config-pinned snippets (id starts with `config:`) live in `aimux.config.ts`
812
+ * (or `.js`); user-edited snippets live in `aimux-snippets.json`.
813
+ *
814
+ * On error (no editor, editor not in PATH) the failure is silent: there is no
815
+ * snippet-picker status line. The user can check the debug log.
816
+ */
817
+ function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
818
+ const snippet = getSelectedSnippet(ctx.state)
819
+ if (!snippet) return
820
+
821
+ const configDir = getProfileConfigDir()
822
+ let absolutePath: string
823
+
824
+ if (isConfigSnippetId(snippet.id)) {
825
+ const tsPath = joinPath(configDir, 'aimux.config.ts')
826
+ const jsPath = joinPath(configDir, 'aimux.config.js')
827
+ absolutePath = existsSync(jsPath) && !existsSync(tsPath) ? jsPath : tsPath
828
+ } else {
829
+ absolutePath = getSnippetsCatalogPath()
634
830
  }
635
831
 
832
+ launchEditorOnFile(ctx, absolutePath, configDir, (message) => {
833
+ logInputDebug('snippets.openInEditor.error', { message, path: absolutePath })
834
+ ctx.dispatch({ message, type: 'snippet-picker-set-message' })
835
+ })
836
+ }
837
+
838
+ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
636
839
  const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
637
840
  const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
638
- if (!cwd) {
841
+ if (!(cwd != null && cwd !== '')) {
639
842
  ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
640
843
  return
641
844
  }
642
845
  const absolutePath = resolvePath(cwd, relPath)
846
+ launchEditorOnFile(ctx, absolutePath, cwd, (message) =>
847
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
848
+ )
849
+ }
850
+
851
+ function launchEditorOnFile(
852
+ ctx: SideEffectContext,
853
+ absolutePath: string,
854
+ cwd: string,
855
+ onError: (message: string) => void
856
+ ): void {
857
+ const config = getExternalEditorConfig()
858
+ const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
859
+ if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
860
+ onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
861
+ return
862
+ }
643
863
 
644
864
  const cmdParts = shellSplit(rawCommand)
645
865
  const executable = cmdParts[0]
646
- if (!executable) {
647
- ctx.dispatch({ message: 'invalid editor command', type: 'git-mode-set-message' })
866
+ if (!(executable != null && executable !== '')) {
867
+ onError('invalid editor command')
648
868
  return
649
869
  }
650
870
  const baseName = executable.split('/').pop() ?? executable
@@ -653,16 +873,12 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
653
873
  const kind: 'gui' | 'tui' = config.kind ?? (KNOWN_GUI_EDITORS.has(baseName) ? 'gui' : 'tui')
654
874
 
655
875
  const templateArgs = config.args ?? DEFAULT_EDITOR_ARGS[baseName] ?? ['{file}']
656
- // git mode does not track a cursor line within the diff yet, so pass undefined
657
- // and let the substitution strip `{line}` placeholders cleanly — preserves
658
- // editor-side "restore last cursor position" behavior (e.g. vscode).
876
+ // No line target let substitution strip `{line}` placeholders so we don't
877
+ // defeat the editor's "restore last cursor position" feature.
659
878
  const resolvedArgs = [...extraCmdArgs, ...substituteEditorArgs(templateArgs, absolutePath)]
660
879
 
661
880
  if (!isCommandAvailable(executable)) {
662
- ctx.dispatch({
663
- message: `editor not found in PATH: ${executable}`,
664
- type: 'git-mode-set-message',
665
- })
881
+ onError(`editor not found in PATH: ${executable}`)
666
882
  return
667
883
  }
668
884
 
@@ -671,9 +887,6 @@ function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
671
887
  return
672
888
  }
673
889
 
674
- // TUI editor — if the user explicitly configured `terminal`, spawn a new
675
- // terminal window with their template. Otherwise default to inline shellout:
676
- // suspend the renderer, hand the TTY to the editor, resume on exit.
677
890
  if (config.terminal && config.terminal.length > 0) {
678
891
  const shellCmd = buildShellCmd(cwd, executable, resolvedArgs)
679
892
  const argv = config.terminal.map((a) =>
@@ -712,7 +925,7 @@ function substituteEditorArgs(template: string[], file: string, line?: string):
712
925
  // Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
713
926
  if (/^[+:]?\{line\}$/.test(arg)) continue
714
927
  // Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
715
- out.push(arg.replace(/[:+]\{line\}/g, '').replaceAll('{file}', file))
928
+ out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
716
929
  }
717
930
  return out
718
931
  }
@@ -775,13 +988,15 @@ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): vo
775
988
  const stderr = await new Response(child.stderr).text()
776
989
  const code = await child.exited
777
990
  if (code !== 0) {
778
- const firstLine = stderr.trim().split('\n')[0] || `exit ${code}`
991
+ const firstStderrLine = stderr.trim().split('\n')[0]
992
+ const firstLine =
993
+ firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
779
994
  ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
780
995
  }
781
996
  })()
782
997
  child.unref()
783
- } catch (err) {
784
- const msg = err instanceof Error ? err.message : 'failed to spawn'
998
+ } catch (error) {
999
+ const msg = error instanceof Error ? error.message : 'failed to spawn'
785
1000
  ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
786
1001
  }
787
1002
  }
@@ -808,8 +1023,8 @@ async function openEditorInline(
808
1023
  stdout: 'inherit',
809
1024
  })
810
1025
  await proc.exited
811
- } catch (err) {
812
- const msg = err instanceof Error ? err.message : 'failed to spawn editor'
1026
+ } catch (error) {
1027
+ const msg = error instanceof Error ? error.message : 'failed to spawn editor'
813
1028
  ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
814
1029
  } finally {
815
1030
  renderer.currentRenderBuffer.clear()
@@ -820,9 +1035,9 @@ async function openEditorInline(
820
1035
 
821
1036
  function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
822
1037
  const { backend, dispatch, state } = ctx
823
- const ordered = state.sessions
824
- .slice()
825
- .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
1038
+ const ordered = [...state.sessions].sort(
1039
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1040
+ )
826
1041
  const target = ordered[index - 1]
827
1042
  if (!target) {
828
1043
  logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
@@ -837,6 +1052,295 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
837
1052
  handleSwitchSessionEffect(state, backend, dispatch, target)
838
1053
  }
839
1054
 
1055
+ function replaceSession(
1056
+ state: AppState,
1057
+ sessionId: string,
1058
+ next: (session: AppState['sessions'][number]) => AppState['sessions'][number]
1059
+ ): AppState['sessions'] {
1060
+ return state.sessions.map((session) => (session.id === sessionId ? next(session) : session))
1061
+ }
1062
+
1063
+ function handleSwitchWorktree(ctx: SideEffectContext, sessionId: string, worktreeId: string): void {
1064
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1065
+ const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1066
+ if (!session || !worktree) return
1067
+ const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1068
+ ...entry,
1069
+ activeWorktreeId: worktreeId,
1070
+ projectPath: worktree.path,
1071
+ updatedAt: new Date().toISOString(),
1072
+ }))
1073
+ saveSessionCatalog(sessions)
1074
+ ctx.dispatch({ sessions, type: 'set-sessions' })
1075
+ }
1076
+
1077
+ function normalizeBranchName(branch: string | undefined): string | undefined {
1078
+ return branch?.replace(/^refs\/heads\//, '').trim()
1079
+ }
1080
+
1081
+ async function createAimuxTempWorktree(
1082
+ ctx: SideEffectContext,
1083
+ sessionId: string,
1084
+ requestedName?: string,
1085
+ requestedBranchName?: string,
1086
+ requestedBaseRef?: string,
1087
+ sourceWorktreeId?: string
1088
+ ): Promise<WorktreeRecord | undefined> {
1089
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1090
+ const source =
1091
+ session?.worktrees?.find((entry) => entry.id === sourceWorktreeId) ?? getActiveWorktree(session)
1092
+ const sourcePath = source?.path ?? getSessionProjectPath(session)
1093
+ if (!session || !(sourcePath != null && sourcePath !== '')) return undefined
1094
+
1095
+ // Resolve the *main* repo checkout, never the active linked worktree, so the
1096
+ // record's repoRoot stays valid after sibling worktrees are deleted.
1097
+ const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
1098
+ const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
1099
+ const baseRef = requestedBaseRef ?? baseBranch
1100
+ const worktreeId = createPrefixedId('worktree')
1101
+ const trimmedName = requestedName?.trim()
1102
+ const worktreeName =
1103
+ trimmedName != null && trimmedName !== ''
1104
+ ? trimmedName
1105
+ : `wt-${sanitizePathSegment(session.name, 12)}`
1106
+ const trimmedBranch = requestedBranchName?.trim()
1107
+ const branchName =
1108
+ trimmedBranch != null && trimmedBranch !== ''
1109
+ ? trimmedBranch
1110
+ : `aimux/${sanitizePathSegment(worktreeName, 40)}-${Date.now().toString(36)}`
1111
+ const targetPath = makeWorktreePath({ repoRoot, worktreeId, worktreeName })
1112
+
1113
+ const existingWorktree = (await listGitWorktrees(repoRoot)).find(
1114
+ (entry) =>
1115
+ entry.prunable !== true &&
1116
+ normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
1117
+ )
1118
+ if (existingWorktree) {
1119
+ ctx.dispatch({
1120
+ message: `Branch already checked out in another worktree: ${existingWorktree.path}`,
1121
+ type: 'set-new-tab-branch-error',
1122
+ })
1123
+ return undefined
1124
+ }
1125
+
1126
+ await mkdir(dirname(targetPath), { recursive: true })
1127
+ await assertSafeAimuxWorktreePath(targetPath)
1128
+ await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
1129
+ // For JS projects, reuse the source checkout's installed dependencies instead
1130
+ // of forcing a fresh install in the new worktree.
1131
+ const linkedModules = await linkNodeModules(sourcePath, targetPath).catch(() => false)
1132
+ const now = new Date().toISOString()
1133
+
1134
+ const worktree: WorktreeRecord = {
1135
+ baseRef,
1136
+ branch: branchName,
1137
+ commitSha: await getHeadSha(targetPath),
1138
+ createdAt: now,
1139
+ createdByAimux: true,
1140
+ id: worktreeId,
1141
+ name: worktreeName,
1142
+ path: targetPath,
1143
+ repoRoot,
1144
+ source: 'aimux-temp',
1145
+ updatedAt: now,
1146
+ }
1147
+ const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1148
+ ...entry,
1149
+ activeWorktreeId: worktree.id,
1150
+ projectPath: worktree.path,
1151
+ updatedAt: now,
1152
+ worktrees: [...(entry.worktrees ?? []), worktree],
1153
+ }))
1154
+ saveSessionCatalog(sessions)
1155
+ ctx.dispatch({ sessions, type: 'set-sessions' })
1156
+ toast.success(
1157
+ linkedModules
1158
+ ? `Created worktree ${branchName} (linked node_modules)`
1159
+ : `Created worktree ${branchName}`
1160
+ )
1161
+ return worktree
1162
+ }
1163
+
1164
+ // Dispose and close every tab pinned to a worktree (timers, pty session, state).
1165
+ function disposeWorktreeTabs(ctx: SideEffectContext, worktreeId: string): void {
1166
+ for (const tab of ctx.state.tabs.filter((entry) => entry.worktreeId === worktreeId)) {
1167
+ ctx.clearIdleTimer(tab.id)
1168
+ ctx.clearStartupGrace(tab.id)
1169
+ ctx.backend.disposeSession(tab.id)
1170
+ ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
1171
+ }
1172
+ }
1173
+
1174
+ async function runDeleteWorktree(
1175
+ ctx: SideEffectContext,
1176
+ sessionId: string,
1177
+ worktreeId: string,
1178
+ force: boolean
1179
+ ): Promise<void> {
1180
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1181
+ const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1182
+ if (!session) throw new Error('session not found')
1183
+ if (!worktree) throw new Error('worktree not found')
1184
+ if ((session.worktrees?.length ?? 0) <= 1) throw new Error('at least one worktree must remain')
1185
+ if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
1186
+
1187
+ const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
1188
+ if (tabsInWorktree.length > 0 && !force) {
1189
+ throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1190
+ }
1191
+ disposeWorktreeTabs(ctx, worktreeId)
1192
+
1193
+ if (
1194
+ worktree.source === 'aimux-temp' &&
1195
+ worktree.createdByAimux &&
1196
+ isInsideAimuxWorktreeRoot(worktree.path) &&
1197
+ !existsSync(worktree.path)
1198
+ ) {
1199
+ removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1200
+ return
1201
+ }
1202
+
1203
+ if (
1204
+ worktree.source === 'aimux-temp' &&
1205
+ worktree.createdByAimux &&
1206
+ isInsideAimuxWorktreeRoot(worktree.path)
1207
+ ) {
1208
+ await assertSafeAimuxWorktreePath(worktree.path)
1209
+ await removeGitWorktree({
1210
+ force,
1211
+ repoPath: resolveWorktreeGitDir(session, worktree),
1212
+ targetPath: worktree.path,
1213
+ })
1214
+ } else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
1215
+ throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
1216
+ }
1217
+
1218
+ removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1219
+ }
1220
+
1221
+ // A worktree's stored repoRoot can point at a sibling worktree that was since
1222
+ // deleted. Git commands only need *some* existing worktree of the repo (they
1223
+ // share the common git dir), so fall back to the main checkout or any live
1224
+ // worktree path rather than letting `git -C` fail on a vanished directory.
1225
+ function resolveWorktreeGitDir(
1226
+ session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1227
+ worktree: WorktreeRecord
1228
+ ): string {
1229
+ const candidates = [
1230
+ session.worktrees?.find((entry) => entry.source === 'primary')?.path,
1231
+ worktree.repoRoot,
1232
+ ...(session.worktrees ?? [])
1233
+ .filter((entry) => entry.id !== worktree.id)
1234
+ .map((entry) => entry.path),
1235
+ ]
1236
+ return candidates.find((path) => path !== undefined && existsSync(path)) ?? worktree.repoRoot
1237
+ }
1238
+
1239
+ async function runMoveWorktree(
1240
+ ctx: SideEffectContext,
1241
+ sessionId: string,
1242
+ sourceWorktreeId: string,
1243
+ targetWorktreeId: string,
1244
+ deleteSource: boolean
1245
+ ): Promise<void> {
1246
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1247
+ const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
1248
+ const target = session?.worktrees?.find((entry) => entry.id === targetWorktreeId)
1249
+ if (!session) throw new Error('session not found')
1250
+ if (!source || !target) throw new Error('worktree not found')
1251
+ if (source.id === target.id) throw new Error('source and target are the same worktree')
1252
+ if (source.branch == null || source.branch === '') {
1253
+ throw new Error('source worktree has no branch to move')
1254
+ }
1255
+ if (deleteSource && source.source === 'primary') {
1256
+ throw new Error('the primary worktree cannot be deleted')
1257
+ }
1258
+
1259
+ const sourceLabel = source.branch ?? source.name
1260
+ const targetLabel = target.branch ?? target.name
1261
+ const result = await moveWorktree({
1262
+ sourceBranch: source.branch,
1263
+ sourcePath: source.path,
1264
+ targetPath: target.path,
1265
+ })
1266
+
1267
+ // Toasts surface the outcome everywhere — the picker can be opened outside git
1268
+ // mode (from a tab menu), where the git-pane message would never be seen.
1269
+ if (result.kind === 'dirty-target') {
1270
+ toast.warning(`Target ${targetLabel} has uncommitted changes — commit or stash it first`)
1271
+ return
1272
+ }
1273
+ if (result.kind === 'conflict') {
1274
+ toast.warning(
1275
+ `Move hit conflicts in ${result.files.length} file(s) — left ${sourceLabel} untouched`
1276
+ )
1277
+ return
1278
+ }
1279
+ if (result.kind === 'error') {
1280
+ toast.error(`Move failed: ${result.message}`)
1281
+ return
1282
+ }
1283
+
1284
+ // Land on the target. When deleting the source, close its terminals and
1285
+ // switch to the target up front, synchronously, BEFORE the slow
1286
+ // `git worktree remove`. Closing the source's active tab re-syncs the active
1287
+ // worktree to a default tab (withActiveTabWorktree); doing the close+switch in
1288
+ // one batch lands on the target with no intermediate render, so the removal
1289
+ // runs with the target already active instead of flashing/sticking to a
1290
+ // default worktree. Re-read state each step so we never resurrect the source.
1291
+ if (deleteSource) {
1292
+ disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, sourceWorktreeId)
1293
+ handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1294
+ await runDeleteWorktree({ ...ctx, state: ctx.getState() }, sessionId, sourceWorktreeId, true)
1295
+ } else {
1296
+ handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1297
+ }
1298
+ toast.success(
1299
+ `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit`
1300
+ )
1301
+ }
1302
+
1303
+ function removeWorktreeRecordFromSession(
1304
+ ctx: SideEffectContext,
1305
+ sessionId: string,
1306
+ session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1307
+ worktreeId: string
1308
+ ): void {
1309
+ const remaining = (session.worktrees ?? []).filter((entry) => entry.id !== worktreeId)
1310
+ const nextActive =
1311
+ session.activeWorktreeId === worktreeId ? remaining[0] : getActiveWorktree(session)
1312
+ const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1313
+ ...entry,
1314
+ activeWorktreeId: nextActive?.id,
1315
+ projectPath: nextActive?.path ?? entry.projectPath,
1316
+ updatedAt: new Date().toISOString(),
1317
+ worktrees: remaining,
1318
+ }))
1319
+ saveSessionCatalog(sessions)
1320
+ ctx.dispatch({ sessions, type: 'set-sessions' })
1321
+ if (ctx.state.modal.type === 'new-tab' && ctx.state.modal.step === 'worktree') {
1322
+ ctx.dispatch({
1323
+ index: Math.min(ctx.state.modal.selectedIndex, Math.max(0, remaining.length - 1)),
1324
+ type: 'set-modal-selection-index',
1325
+ })
1326
+ }
1327
+ ctx.dispatch({ message: null, type: 'set-new-tab-worktree-delete-state' })
1328
+ }
1329
+
1330
+ function isForceableWorktreeDeleteError(message: string): boolean {
1331
+ return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
1332
+ message
1333
+ )
1334
+ }
1335
+
1336
+ class ActiveWorktreeTabsError extends Error {
1337
+ constructor(tabCount: number) {
1338
+ super(
1339
+ `active assistant tabs are using this worktree (${tabCount}). Click [del] again to close them and delete the worktree.`
1340
+ )
1341
+ }
1342
+ }
1343
+
840
1344
  function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
841
1345
  const { state } = ctx
842
1346
  if (state.modal.type !== 'update-available') {
@@ -876,11 +1380,12 @@ async function runGitAction(
876
1380
  pathToInvalidate?: string
877
1381
  ): Promise<void> {
878
1382
  const fallback = ctx.getCurrentSessionProjectPath()
879
- const repoPath = pathToInvalidate
880
- ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
881
- : undefined
1383
+ const repoPath =
1384
+ pathToInvalidate != null && pathToInvalidate !== ''
1385
+ ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
1386
+ : undefined
882
1387
  const cwd = repoPath ?? fallback
883
- if (!cwd) return
1388
+ if (!(cwd != null && cwd !== '')) return
884
1389
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
885
1390
  if (result.exitCode !== 0) {
886
1391
  const stderr = result.stderr.toString().trim()
@@ -888,7 +1393,7 @@ async function runGitAction(
888
1393
  return
889
1394
  }
890
1395
  ctx.dispatch({ message: null, type: 'git-mode-set-message' })
891
- if (pathToInvalidate) {
1396
+ if (pathToInvalidate != null && pathToInvalidate !== '') {
892
1397
  ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
893
1398
  }
894
1399
  }
@@ -899,7 +1404,7 @@ async function runGitActionAll(
899
1404
  pathsToInvalidate: string[]
900
1405
  ): Promise<void> {
901
1406
  const cwd = ctx.getCurrentSessionProjectPath()
902
- if (!cwd) return
1407
+ if (!(cwd != null && cwd !== '')) return
903
1408
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
904
1409
  if (result.exitCode !== 0) {
905
1410
  const stderr = result.stderr.toString().trim()
@@ -915,17 +1420,15 @@ async function runGitActionAll(
915
1420
  async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
916
1421
  const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
917
1422
  const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
918
- if (!cwd) return
1423
+ if (!(cwd != null && cwd !== '')) return
919
1424
  const absolute = `${cwd}/${path}`
920
1425
  try {
921
1426
  const stat = await Bun.file(absolute).stat()
922
- if (stat.isDirectory()) {
923
- await Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
924
- } else {
925
- await Bun.file(absolute).unlink()
926
- }
927
- } catch (err) {
928
- const message = err instanceof Error ? err.message : 'failed to delete file'
1427
+ await (stat.isDirectory()
1428
+ ? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
1429
+ : Bun.file(absolute).unlink())
1430
+ } catch (error) {
1431
+ const message = error instanceof Error ? error.message : 'failed to delete file'
929
1432
  ctx.dispatch({ message, type: 'git-mode-set-message' })
930
1433
  return
931
1434
  }
@@ -935,7 +1438,7 @@ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
935
1438
 
936
1439
  async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
937
1440
  const cwd = ctx.getCurrentSessionProjectPath()
938
- if (!cwd) return
1441
+ if (!(cwd != null && cwd !== '')) return
939
1442
  if (!title) {
940
1443
  ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
941
1444
  return
@@ -945,14 +1448,15 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
945
1448
  : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
946
1449
  if (result.exitCode !== 0) {
947
1450
  const stderr = result.stderr.toString().trim()
948
- ctx.dispatch({
949
- message: stderr || 'commit failed',
950
- type: 'git-mode-set-message',
951
- })
1451
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1452
+ toast.error(stderr || 'Commit failed')
952
1453
  return
953
1454
  }
954
1455
  clearAutoCommitForCurrentSession(ctx)
955
- ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
1456
+ // Match the push flow: clear any inline git-pane message and surface the
1457
+ // result as a toast so it's seen even after leaving git mode.
1458
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1459
+ toast.success(`Committed: ${title}`)
956
1460
  }
957
1461
 
958
1462
  async function runGitCommitAuto(
@@ -965,7 +1469,7 @@ async function runGitCommitAuto(
965
1469
  return
966
1470
  }
967
1471
  const cwd = ctx.getCurrentSessionProjectPath()
968
- if (!cwd) return
1472
+ if (!(cwd != null && cwd !== '')) return
969
1473
 
970
1474
  // If the user has manually staged files, respect that intent and commit
971
1475
  // only the staged set — don't run `git add -A` which would sweep up
@@ -976,10 +1480,8 @@ async function runGitCommitAuto(
976
1480
  const addArgs = ['add', '-A']
977
1481
  const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
978
1482
  if (addResult.exitCode !== 0) {
979
- ctx.dispatch({
980
- message: addResult.stderr.toString().trim() || 'auto-commit: git add failed',
981
- type: 'git-mode-set-message',
982
- })
1483
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1484
+ toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
983
1485
  return
984
1486
  }
985
1487
  }
@@ -989,10 +1491,8 @@ async function runGitCommitAuto(
989
1491
  : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
990
1492
 
991
1493
  if (commitResult.exitCode !== 0) {
992
- ctx.dispatch({
993
- message: commitResult.stderr.toString().trim() || 'auto-commit: commit failed',
994
- type: 'git-mode-set-message',
995
- })
1494
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1495
+ toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
996
1496
  return
997
1497
  }
998
1498
 
@@ -1002,7 +1502,7 @@ async function runGitCommitAuto(
1002
1502
 
1003
1503
  function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
1004
1504
  const sessionId = ctx.state.currentSessionId
1005
- if (!sessionId) return
1505
+ if (!(sessionId != null && sessionId !== '')) return
1006
1506
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1007
1507
  }
1008
1508
 
@@ -1010,16 +1510,13 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
1010
1510
  const session = ctx.state.sessions.find((s) => s.id === sessionId)
1011
1511
  const panel = ctx.state.gitPanel
1012
1512
  if (panel.error !== null) {
1013
- ctx.dispatch({ message: 'auto-commit: git panel unavailable', type: 'git-mode-set-message' })
1513
+ toast.warning('Auto-commit: git panel unavailable')
1014
1514
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1015
1515
  return
1016
1516
  }
1017
1517
  const tab = ctx.activeTab
1018
1518
  if (!tab) {
1019
- ctx.dispatch({
1020
- message: 'auto-commit: no active assistant tab — open a claude/codex session first',
1021
- type: 'git-mode-set-message',
1022
- })
1519
+ toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
1023
1520
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1024
1521
  return
1025
1522
  }
@@ -1039,7 +1536,7 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
1039
1536
 
1040
1537
  async function runGitPush(ctx: SideEffectContext): Promise<void> {
1041
1538
  const cwd = ctx.getCurrentSessionProjectPath()
1042
- if (!cwd) return
1539
+ if (!(cwd != null && cwd !== '')) return
1043
1540
  ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
1044
1541
 
1045
1542
  const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
@@ -1051,13 +1548,12 @@ async function runGitPush(ctx: SideEffectContext): Promise<void> {
1051
1548
  ? await $`git -C ${cwd} push`.quiet().nothrow()
1052
1549
  : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
1053
1550
 
1551
+ // Clear the inline "pushing…" progress; surface the result as a toast so it's
1552
+ // visible even after leaving git mode (and so push failures aren't missed).
1553
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1054
1554
  if (result.exitCode !== 0) {
1055
- const stderr = result.stderr.toString().trim()
1056
- ctx.dispatch({
1057
- message: stderr || 'push failed',
1058
- type: 'git-mode-set-message',
1059
- })
1555
+ toast.error(result.stderr.toString().trim() || 'Push failed')
1060
1556
  return
1061
1557
  }
1062
- ctx.dispatch({ message: 'pushed', type: 'git-mode-set-message' })
1558
+ toast.success('Pushed')
1063
1559
  }