@brimveyn/aimux 1.12.6 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +29 -13
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +563 -85
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +21 -11
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +19 -3
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +60 -45
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
  91. package/src/ui/components/git/git-panel.tsx +129 -83
  92. package/src/ui/components/git/git-view.tsx +96 -28
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +135 -117
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +27 -20
  103. package/src/ui/components/layout/terminal-pane.tsx +163 -120
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +29 -20
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
  109. package/src/ui/components/modals/shared/form.tsx +6 -6
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +71 -32
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
  114. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  115. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
  116. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
  117. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  118. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  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 +23 -7
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +121 -38
  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,21 +1,40 @@
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
10
  import { existsSync } from 'node:fs'
10
- import { join as joinPath, resolve as resolvePath } from 'node:path'
11
+ import { mkdir } from 'node:fs/promises'
12
+ import { dirname, join as joinPath, resolve as resolvePath } from 'node:path'
11
13
 
12
14
  import type { SideEffect } from '../input/modes/types'
13
15
  import type { SessionBackend } from '../session-backend/types'
16
+ import type { ThemeId } from '../ui/themes'
14
17
 
15
18
  import { loadConfig, saveConfig } from '../config'
16
19
  import { logInputDebug } from '../debug/input-log'
17
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'
18
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'
19
38
  import { getProfileConfigDir } from '../profile-paths'
20
39
  import {
21
40
  getAllAssistantOptions,
@@ -35,20 +54,23 @@ import {
35
54
  splitNode,
36
55
  } from '../state/layout-tree'
37
56
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
57
+ import { saveSessionCatalog } from '../state/session-catalog'
58
+ import { getActiveWorktree, getSessionProjectPath } from '../state/session-worktrees'
38
59
  import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
39
60
  import { createDefaultTerminalModes } from '../state/terminal-modes'
61
+ import { toast } from '../state/toast-store'
40
62
  import {
41
63
  type AppAction,
42
64
  type AppState,
43
65
  type AssistantId,
44
66
  DEFAULT_SCROLL_INTENT,
45
67
  type TabSession,
68
+ type WorktreeRecord,
46
69
  } from '../state/types'
47
70
  import { saveCurrentWorkspace } from '../state/workspace-save'
48
71
  import { filterThemeIds } from '../ui/filter-themes'
49
72
  import { scrollGitDiff } from '../ui/git-view-controls'
50
73
  import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
51
- import { type ThemeId } from '../ui/themes'
52
74
  import { triggerAutoCommitNow } from './auto-commit-ref'
53
75
  import {
54
76
  handleCreateSessionEffect,
@@ -76,11 +98,16 @@ export interface SideEffectContext {
76
98
  clearIdleTimer: (tabId: string) => void
77
99
  clearStartupGrace: (tabId: string) => void
78
100
  startStartupGrace: (tabId: string, timeoutMs: number) => void
101
+ getState: () => AppState
79
102
  getCurrentSessionProjectPath: () => string | undefined
80
103
  }
81
104
 
82
105
  function getSelectedAssistantOption(state: AppState) {
83
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
+ }
84
111
  const filter = state.modal.type === 'new-tab' ? state.modal.editBuffer : null
85
112
  const list = filterAssistants(all, filter)
86
113
  return list[state.modal.selectedIndex] ?? list[0] ?? getAssistantOption(0)
@@ -139,12 +166,12 @@ function openSelectedSessionRename(ctx: SideEffectContext): void {
139
166
  function pasteSnippetToActiveGroup(ctx: SideEffectContext): void {
140
167
  const { activeTab, backend, state } = ctx
141
168
  const snippet = getSelectedSnippet(state)
142
- if (!snippet || !state.activeTabId) {
169
+ if (!snippet || !(state.activeTabId != null && state.activeTabId !== '')) {
143
170
  return
144
171
  }
145
172
 
146
173
  const groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
147
- const groupTree = groupId ? state.layoutTrees[groupId] : null
174
+ const groupTree = groupId != null && groupId !== '' ? state.layoutTrees[groupId] : null
148
175
  if (!groupTree) {
149
176
  pasteSnippetToTab(backend, state.activeTabId, activeTab, snippet, ctx.dispatch)
150
177
  return
@@ -199,7 +226,7 @@ function applyThemeEffect(
199
226
  return
200
227
  case 'confirm': {
201
228
  const selectedId = ids[state.modal.selectedIndex]
202
- if (selectedId) {
229
+ if (selectedId != null && selectedId !== '') {
203
230
  applyTheme(selectedId)
204
231
  ctx.setThemeId(selectedId)
205
232
  saveConfig({ ...loadConfig(), themeId: selectedId })
@@ -210,7 +237,7 @@ function applyThemeEffect(
210
237
  if (ids.length === 0) return
211
238
  const nextIndex = (state.modal.selectedIndex + effect.delta + ids.length) % ids.length
212
239
  const previewId = ids[nextIndex]
213
- if (previewId) {
240
+ if (previewId != null && previewId !== '') {
214
241
  applyTheme(previewId)
215
242
  }
216
243
  return
@@ -223,7 +250,16 @@ function confirmSplitSelection(ctx: SideEffectContext): void {
223
250
  const option = getSelectedAssistantOption(state)
224
251
  const direction = state.modal.type === 'split-picker' ? state.modal.splitDirection : 'vertical'
225
252
  const customCommand = state.customCommands[option.id]
226
- 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
+ )
227
263
  dispatch({ type: 'close-modal' })
228
264
  executeSplitPane(ctx, direction, tab)
229
265
  dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
@@ -246,7 +282,8 @@ function getSelectedSnippet(state: AppState) {
246
282
  export function createTabSession(
247
283
  assistant: AssistantId,
248
284
  customCommand?: string,
249
- customCommands?: Record<string, string>
285
+ customCommands?: Record<string, string>,
286
+ worktreeId?: string
250
287
  ): TabSession {
251
288
  const allOptions = getAllAssistantOptions(customCommands ?? {})
252
289
  const option = allOptions.find((o) => o.id === assistant) ?? getAssistantOption(0)
@@ -261,6 +298,7 @@ export function createTabSession(
261
298
  status: 'starting',
262
299
  terminalModes: createDefaultTerminalModes(),
263
300
  title: option.label,
301
+ worktreeId,
264
302
  }
265
303
  }
266
304
 
@@ -308,10 +346,38 @@ export function startTabSession(
308
346
  })
309
347
  }
310
348
 
311
- 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 {
312
368
  const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
313
369
  const customCommand = state.customCommands[assistant]
314
- 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
+ )
315
381
  logInputDebug('app.launchAssistant', {
316
382
  assistant,
317
383
  command: tab.command,
@@ -327,10 +393,60 @@ function launchAssistant(ctx: SideEffectContext, assistant: AssistantId): void {
327
393
  tab,
328
394
  state.layout.terminalCols,
329
395
  state.layout.terminalRows,
330
- 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
331
432
  )
332
433
  }
333
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
+
334
450
  function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
335
451
  const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
336
452
  startTabSession(
@@ -341,7 +457,7 @@ function startExistingTab(ctx: SideEffectContext, tab: TabSession): void {
341
457
  tab,
342
458
  state.layout.terminalCols,
343
459
  state.layout.terminalRows,
344
- ctx.getCurrentSessionProjectPath()
460
+ getTabProjectPath(ctx, tab)
345
461
  )
346
462
  }
347
463
 
@@ -352,7 +468,7 @@ function executeSplitPane(
352
468
  ): void {
353
469
  const { backend, clearStartupGrace, dispatch, startStartupGrace, state } = ctx
354
470
  const activeTabId = state.activeTabId
355
- if (!activeTabId) {
471
+ if (!(activeTabId != null && activeTabId !== '')) {
356
472
  return
357
473
  }
358
474
 
@@ -371,7 +487,7 @@ function executeSplitPane(
371
487
  tab,
372
488
  Math.max(1, (paneRect?.cols ?? state.layout.terminalCols) - PANE_BORDER * 2),
373
489
  Math.max(1, (paneRect?.rows ?? state.layout.terminalRows) - PANE_BORDER * 2),
374
- ctx.getCurrentSessionProjectPath()
490
+ getTabProjectPath(ctx, tab)
375
491
  )
376
492
  }
377
493
 
@@ -388,7 +504,28 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
388
504
  }
389
505
  case 'launch-selected-assistant': {
390
506
  const option = getSelectedAssistantOption(state)
391
- 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 (async () => {
512
+ try {
513
+ await enqueueGitOp(async () =>
514
+ launchAssistantInNewWorktree(
515
+ ctx,
516
+ option.id,
517
+ worktreeName,
518
+ branchName,
519
+ sourceWorktreeId
520
+ )
521
+ )
522
+ } catch (error) {
523
+ toast.error(error instanceof Error ? error.message : String(error))
524
+ }
525
+ })()
526
+ return
527
+ }
528
+ launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
392
529
  return
393
530
  }
394
531
  case 'edit-selected-assistant': {
@@ -408,6 +545,57 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
408
545
  handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
409
546
  return
410
547
  }
548
+ case 'delete-worktree': {
549
+ void (async () => {
550
+ try {
551
+ await enqueueGitOp(async () =>
552
+ runDeleteWorktree(
553
+ { ...ctx, state: ctx.getState() },
554
+ effect.sessionId,
555
+ effect.worktreeId,
556
+ !!(effect.force === true)
557
+ )
558
+ )
559
+ } catch (error) {
560
+ const message = error instanceof Error ? error.message : String(error)
561
+ const forceable = isForceableWorktreeDeleteError(message)
562
+ const latest = ctx.getState()
563
+ if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
564
+ const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
565
+ const selected = session?.worktrees?.[latest.modal.selectedIndex]
566
+ if (selected && selected.id !== effect.worktreeId) {
567
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
568
+ return
569
+ }
570
+ }
571
+ ctx.dispatch({
572
+ confirmWorktreeId: forceable ? effect.worktreeId : null,
573
+ message: forceable ? message : `Could not delete worktree: ${message}`,
574
+ type: 'set-new-tab-worktree-delete-state',
575
+ })
576
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
577
+ }
578
+ })()
579
+ return
580
+ }
581
+ case 'move-worktree': {
582
+ void (async () => {
583
+ try {
584
+ await enqueueGitOp(async () =>
585
+ runMoveWorktree(
586
+ { ...ctx, state: ctx.getState() },
587
+ effect.sessionId,
588
+ effect.sourceWorktreeId,
589
+ effect.targetWorktreeId,
590
+ effect.deleteSource === true
591
+ )
592
+ )
593
+ } catch (error) {
594
+ toast.error(error instanceof Error ? error.message : String(error))
595
+ }
596
+ })()
597
+ return
598
+ }
411
599
  case 'open-rename-selected-session': {
412
600
  openSelectedSessionRename(ctx)
413
601
  return
@@ -476,12 +664,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
476
664
  return
477
665
  }
478
666
  case 'split-pane': {
479
- const sourceTab = effect.sourceTabId
480
- ? state.tabs.find((t) => t.id === effect.sourceTabId)
481
- : undefined
667
+ const sourceTab =
668
+ effect.sourceTabId != null && effect.sourceTabId !== ''
669
+ ? state.tabs.find((t) => t.id === effect.sourceTabId)
670
+ : undefined
482
671
  const assistant = sourceTab?.assistant ?? 'terminal'
483
672
  const customCommand = state.customCommands[assistant]
484
- const tab = createTabSession(assistant, customCommand, state.customCommands)
673
+ const tab = createTabSession(
674
+ assistant,
675
+ customCommand,
676
+ state.customCommands,
677
+ sourceTab?.worktreeId
678
+ )
485
679
  executeSplitPane(ctx, effect.direction, tab)
486
680
  return
487
681
  }
@@ -554,42 +748,42 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
554
748
  return
555
749
  }
556
750
  case 'git-stage': {
557
- void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
751
+ void enqueueGitOp(async () => runGitAction(ctx, ['add', '--', effect.path], effect.path))
558
752
  return
559
753
  }
560
754
  case 'git-unstage': {
561
- void enqueueGitOp(() =>
755
+ void enqueueGitOp(async () =>
562
756
  runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
563
757
  )
564
758
  return
565
759
  }
566
760
  case 'git-stage-all': {
567
761
  const paths = ctx.state.gitPanel.files.map((f) => f.path)
568
- void enqueueGitOp(() => runGitActionAll(ctx, ['add', '-A'], paths))
762
+ void enqueueGitOp(async () => runGitActionAll(ctx, ['add', '-A'], paths))
569
763
  return
570
764
  }
571
765
  case 'git-unstage-all': {
572
766
  const paths = ctx.state.gitPanel.files.map((f) => f.path)
573
- void enqueueGitOp(() => runGitActionAll(ctx, ['reset'], paths))
767
+ void enqueueGitOp(async () => runGitActionAll(ctx, ['reset'], paths))
574
768
  return
575
769
  }
576
770
  case 'git-restore': {
577
- void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
771
+ void enqueueGitOp(async () => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
578
772
  return
579
773
  }
580
774
  case 'git-rm': {
581
- void enqueueGitOp(() => runGitRm(ctx, effect.path))
775
+ void enqueueGitOp(async () => runGitRm(ctx, effect.path))
582
776
  return
583
777
  }
584
778
  case 'git-commit': {
585
779
  const { body, title } = effect
586
- void enqueueGitOp(() => runGitCommit(ctx, title, body))
780
+ void enqueueGitOp(async () => runGitCommit(ctx, title, body))
587
781
  return
588
782
  }
589
783
  case 'git-commit-auto': {
590
784
  if (!isAutoCommitEnabled()) return
591
785
  const { body, title } = effect
592
- void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
786
+ void enqueueGitOp(async () => runGitCommitAuto(ctx, title, body))
593
787
  return
594
788
  }
595
789
  case 'generate-auto-commit-now': {
@@ -598,7 +792,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
598
792
  return
599
793
  }
600
794
  case 'git-push': {
601
- void enqueueGitOp(() => runGitPush(ctx))
795
+ void enqueueGitOp(async () => runGitPush(ctx))
602
796
  return
603
797
  }
604
798
  case 'confirm-update-selection': {
@@ -666,7 +860,7 @@ function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
666
860
  function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
667
861
  const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
668
862
  const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
669
- if (!cwd) {
863
+ if (!(cwd != null && cwd !== '')) {
670
864
  ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
671
865
  return
672
866
  }
@@ -684,14 +878,14 @@ function launchEditorOnFile(
684
878
  ): void {
685
879
  const config = getExternalEditorConfig()
686
880
  const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
687
- if (!rawCommand || rawCommand.trim() === '') {
881
+ if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
688
882
  onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
689
883
  return
690
884
  }
691
885
 
692
886
  const cmdParts = shellSplit(rawCommand)
693
887
  const executable = cmdParts[0]
694
- if (!executable) {
888
+ if (!(executable != null && executable !== '')) {
695
889
  onError('invalid editor command')
696
890
  return
697
891
  }
@@ -753,7 +947,7 @@ function substituteEditorArgs(template: string[], file: string, line?: string):
753
947
  // Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
754
948
  if (/^[+:]?\{line\}$/.test(arg)) continue
755
949
  // Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
756
- out.push(arg.replace(/[:+]\{line\}/g, '').replaceAll('{file}', file))
950
+ out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
757
951
  }
758
952
  return out
759
953
  }
@@ -816,13 +1010,15 @@ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): vo
816
1010
  const stderr = await new Response(child.stderr).text()
817
1011
  const code = await child.exited
818
1012
  if (code !== 0) {
819
- const firstLine = stderr.trim().split('\n')[0] || `exit ${code}`
1013
+ const firstStderrLine = stderr.trim().split('\n')[0]
1014
+ const firstLine =
1015
+ firstStderrLine != null && firstStderrLine !== '' ? firstStderrLine : `exit ${code}`
820
1016
  ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
821
1017
  }
822
1018
  })()
823
1019
  child.unref()
824
- } catch (err) {
825
- const msg = err instanceof Error ? err.message : 'failed to spawn'
1020
+ } catch (error) {
1021
+ const msg = error instanceof Error ? error.message : 'failed to spawn'
826
1022
  ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
827
1023
  }
828
1024
  }
@@ -849,8 +1045,8 @@ async function openEditorInline(
849
1045
  stdout: 'inherit',
850
1046
  })
851
1047
  await proc.exited
852
- } catch (err) {
853
- const msg = err instanceof Error ? err.message : 'failed to spawn editor'
1048
+ } catch (error) {
1049
+ const msg = error instanceof Error ? error.message : 'failed to spawn editor'
854
1050
  ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
855
1051
  } finally {
856
1052
  renderer.currentRenderBuffer.clear()
@@ -861,9 +1057,9 @@ async function openEditorInline(
861
1057
 
862
1058
  function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
863
1059
  const { backend, dispatch, state } = ctx
864
- const ordered = state.sessions
865
- .slice()
866
- .sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
1060
+ const ordered = [...state.sessions].sort(
1061
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1062
+ )
867
1063
  const target = ordered[index - 1]
868
1064
  if (!target) {
869
1065
  logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
@@ -878,6 +1074,295 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
878
1074
  handleSwitchSessionEffect(state, backend, dispatch, target)
879
1075
  }
880
1076
 
1077
+ function replaceSession(
1078
+ state: AppState,
1079
+ sessionId: string,
1080
+ next: (session: AppState['sessions'][number]) => AppState['sessions'][number]
1081
+ ): AppState['sessions'] {
1082
+ return state.sessions.map((session) => (session.id === sessionId ? next(session) : session))
1083
+ }
1084
+
1085
+ function handleSwitchWorktree(ctx: SideEffectContext, sessionId: string, worktreeId: string): void {
1086
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1087
+ const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1088
+ if (!session || !worktree) return
1089
+ const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1090
+ ...entry,
1091
+ activeWorktreeId: worktreeId,
1092
+ projectPath: worktree.path,
1093
+ updatedAt: new Date().toISOString(),
1094
+ }))
1095
+ saveSessionCatalog(sessions)
1096
+ ctx.dispatch({ sessions, type: 'set-sessions' })
1097
+ }
1098
+
1099
+ function normalizeBranchName(branch: string | undefined): string | undefined {
1100
+ return branch?.replace(/^refs\/heads\//, '').trim()
1101
+ }
1102
+
1103
+ async function createAimuxTempWorktree(
1104
+ ctx: SideEffectContext,
1105
+ sessionId: string,
1106
+ requestedName?: string,
1107
+ requestedBranchName?: string,
1108
+ requestedBaseRef?: string,
1109
+ sourceWorktreeId?: string
1110
+ ): Promise<WorktreeRecord | undefined> {
1111
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1112
+ const source =
1113
+ session?.worktrees?.find((entry) => entry.id === sourceWorktreeId) ?? getActiveWorktree(session)
1114
+ const sourcePath = source?.path ?? getSessionProjectPath(session)
1115
+ if (!session || !(sourcePath != null && sourcePath !== '')) return undefined
1116
+
1117
+ // Resolve the *main* repo checkout, never the active linked worktree, so the
1118
+ // record's repoRoot stays valid after sibling worktrees are deleted.
1119
+ const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
1120
+ const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
1121
+ const baseRef = requestedBaseRef ?? baseBranch
1122
+ const worktreeId = createPrefixedId('worktree')
1123
+ const trimmedName = requestedName?.trim()
1124
+ const worktreeName =
1125
+ trimmedName != null && trimmedName !== ''
1126
+ ? trimmedName
1127
+ : `wt-${sanitizePathSegment(session.name, 12)}`
1128
+ const trimmedBranch = requestedBranchName?.trim()
1129
+ const branchName =
1130
+ trimmedBranch != null && trimmedBranch !== ''
1131
+ ? trimmedBranch
1132
+ : `aimux/${sanitizePathSegment(worktreeName, 40)}-${Date.now().toString(36)}`
1133
+ const targetPath = makeWorktreePath({ repoRoot, worktreeId, worktreeName })
1134
+
1135
+ const existingWorktree = (await listGitWorktrees(repoRoot)).find(
1136
+ (entry) =>
1137
+ entry.prunable !== true &&
1138
+ normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
1139
+ )
1140
+ if (existingWorktree) {
1141
+ ctx.dispatch({
1142
+ message: `Branch already checked out in another worktree: ${existingWorktree.path}`,
1143
+ type: 'set-new-tab-branch-error',
1144
+ })
1145
+ return undefined
1146
+ }
1147
+
1148
+ await mkdir(dirname(targetPath), { recursive: true })
1149
+ await assertSafeAimuxWorktreePath(targetPath)
1150
+ await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
1151
+ // For JS projects, reuse the source checkout's installed dependencies instead
1152
+ // of forcing a fresh install in the new worktree.
1153
+ const linkedModules = await linkNodeModules(sourcePath, targetPath).catch(() => false)
1154
+ const now = new Date().toISOString()
1155
+
1156
+ const worktree: WorktreeRecord = {
1157
+ baseRef,
1158
+ branch: branchName,
1159
+ commitSha: await getHeadSha(targetPath),
1160
+ createdAt: now,
1161
+ createdByAimux: true,
1162
+ id: worktreeId,
1163
+ name: worktreeName,
1164
+ path: targetPath,
1165
+ repoRoot,
1166
+ source: 'aimux-temp',
1167
+ updatedAt: now,
1168
+ }
1169
+ const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1170
+ ...entry,
1171
+ activeWorktreeId: worktree.id,
1172
+ projectPath: worktree.path,
1173
+ updatedAt: now,
1174
+ worktrees: [...(entry.worktrees ?? []), worktree],
1175
+ }))
1176
+ saveSessionCatalog(sessions)
1177
+ ctx.dispatch({ sessions, type: 'set-sessions' })
1178
+ toast.success(
1179
+ linkedModules
1180
+ ? `Created worktree ${branchName} (linked node_modules)`
1181
+ : `Created worktree ${branchName}`
1182
+ )
1183
+ return worktree
1184
+ }
1185
+
1186
+ // Dispose and close every tab pinned to a worktree (timers, pty session, state).
1187
+ function disposeWorktreeTabs(ctx: SideEffectContext, worktreeId: string): void {
1188
+ for (const tab of ctx.state.tabs.filter((entry) => entry.worktreeId === worktreeId)) {
1189
+ ctx.clearIdleTimer(tab.id)
1190
+ ctx.clearStartupGrace(tab.id)
1191
+ ctx.backend.disposeSession(tab.id)
1192
+ ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
1193
+ }
1194
+ }
1195
+
1196
+ async function runDeleteWorktree(
1197
+ ctx: SideEffectContext,
1198
+ sessionId: string,
1199
+ worktreeId: string,
1200
+ force: boolean
1201
+ ): Promise<void> {
1202
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1203
+ const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
1204
+ if (!session) throw new Error('session not found')
1205
+ if (!worktree) throw new Error('worktree not found')
1206
+ if ((session.worktrees?.length ?? 0) <= 1) throw new Error('at least one worktree must remain')
1207
+ if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
1208
+
1209
+ const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
1210
+ if (tabsInWorktree.length > 0 && !force) {
1211
+ throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1212
+ }
1213
+ disposeWorktreeTabs(ctx, worktreeId)
1214
+
1215
+ if (
1216
+ worktree.source === 'aimux-temp' &&
1217
+ worktree.createdByAimux &&
1218
+ isInsideAimuxWorktreeRoot(worktree.path) &&
1219
+ !existsSync(worktree.path)
1220
+ ) {
1221
+ removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1222
+ return
1223
+ }
1224
+
1225
+ if (
1226
+ worktree.source === 'aimux-temp' &&
1227
+ worktree.createdByAimux &&
1228
+ isInsideAimuxWorktreeRoot(worktree.path)
1229
+ ) {
1230
+ await assertSafeAimuxWorktreePath(worktree.path)
1231
+ await removeGitWorktree({
1232
+ force,
1233
+ repoPath: resolveWorktreeGitDir(session, worktree),
1234
+ targetPath: worktree.path,
1235
+ })
1236
+ } else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
1237
+ throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
1238
+ }
1239
+
1240
+ removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1241
+ }
1242
+
1243
+ // A worktree's stored repoRoot can point at a sibling worktree that was since
1244
+ // deleted. Git commands only need *some* existing worktree of the repo (they
1245
+ // share the common git dir), so fall back to the main checkout or any live
1246
+ // worktree path rather than letting `git -C` fail on a vanished directory.
1247
+ function resolveWorktreeGitDir(
1248
+ session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1249
+ worktree: WorktreeRecord
1250
+ ): string {
1251
+ const candidates = [
1252
+ session.worktrees?.find((entry) => entry.source === 'primary')?.path,
1253
+ worktree.repoRoot,
1254
+ ...(session.worktrees ?? [])
1255
+ .filter((entry) => entry.id !== worktree.id)
1256
+ .map((entry) => entry.path),
1257
+ ]
1258
+ return candidates.find((path) => path !== undefined && existsSync(path)) ?? worktree.repoRoot
1259
+ }
1260
+
1261
+ async function runMoveWorktree(
1262
+ ctx: SideEffectContext,
1263
+ sessionId: string,
1264
+ sourceWorktreeId: string,
1265
+ targetWorktreeId: string,
1266
+ deleteSource: boolean
1267
+ ): Promise<void> {
1268
+ const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1269
+ const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
1270
+ const target = session?.worktrees?.find((entry) => entry.id === targetWorktreeId)
1271
+ if (!session) throw new Error('session not found')
1272
+ if (!source || !target) throw new Error('worktree not found')
1273
+ if (source.id === target.id) throw new Error('source and target are the same worktree')
1274
+ if (source.branch == null || source.branch === '') {
1275
+ throw new Error('source worktree has no branch to move')
1276
+ }
1277
+ if (deleteSource && source.source === 'primary') {
1278
+ throw new Error('the primary worktree cannot be deleted')
1279
+ }
1280
+
1281
+ const sourceLabel = source.branch ?? source.name
1282
+ const targetLabel = target.branch ?? target.name
1283
+ const result = await moveWorktree({
1284
+ sourceBranch: source.branch,
1285
+ sourcePath: source.path,
1286
+ targetPath: target.path,
1287
+ })
1288
+
1289
+ // Toasts surface the outcome everywhere — the picker can be opened outside git
1290
+ // mode (from a tab menu), where the git-pane message would never be seen.
1291
+ if (result.kind === 'dirty-target') {
1292
+ toast.warning(`Target ${targetLabel} has uncommitted changes — commit or stash it first`)
1293
+ return
1294
+ }
1295
+ if (result.kind === 'conflict') {
1296
+ toast.warning(
1297
+ `Move hit conflicts in ${result.files.length} file(s) — left ${sourceLabel} untouched`
1298
+ )
1299
+ return
1300
+ }
1301
+ if (result.kind === 'error') {
1302
+ toast.error(`Move failed: ${result.message}`)
1303
+ return
1304
+ }
1305
+
1306
+ // Land on the target. When deleting the source, close its terminals and
1307
+ // switch to the target up front, synchronously, BEFORE the slow
1308
+ // `git worktree remove`. Closing the source's active tab re-syncs the active
1309
+ // worktree to a default tab (withActiveTabWorktree); doing the close+switch in
1310
+ // one batch lands on the target with no intermediate render, so the removal
1311
+ // runs with the target already active instead of flashing/sticking to a
1312
+ // default worktree. Re-read state each step so we never resurrect the source.
1313
+ if (deleteSource) {
1314
+ disposeWorktreeTabs({ ...ctx, state: ctx.getState() }, sourceWorktreeId)
1315
+ handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1316
+ await runDeleteWorktree({ ...ctx, state: ctx.getState() }, sessionId, sourceWorktreeId, true)
1317
+ } else {
1318
+ handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1319
+ }
1320
+ toast.success(
1321
+ `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit`
1322
+ )
1323
+ }
1324
+
1325
+ function removeWorktreeRecordFromSession(
1326
+ ctx: SideEffectContext,
1327
+ sessionId: string,
1328
+ session: NonNullable<SideEffectContext['state']['sessions'][number]>,
1329
+ worktreeId: string
1330
+ ): void {
1331
+ const remaining = (session.worktrees ?? []).filter((entry) => entry.id !== worktreeId)
1332
+ const nextActive =
1333
+ session.activeWorktreeId === worktreeId ? remaining[0] : getActiveWorktree(session)
1334
+ const sessions = replaceSession(ctx.state, sessionId, (entry) => ({
1335
+ ...entry,
1336
+ activeWorktreeId: nextActive?.id,
1337
+ projectPath: nextActive?.path ?? entry.projectPath,
1338
+ updatedAt: new Date().toISOString(),
1339
+ worktrees: remaining,
1340
+ }))
1341
+ saveSessionCatalog(sessions)
1342
+ ctx.dispatch({ sessions, type: 'set-sessions' })
1343
+ if (ctx.state.modal.type === 'new-tab' && ctx.state.modal.step === 'worktree') {
1344
+ ctx.dispatch({
1345
+ index: Math.min(ctx.state.modal.selectedIndex, Math.max(0, remaining.length - 1)),
1346
+ type: 'set-modal-selection-index',
1347
+ })
1348
+ }
1349
+ ctx.dispatch({ message: null, type: 'set-new-tab-worktree-delete-state' })
1350
+ }
1351
+
1352
+ function isForceableWorktreeDeleteError(message: string): boolean {
1353
+ return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
1354
+ message
1355
+ )
1356
+ }
1357
+
1358
+ class ActiveWorktreeTabsError extends Error {
1359
+ constructor(tabCount: number) {
1360
+ super(
1361
+ `active assistant tabs are using this worktree (${tabCount}). Click [del] again to close them and delete the worktree.`
1362
+ )
1363
+ }
1364
+ }
1365
+
881
1366
  function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
882
1367
  const { state } = ctx
883
1368
  if (state.modal.type !== 'update-available') {
@@ -901,14 +1386,15 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
901
1386
  stdin: 'inherit',
902
1387
  stdout: 'inherit',
903
1388
  })
904
- void proc.exited.then((code) => {
1389
+ void (async () => {
1390
+ const code = await proc.exited
905
1391
  if (code === 0) {
906
1392
  process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
907
1393
  } else {
908
1394
  process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
909
1395
  }
910
1396
  process.exit(code ?? 1)
911
- })
1397
+ })()
912
1398
  }
913
1399
 
914
1400
  async function runGitAction(
@@ -917,11 +1403,12 @@ async function runGitAction(
917
1403
  pathToInvalidate?: string
918
1404
  ): Promise<void> {
919
1405
  const fallback = ctx.getCurrentSessionProjectPath()
920
- const repoPath = pathToInvalidate
921
- ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
922
- : undefined
1406
+ const repoPath =
1407
+ pathToInvalidate != null && pathToInvalidate !== ''
1408
+ ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
1409
+ : undefined
923
1410
  const cwd = repoPath ?? fallback
924
- if (!cwd) return
1411
+ if (!(cwd != null && cwd !== '')) return
925
1412
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
926
1413
  if (result.exitCode !== 0) {
927
1414
  const stderr = result.stderr.toString().trim()
@@ -929,7 +1416,7 @@ async function runGitAction(
929
1416
  return
930
1417
  }
931
1418
  ctx.dispatch({ message: null, type: 'git-mode-set-message' })
932
- if (pathToInvalidate) {
1419
+ if (pathToInvalidate != null && pathToInvalidate !== '') {
933
1420
  ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
934
1421
  }
935
1422
  }
@@ -940,7 +1427,7 @@ async function runGitActionAll(
940
1427
  pathsToInvalidate: string[]
941
1428
  ): Promise<void> {
942
1429
  const cwd = ctx.getCurrentSessionProjectPath()
943
- if (!cwd) return
1430
+ if (!(cwd != null && cwd !== '')) return
944
1431
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
945
1432
  if (result.exitCode !== 0) {
946
1433
  const stderr = result.stderr.toString().trim()
@@ -956,17 +1443,15 @@ async function runGitActionAll(
956
1443
  async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
957
1444
  const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
958
1445
  const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
959
- if (!cwd) return
1446
+ if (!(cwd != null && cwd !== '')) return
960
1447
  const absolute = `${cwd}/${path}`
961
1448
  try {
962
1449
  const stat = await Bun.file(absolute).stat()
963
- if (stat.isDirectory()) {
964
- await Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
965
- } else {
966
- await Bun.file(absolute).unlink()
967
- }
968
- } catch (err) {
969
- const message = err instanceof Error ? err.message : 'failed to delete file'
1450
+ await (stat.isDirectory()
1451
+ ? Bun.$`rm -rf -- ${absolute}`.quiet().nothrow()
1452
+ : Bun.file(absolute).unlink())
1453
+ } catch (error) {
1454
+ const message = error instanceof Error ? error.message : 'failed to delete file'
970
1455
  ctx.dispatch({ message, type: 'git-mode-set-message' })
971
1456
  return
972
1457
  }
@@ -976,7 +1461,7 @@ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
976
1461
 
977
1462
  async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
978
1463
  const cwd = ctx.getCurrentSessionProjectPath()
979
- if (!cwd) return
1464
+ if (!(cwd != null && cwd !== '')) return
980
1465
  if (!title) {
981
1466
  ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
982
1467
  return
@@ -986,14 +1471,15 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
986
1471
  : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
987
1472
  if (result.exitCode !== 0) {
988
1473
  const stderr = result.stderr.toString().trim()
989
- ctx.dispatch({
990
- message: stderr || 'commit failed',
991
- type: 'git-mode-set-message',
992
- })
1474
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1475
+ toast.error(stderr || 'Commit failed')
993
1476
  return
994
1477
  }
995
1478
  clearAutoCommitForCurrentSession(ctx)
996
- ctx.dispatch({ message: `committed: ${title}`, type: 'git-mode-set-message' })
1479
+ // Match the push flow: clear any inline git-pane message and surface the
1480
+ // result as a toast so it's seen even after leaving git mode.
1481
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1482
+ toast.success(`Committed: ${title}`)
997
1483
  }
998
1484
 
999
1485
  async function runGitCommitAuto(
@@ -1006,7 +1492,7 @@ async function runGitCommitAuto(
1006
1492
  return
1007
1493
  }
1008
1494
  const cwd = ctx.getCurrentSessionProjectPath()
1009
- if (!cwd) return
1495
+ if (!(cwd != null && cwd !== '')) return
1010
1496
 
1011
1497
  // If the user has manually staged files, respect that intent and commit
1012
1498
  // only the staged set — don't run `git add -A` which would sweep up
@@ -1017,10 +1503,8 @@ async function runGitCommitAuto(
1017
1503
  const addArgs = ['add', '-A']
1018
1504
  const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
1019
1505
  if (addResult.exitCode !== 0) {
1020
- ctx.dispatch({
1021
- message: addResult.stderr.toString().trim() || 'auto-commit: git add failed',
1022
- type: 'git-mode-set-message',
1023
- })
1506
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1507
+ toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
1024
1508
  return
1025
1509
  }
1026
1510
  }
@@ -1030,10 +1514,8 @@ async function runGitCommitAuto(
1030
1514
  : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
1031
1515
 
1032
1516
  if (commitResult.exitCode !== 0) {
1033
- ctx.dispatch({
1034
- message: commitResult.stderr.toString().trim() || 'auto-commit: commit failed',
1035
- type: 'git-mode-set-message',
1036
- })
1517
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1518
+ toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
1037
1519
  return
1038
1520
  }
1039
1521
 
@@ -1043,7 +1525,7 @@ async function runGitCommitAuto(
1043
1525
 
1044
1526
  function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
1045
1527
  const sessionId = ctx.state.currentSessionId
1046
- if (!sessionId) return
1528
+ if (!(sessionId != null && sessionId !== '')) return
1047
1529
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1048
1530
  }
1049
1531
 
@@ -1051,16 +1533,13 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
1051
1533
  const session = ctx.state.sessions.find((s) => s.id === sessionId)
1052
1534
  const panel = ctx.state.gitPanel
1053
1535
  if (panel.error !== null) {
1054
- ctx.dispatch({ message: 'auto-commit: git panel unavailable', type: 'git-mode-set-message' })
1536
+ toast.warning('Auto-commit: git panel unavailable')
1055
1537
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1056
1538
  return
1057
1539
  }
1058
1540
  const tab = ctx.activeTab
1059
1541
  if (!tab) {
1060
- ctx.dispatch({
1061
- message: 'auto-commit: no active assistant tab — open a claude/codex session first',
1062
- type: 'git-mode-set-message',
1063
- })
1542
+ toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
1064
1543
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1065
1544
  return
1066
1545
  }
@@ -1080,7 +1559,7 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
1080
1559
 
1081
1560
  async function runGitPush(ctx: SideEffectContext): Promise<void> {
1082
1561
  const cwd = ctx.getCurrentSessionProjectPath()
1083
- if (!cwd) return
1562
+ if (!(cwd != null && cwd !== '')) return
1084
1563
  ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
1085
1564
 
1086
1565
  const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
@@ -1092,13 +1571,12 @@ async function runGitPush(ctx: SideEffectContext): Promise<void> {
1092
1571
  ? await $`git -C ${cwd} push`.quiet().nothrow()
1093
1572
  : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
1094
1573
 
1574
+ // Clear the inline "pushing…" progress; surface the result as a toast so it's
1575
+ // visible even after leaving git mode (and so push failures aren't missed).
1576
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1095
1577
  if (result.exitCode !== 0) {
1096
- const stderr = result.stderr.toString().trim()
1097
- ctx.dispatch({
1098
- message: stderr || 'push failed',
1099
- type: 'git-mode-set-message',
1100
- })
1578
+ toast.error(result.stderr.toString().trim() || 'Push failed')
1101
1579
  return
1102
1580
  }
1103
- ctx.dispatch({ message: 'pushed', type: 'git-mode-set-message' })
1581
+ toast.success('Pushed')
1104
1582
  }