@brimveyn/aimux 1.12.6 → 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 (130) 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/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +538 -83
  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 +3 -3
  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 +1 -1
  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 +3 -3
  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 +2 -1
  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 +6 -4
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  91. package/src/ui/components/git/git-panel.tsx +17 -10
  92. package/src/ui/components/git/git-view.tsx +79 -19
  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 +5 -4
  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 +3 -3
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  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 +1 -1
  103. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  109. package/src/ui/components/modals/shared/form.tsx +4 -5
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +28 -5
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  114. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  115. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  116. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  117. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  118. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  119. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  120. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  121. package/src/ui/components/primitives/input-field.tsx +1 -1
  122. package/src/ui/components/primitives/list-item.tsx +3 -3
  123. package/src/ui/filter-themes.ts +1 -1
  124. package/src/ui/root.tsx +57 -3
  125. package/src/ui/session-ordering.ts +2 -2
  126. package/src/ui/status-bar-model.ts +14 -7
  127. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  128. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  129. package/src/ui/terminal-graphics/kitty.ts +2 -7
  130. 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,16 @@ 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 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))
392
517
  return
393
518
  }
394
519
  case 'edit-selected-assistant': {
@@ -408,6 +533,47 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
408
533
  handleDeleteSessionEffect(state, backend, dispatch, effect.sessionId)
409
534
  return
410
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
+ }
411
577
  case 'open-rename-selected-session': {
412
578
  openSelectedSessionRename(ctx)
413
579
  return
@@ -476,12 +642,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
476
642
  return
477
643
  }
478
644
  case 'split-pane': {
479
- const sourceTab = effect.sourceTabId
480
- ? state.tabs.find((t) => t.id === effect.sourceTabId)
481
- : undefined
645
+ const sourceTab =
646
+ effect.sourceTabId != null && effect.sourceTabId !== ''
647
+ ? state.tabs.find((t) => t.id === effect.sourceTabId)
648
+ : undefined
482
649
  const assistant = sourceTab?.assistant ?? 'terminal'
483
650
  const customCommand = state.customCommands[assistant]
484
- const tab = createTabSession(assistant, customCommand, state.customCommands)
651
+ const tab = createTabSession(
652
+ assistant,
653
+ customCommand,
654
+ state.customCommands,
655
+ sourceTab?.worktreeId
656
+ )
485
657
  executeSplitPane(ctx, effect.direction, tab)
486
658
  return
487
659
  }
@@ -554,42 +726,42 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
554
726
  return
555
727
  }
556
728
  case 'git-stage': {
557
- void enqueueGitOp(() => runGitAction(ctx, ['add', '--', effect.path], effect.path))
729
+ void enqueueGitOp(async () => runGitAction(ctx, ['add', '--', effect.path], effect.path))
558
730
  return
559
731
  }
560
732
  case 'git-unstage': {
561
- void enqueueGitOp(() =>
733
+ void enqueueGitOp(async () =>
562
734
  runGitAction(ctx, ['restore', '--staged', '--', effect.path], effect.path)
563
735
  )
564
736
  return
565
737
  }
566
738
  case 'git-stage-all': {
567
739
  const paths = ctx.state.gitPanel.files.map((f) => f.path)
568
- void enqueueGitOp(() => runGitActionAll(ctx, ['add', '-A'], paths))
740
+ void enqueueGitOp(async () => runGitActionAll(ctx, ['add', '-A'], paths))
569
741
  return
570
742
  }
571
743
  case 'git-unstage-all': {
572
744
  const paths = ctx.state.gitPanel.files.map((f) => f.path)
573
- void enqueueGitOp(() => runGitActionAll(ctx, ['reset'], paths))
745
+ void enqueueGitOp(async () => runGitActionAll(ctx, ['reset'], paths))
574
746
  return
575
747
  }
576
748
  case 'git-restore': {
577
- void enqueueGitOp(() => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
749
+ void enqueueGitOp(async () => runGitAction(ctx, ['restore', '--', effect.path], effect.path))
578
750
  return
579
751
  }
580
752
  case 'git-rm': {
581
- void enqueueGitOp(() => runGitRm(ctx, effect.path))
753
+ void enqueueGitOp(async () => runGitRm(ctx, effect.path))
582
754
  return
583
755
  }
584
756
  case 'git-commit': {
585
757
  const { body, title } = effect
586
- void enqueueGitOp(() => runGitCommit(ctx, title, body))
758
+ void enqueueGitOp(async () => runGitCommit(ctx, title, body))
587
759
  return
588
760
  }
589
761
  case 'git-commit-auto': {
590
762
  if (!isAutoCommitEnabled()) return
591
763
  const { body, title } = effect
592
- void enqueueGitOp(() => runGitCommitAuto(ctx, title, body))
764
+ void enqueueGitOp(async () => runGitCommitAuto(ctx, title, body))
593
765
  return
594
766
  }
595
767
  case 'generate-auto-commit-now': {
@@ -598,7 +770,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
598
770
  return
599
771
  }
600
772
  case 'git-push': {
601
- void enqueueGitOp(() => runGitPush(ctx))
773
+ void enqueueGitOp(async () => runGitPush(ctx))
602
774
  return
603
775
  }
604
776
  case 'confirm-update-selection': {
@@ -666,7 +838,7 @@ function openSelectedSnippetSourceInEditor(ctx: SideEffectContext): void {
666
838
  function openFileInEditor(ctx: SideEffectContext, relPath: string): void {
667
839
  const fileEntry = ctx.state.gitPanel.files.find((f) => f.path === relPath)
668
840
  const cwd = fileEntry?.repoPath ?? ctx.getCurrentSessionProjectPath()
669
- if (!cwd) {
841
+ if (!(cwd != null && cwd !== '')) {
670
842
  ctx.dispatch({ message: 'no working directory', type: 'git-mode-set-message' })
671
843
  return
672
844
  }
@@ -684,14 +856,14 @@ function launchEditorOnFile(
684
856
  ): void {
685
857
  const config = getExternalEditorConfig()
686
858
  const rawCommand = config.command ?? process.env.VISUAL ?? process.env.EDITOR
687
- if (!rawCommand || rawCommand.trim() === '') {
859
+ if (rawCommand == null || rawCommand === '' || rawCommand.trim() === '') {
688
860
  onError('no $EDITOR/$VISUAL set — configure externalEditor in aimux.config.ts')
689
861
  return
690
862
  }
691
863
 
692
864
  const cmdParts = shellSplit(rawCommand)
693
865
  const executable = cmdParts[0]
694
- if (!executable) {
866
+ if (!(executable != null && executable !== '')) {
695
867
  onError('invalid editor command')
696
868
  return
697
869
  }
@@ -753,7 +925,7 @@ function substituteEditorArgs(template: string[], file: string, line?: string):
753
925
  // Drop standalone line tokens like `{line}`, `+{line}`, `:{line}`.
754
926
  if (/^[+:]?\{line\}$/.test(arg)) continue
755
927
  // Strip trailing `:{line}` or `+{line}` from compound tokens like `{file}:{line}`.
756
- out.push(arg.replace(/[:+]\{line\}/g, '').replaceAll('{file}', file))
928
+ out.push(arg.replaceAll(/[:+]\{line\}/g, '').replaceAll('{file}', file))
757
929
  }
758
930
  return out
759
931
  }
@@ -816,13 +988,15 @@ function spawnDetached(ctx: SideEffectContext, argv: string[], cwd?: string): vo
816
988
  const stderr = await new Response(child.stderr).text()
817
989
  const code = await child.exited
818
990
  if (code !== 0) {
819
- 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}`
820
994
  ctx.dispatch({ message: `editor: ${firstLine}`, type: 'git-mode-set-message' })
821
995
  }
822
996
  })()
823
997
  child.unref()
824
- } catch (err) {
825
- const msg = err instanceof Error ? err.message : 'failed to spawn'
998
+ } catch (error) {
999
+ const msg = error instanceof Error ? error.message : 'failed to spawn'
826
1000
  ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
827
1001
  }
828
1002
  }
@@ -849,8 +1023,8 @@ async function openEditorInline(
849
1023
  stdout: 'inherit',
850
1024
  })
851
1025
  await proc.exited
852
- } catch (err) {
853
- 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'
854
1028
  ctx.dispatch({ message: `editor: ${msg}`, type: 'git-mode-set-message' })
855
1029
  } finally {
856
1030
  renderer.currentRenderBuffer.clear()
@@ -861,9 +1035,9 @@ async function openEditorInline(
861
1035
 
862
1036
  function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
863
1037
  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))
1038
+ const ordered = [...state.sessions].sort(
1039
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1040
+ )
867
1041
  const target = ordered[index - 1]
868
1042
  if (!target) {
869
1043
  logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
@@ -878,6 +1052,295 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
878
1052
  handleSwitchSessionEffect(state, backend, dispatch, target)
879
1053
  }
880
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
+
881
1344
  function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
882
1345
  const { state } = ctx
883
1346
  if (state.modal.type !== 'update-available') {
@@ -917,11 +1380,12 @@ async function runGitAction(
917
1380
  pathToInvalidate?: string
918
1381
  ): Promise<void> {
919
1382
  const fallback = ctx.getCurrentSessionProjectPath()
920
- const repoPath = pathToInvalidate
921
- ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
922
- : undefined
1383
+ const repoPath =
1384
+ pathToInvalidate != null && pathToInvalidate !== ''
1385
+ ? ctx.state.gitPanel.files.find((f) => f.path === pathToInvalidate)?.repoPath
1386
+ : undefined
923
1387
  const cwd = repoPath ?? fallback
924
- if (!cwd) return
1388
+ if (!(cwd != null && cwd !== '')) return
925
1389
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
926
1390
  if (result.exitCode !== 0) {
927
1391
  const stderr = result.stderr.toString().trim()
@@ -929,7 +1393,7 @@ async function runGitAction(
929
1393
  return
930
1394
  }
931
1395
  ctx.dispatch({ message: null, type: 'git-mode-set-message' })
932
- if (pathToInvalidate) {
1396
+ if (pathToInvalidate != null && pathToInvalidate !== '') {
933
1397
  ctx.dispatch({ path: pathToInvalidate, type: 'git-mode-clear-diff-cache' })
934
1398
  }
935
1399
  }
@@ -940,7 +1404,7 @@ async function runGitActionAll(
940
1404
  pathsToInvalidate: string[]
941
1405
  ): Promise<void> {
942
1406
  const cwd = ctx.getCurrentSessionProjectPath()
943
- if (!cwd) return
1407
+ if (!(cwd != null && cwd !== '')) return
944
1408
  const result = await $`git -C ${cwd} ${args}`.quiet().nothrow()
945
1409
  if (result.exitCode !== 0) {
946
1410
  const stderr = result.stderr.toString().trim()
@@ -956,17 +1420,15 @@ async function runGitActionAll(
956
1420
  async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
957
1421
  const repoPath = ctx.state.gitPanel.files.find((f) => f.path === path)?.repoPath
958
1422
  const cwd = repoPath ?? ctx.getCurrentSessionProjectPath()
959
- if (!cwd) return
1423
+ if (!(cwd != null && cwd !== '')) return
960
1424
  const absolute = `${cwd}/${path}`
961
1425
  try {
962
1426
  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'
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'
970
1432
  ctx.dispatch({ message, type: 'git-mode-set-message' })
971
1433
  return
972
1434
  }
@@ -976,7 +1438,7 @@ async function runGitRm(ctx: SideEffectContext, path: string): Promise<void> {
976
1438
 
977
1439
  async function runGitCommit(ctx: SideEffectContext, title: string, body: string): Promise<void> {
978
1440
  const cwd = ctx.getCurrentSessionProjectPath()
979
- if (!cwd) return
1441
+ if (!(cwd != null && cwd !== '')) return
980
1442
  if (!title) {
981
1443
  ctx.dispatch({ message: 'empty commit title', type: 'git-mode-set-message' })
982
1444
  return
@@ -986,14 +1448,15 @@ async function runGitCommit(ctx: SideEffectContext, title: string, body: string)
986
1448
  : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
987
1449
  if (result.exitCode !== 0) {
988
1450
  const stderr = result.stderr.toString().trim()
989
- ctx.dispatch({
990
- message: stderr || 'commit failed',
991
- type: 'git-mode-set-message',
992
- })
1451
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1452
+ toast.error(stderr || 'Commit failed')
993
1453
  return
994
1454
  }
995
1455
  clearAutoCommitForCurrentSession(ctx)
996
- 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}`)
997
1460
  }
998
1461
 
999
1462
  async function runGitCommitAuto(
@@ -1006,7 +1469,7 @@ async function runGitCommitAuto(
1006
1469
  return
1007
1470
  }
1008
1471
  const cwd = ctx.getCurrentSessionProjectPath()
1009
- if (!cwd) return
1472
+ if (!(cwd != null && cwd !== '')) return
1010
1473
 
1011
1474
  // If the user has manually staged files, respect that intent and commit
1012
1475
  // only the staged set — don't run `git add -A` which would sweep up
@@ -1017,10 +1480,8 @@ async function runGitCommitAuto(
1017
1480
  const addArgs = ['add', '-A']
1018
1481
  const addResult = await $`git -C ${cwd} ${addArgs}`.quiet().nothrow()
1019
1482
  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
- })
1483
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1484
+ toast.error(addResult.stderr.toString().trim() || 'Auto-commit: git add failed')
1024
1485
  return
1025
1486
  }
1026
1487
  }
@@ -1030,10 +1491,8 @@ async function runGitCommitAuto(
1030
1491
  : await $`git -C ${cwd} commit -m ${title}`.quiet().nothrow()
1031
1492
 
1032
1493
  if (commitResult.exitCode !== 0) {
1033
- ctx.dispatch({
1034
- message: commitResult.stderr.toString().trim() || 'auto-commit: commit failed',
1035
- type: 'git-mode-set-message',
1036
- })
1494
+ ctx.dispatch({ message: null, type: 'git-mode-set-message' })
1495
+ toast.error(commitResult.stderr.toString().trim() || 'Auto-commit: commit failed')
1037
1496
  return
1038
1497
  }
1039
1498
 
@@ -1043,7 +1502,7 @@ async function runGitCommitAuto(
1043
1502
 
1044
1503
  function clearAutoCommitForCurrentSession(ctx: SideEffectContext): void {
1045
1504
  const sessionId = ctx.state.currentSessionId
1046
- if (!sessionId) return
1505
+ if (!(sessionId != null && sessionId !== '')) return
1047
1506
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1048
1507
  }
1049
1508
 
@@ -1051,16 +1510,13 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
1051
1510
  const session = ctx.state.sessions.find((s) => s.id === sessionId)
1052
1511
  const panel = ctx.state.gitPanel
1053
1512
  if (panel.error !== null) {
1054
- ctx.dispatch({ message: 'auto-commit: git panel unavailable', type: 'git-mode-set-message' })
1513
+ toast.warning('Auto-commit: git panel unavailable')
1055
1514
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1056
1515
  return
1057
1516
  }
1058
1517
  const tab = ctx.activeTab
1059
1518
  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
- })
1519
+ toast.warning('Auto-commit: no active assistant tab — open a claude/codex session first')
1064
1520
  ctx.dispatch({ sessionId, type: 'auto-commit-clear' })
1065
1521
  return
1066
1522
  }
@@ -1080,7 +1536,7 @@ async function runGenerateAutoCommitNow(ctx: SideEffectContext, sessionId: strin
1080
1536
 
1081
1537
  async function runGitPush(ctx: SideEffectContext): Promise<void> {
1082
1538
  const cwd = ctx.getCurrentSessionProjectPath()
1083
- if (!cwd) return
1539
+ if (!(cwd != null && cwd !== '')) return
1084
1540
  ctx.dispatch({ message: 'pushing…', type: 'git-mode-set-message' })
1085
1541
 
1086
1542
  const upstream = await $`git -C ${cwd} rev-parse --abbrev-ref --symbolic-full-name @{u}`
@@ -1092,13 +1548,12 @@ async function runGitPush(ctx: SideEffectContext): Promise<void> {
1092
1548
  ? await $`git -C ${cwd} push`.quiet().nothrow()
1093
1549
  : await $`git -C ${cwd} push --set-upstream origin HEAD`.quiet().nothrow()
1094
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' })
1095
1554
  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
- })
1555
+ toast.error(result.stderr.toString().trim() || 'Push failed')
1101
1556
  return
1102
1557
  }
1103
- ctx.dispatch({ message: 'pushed', type: 'git-mode-set-message' })
1558
+ toast.success('Pushed')
1104
1559
  }