@brimveyn/aimux 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/pty-write.ts +52 -0
  8. package/src/app-runtime/selection-scroll.ts +2 -2
  9. package/src/app-runtime/session-actions.ts +16 -4
  10. package/src/app-runtime/side-effects.ts +598 -102
  11. package/src/app-runtime/snippet-actions.ts +25 -11
  12. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  13. package/src/app-runtime/use-backend-runtime.ts +3 -3
  14. package/src/app-runtime/use-directory-search.ts +7 -5
  15. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  16. package/src/app-runtime/use-pane-size-report.ts +1 -1
  17. package/src/app-runtime/use-renderer-bindings.ts +143 -8
  18. package/src/app.tsx +25 -9
  19. package/src/auto-commit/headless-commands.ts +4 -6
  20. package/src/daemon/daemon.ts +3 -3
  21. package/src/daemon/runtime-paths.ts +1 -1
  22. package/src/daemon/session-manager.ts +1 -1
  23. package/src/daemon/session-registry.ts +10 -3
  24. package/src/diff-parser/parse-patch-files.ts +18 -18
  25. package/src/git/command-queue.ts +1 -1
  26. package/src/git/divergence.ts +46 -0
  27. package/src/git/git-diff.ts +12 -5
  28. package/src/git/git-poller.ts +33 -11
  29. package/src/git/git-status.ts +14 -3
  30. package/src/git/move-worktree.ts +96 -0
  31. package/src/git/use-repo-discovery.ts +2 -1
  32. package/src/git/worktree-divergence-poller.ts +59 -0
  33. package/src/git/worktree.ts +99 -0
  34. package/src/index.tsx +1 -1
  35. package/src/input/keymap/describe-bindings.ts +27 -9
  36. package/src/input/keymap/key-chord.ts +4 -4
  37. package/src/input/keymap/key-format.ts +2 -2
  38. package/src/input/keymap/sequence-resolver.ts +1 -1
  39. package/src/input/keymap/trie.ts +1 -1
  40. package/src/input/modes/bridge.ts +7 -0
  41. package/src/input/modes/transitions.ts +2 -1
  42. package/src/input/modes/types.ts +13 -1
  43. package/src/input/raw-input-handler.ts +34 -1
  44. package/src/input/terminal-text-extraction.ts +9 -5
  45. package/src/integrations/claude-syntax-overlay.ts +8 -8
  46. package/src/integrations/claude-theme-sync.ts +12 -12
  47. package/src/ipc/protocol.ts +3 -3
  48. package/src/platform/clipboard.ts +18 -2
  49. package/src/platform/worktree-deps.ts +22 -0
  50. package/src/platform/worktree-paths.ts +67 -0
  51. package/src/profile-paths.ts +3 -3
  52. package/src/pty/assistant-status-detection-loop.ts +2 -2
  53. package/src/pty/assistant-status-detector.ts +15 -6
  54. package/src/pty/command-registry.ts +8 -1
  55. package/src/pty/pty-manager.ts +1 -1
  56. package/src/services/ai-usage/adapters/claude.ts +5 -5
  57. package/src/services/ai-usage/adapters/codex.ts +8 -8
  58. package/src/services/ai-usage/cache.ts +2 -2
  59. package/src/services/ai-usage/pace.ts +3 -6
  60. package/src/services/ai-usage/provider.ts +1 -1
  61. package/src/session-backend/bootstrap.ts +2 -2
  62. package/src/session-backend/local-session-backend.ts +11 -11
  63. package/src/session-backend/remote-session-backend.ts +3 -3
  64. package/src/session-backend/types.ts +2 -2
  65. package/src/snippets/expand-variables.ts +112 -0
  66. package/src/snippets/run-shell-var.ts +93 -0
  67. package/src/snippets/trigger-detector.ts +105 -0
  68. package/src/state/ai-usage-store.ts +1 -1
  69. package/src/state/git-tree.ts +8 -4
  70. package/src/state/layout-tree.ts +8 -5
  71. package/src/state/reducers/git-mode-state.ts +44 -14
  72. package/src/state/reducers/git-panel-state.ts +38 -2
  73. package/src/state/reducers/modal-state.ts +385 -24
  74. package/src/state/reducers/session-state.ts +60 -1
  75. package/src/state/reducers/tab-state.ts +151 -85
  76. package/src/state/selectors.ts +6 -4
  77. package/src/state/session-catalog.ts +20 -5
  78. package/src/state/session-persistence.ts +8 -4
  79. package/src/state/session-worktrees.ts +227 -0
  80. package/src/state/snippet-catalog.ts +65 -4
  81. package/src/state/store.ts +1 -0
  82. package/src/state/toast-store.ts +95 -0
  83. package/src/state/types.ts +108 -16
  84. package/src/state/validation.ts +47 -3
  85. package/src/terminal-manager/manager-client.ts +15 -13
  86. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  87. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  88. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  89. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  90. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  91. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  92. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  93. package/src/ui/components/git/git-panel.tsx +17 -10
  94. package/src/ui/components/git/git-view.tsx +79 -19
  95. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  96. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  97. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  98. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  99. package/src/ui/components/layout/session-bar.tsx +3 -3
  100. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  101. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  102. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  103. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  104. package/src/ui/components/layout/split-layout.tsx +1 -1
  105. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  106. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  107. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  108. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  109. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  110. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  111. package/src/ui/components/modals/shared/form.tsx +4 -5
  112. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  113. package/src/ui/components/modals/shared/picker.tsx +28 -5
  114. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
  115. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
  116. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  117. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  118. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +3 -3
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +65 -14
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -25,21 +25,154 @@ function clampCursor(value: number, max: number): number {
25
25
  return Math.max(0, Math.min(max, value))
26
26
  }
27
27
 
28
+ function getCurrentWorktreeCount(state: AppState): number {
29
+ if (!(state.currentSessionId != null && state.currentSessionId !== '')) return 0
30
+ return state.sessions.find((entry) => entry.id === state.currentSessionId)?.worktrees?.length ?? 0
31
+ }
32
+
33
+ function getCurrentWorktreeIndex(state: AppState): number {
34
+ if (!(state.currentSessionId != null && state.currentSessionId !== '')) return 0
35
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
36
+ return Math.max(
37
+ 0,
38
+ (session?.worktrees ?? []).findIndex((worktree) => worktree.id === session?.activeWorktreeId)
39
+ )
40
+ }
41
+
42
+ function getSelectedNewTabAssistant(state: AppState, assistantId?: string) {
43
+ if (assistantId != null && assistantId !== '') {
44
+ return getAllAssistantOptions(state.customCommands).find((entry) => entry.id === assistantId)
45
+ }
46
+ if (state.modal.type !== 'new-tab') return
47
+ return filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)[
48
+ state.modal.selectedIndex
49
+ ]
50
+ }
51
+
28
52
  export function reduceModalState(state: AppState, action: AppAction): AppState | null {
29
53
  switch (action.type) {
30
- case 'open-new-tab-modal':
54
+ case 'open-new-tab-modal': {
55
+ const targetWorktreeIndex = getCurrentWorktreeIndex(state)
31
56
  return {
32
57
  ...state,
33
58
  focusMode: 'command-edit',
34
59
  modal: {
60
+ activeField: 'assistant',
61
+ branchError: null,
62
+ branchName: '',
63
+ createWorktree: false,
35
64
  cursorPos: 0,
36
65
  editBuffer: '',
37
66
  editingCommand: null,
67
+ selectedAssistantId: null,
38
68
  selectedIndex: 0,
39
69
  sessionTargetId: null,
70
+ step: 'assistant',
71
+ targetWorktreeIndex,
40
72
  type: 'new-tab',
73
+ worktreeDeleteConfirmId: null,
74
+ worktreeDeleteMessage: null,
75
+ worktreeName: '',
76
+ },
77
+ }
78
+ }
79
+ case 'enter-new-tab-worktree-create': {
80
+ if (state.modal.type !== 'new-tab') return state
81
+ return {
82
+ ...state,
83
+ modal: {
84
+ ...state.modal,
85
+ activeField: 'worktree-name',
86
+ createWorktree: true,
87
+ cursorPos: state.modal.worktreeName.length,
88
+ selectedIndex: 0,
89
+ step: 'worktree-create',
90
+ },
91
+ }
92
+ }
93
+ case 'set-new-tab-worktree-delete-state': {
94
+ if (state.modal.type !== 'new-tab') return state
95
+ return {
96
+ ...state,
97
+ modal: {
98
+ ...state.modal,
99
+ worktreeDeleteConfirmId: action.confirmWorktreeId ?? null,
100
+ worktreeDeleteMessage: action.message,
101
+ },
102
+ }
103
+ }
104
+ case 'set-new-tab-branch-error': {
105
+ if (state.modal.type !== 'new-tab') return state
106
+ return {
107
+ ...state,
108
+ modal: {
109
+ ...state.modal,
110
+ activeField: 'branch-name',
111
+ branchError: action.message,
112
+ cursorPos: state.modal.branchName.length,
41
113
  },
42
114
  }
115
+ }
116
+ case 'select-new-tab-assistant': {
117
+ if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
118
+ const option = getSelectedNewTabAssistant(state, action.assistantId)
119
+ if (!option) return state
120
+ const targetWorktreeIndex = getCurrentWorktreeIndex(state)
121
+ const worktreeCount = getCurrentWorktreeCount(state)
122
+ return {
123
+ ...state,
124
+ modal: {
125
+ ...state.modal,
126
+ activeField: 'target-worktree',
127
+ branchError: null,
128
+ createWorktree: worktreeCount === 0,
129
+ cursorPos: 0,
130
+ selectedAssistantId: option.id,
131
+ selectedIndex: worktreeCount === 0 ? 0 : targetWorktreeIndex,
132
+ step: 'worktree',
133
+ targetWorktreeIndex,
134
+ worktreeDeleteConfirmId: null,
135
+ worktreeDeleteMessage: null,
136
+ worktreeName: state.modal.worktreeName || `wt-${option.label}`,
137
+ },
138
+ }
139
+ }
140
+ case 'toggle-new-tab-worktree': {
141
+ if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
142
+ const createWorktree = !state.modal.createWorktree
143
+ const option = getSelectedNewTabAssistant(state, action.assistantId)
144
+ const defaultName = state.modal.worktreeName || `wt-${option?.label ?? 'assistant'}`
145
+ const worktreeCount = getCurrentWorktreeCount(state)
146
+ const targetWorktreeIndex = getCurrentWorktreeIndex(state)
147
+ let activeField = state.modal.activeField
148
+ if (createWorktree) {
149
+ activeField = state.modal.step === 'worktree' || option ? 'target-worktree' : activeField
150
+ } else if (activeField === 'worktree-name') {
151
+ activeField = 'target-worktree'
152
+ }
153
+ const cursorPos =
154
+ createWorktree && worktreeCount <= 0
155
+ ? defaultName.length
156
+ : (state.modal.editBuffer?.length ?? 0)
157
+ return {
158
+ ...state,
159
+ modal: {
160
+ ...state.modal,
161
+ activeField,
162
+ createWorktree,
163
+ cursorPos,
164
+ selectedAssistantId: option?.id ?? state.modal.selectedAssistantId,
165
+ selectedIndex: createWorktree
166
+ ? worktreeCount
167
+ : Math.min(state.modal.selectedIndex, Math.max(0, worktreeCount - 1)),
168
+ step: option && createWorktree ? 'worktree' : state.modal.step,
169
+ targetWorktreeIndex,
170
+ worktreeDeleteConfirmId: null,
171
+ worktreeDeleteMessage: null,
172
+ worktreeName: defaultName,
173
+ },
174
+ }
175
+ }
43
176
  case 'open-edit-custom-command': {
44
177
  if (state.modal.type !== 'new-tab') return state
45
178
  const current = state.customCommands[action.assistantId] ?? ''
@@ -67,6 +200,29 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
67
200
  },
68
201
  }
69
202
  }
203
+ case 'open-worktree-move-modal': {
204
+ // Overlay: keep focusMode (git when opened via `m`, navigation when opened
205
+ // from a tab menu) so the view underneath stays mounted, like the help
206
+ // modal. deriveModeId routes input to the picker while open.
207
+ return {
208
+ ...state,
209
+ modal: {
210
+ deleteSource: false,
211
+ editBuffer: null,
212
+ selectedIndex: 0,
213
+ sessionTargetId: null,
214
+ sourceWorktreeId: action.sourceWorktreeId,
215
+ type: 'worktree-move',
216
+ },
217
+ }
218
+ }
219
+ case 'toggle-worktree-move-delete': {
220
+ if (state.modal.type !== 'worktree-move') return state
221
+ return {
222
+ ...state,
223
+ modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
224
+ }
225
+ }
70
226
  case 'open-help-modal': {
71
227
  const keymap = getActiveKeymap()
72
228
  const scope = action.scope ?? null
@@ -160,6 +316,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
160
316
  ...state,
161
317
  focusMode: 'command-edit',
162
318
  modal: {
319
+ actionMessage: null,
163
320
  cursorPos: 0,
164
321
  editBuffer: '',
165
322
  selectedIndex: 0,
@@ -167,10 +324,16 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
167
324
  type: 'snippet-picker',
168
325
  },
169
326
  }
327
+ case 'snippet-picker-set-message': {
328
+ if (state.modal.type !== 'snippet-picker') return state
329
+ if (state.modal.actionMessage === action.message) return state
330
+ return { ...state, modal: { ...state.modal, actionMessage: action.message } }
331
+ }
170
332
  case 'open-snippet-editor': {
171
- const snippet = action.snippetId
172
- ? state.snippets.find((s) => s.id === action.snippetId)
173
- : undefined
333
+ const snippet =
334
+ action.snippetId != null && action.snippetId !== ''
335
+ ? state.snippets.find((s) => s.id === action.snippetId)
336
+ : undefined
174
337
  const initialName = snippet?.name ?? ''
175
338
  return {
176
339
  ...state,
@@ -180,8 +343,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
180
343
  contentBuffer: snippet?.content ?? '',
181
344
  cursorPos: initialName.length,
182
345
  editBuffer: initialName,
346
+ nameBuffer: initialName,
183
347
  selectedIndex: 0,
184
348
  sessionTargetId: snippet?.id ?? null,
349
+ triggerBuffer: snippet?.trigger ?? '',
185
350
  type: 'snippet-editor',
186
351
  },
187
352
  }
@@ -357,8 +522,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
357
522
  }
358
523
  case 'close-modal': {
359
524
  const closingType = state.modal.type
360
- // Help is a pure overlay it never flipped focusMode, so leave it alone.
361
- if (closingType === 'help') {
525
+ // Pure overlays never flipped focusMode (they render on top of git mode),
526
+ // so leave it alone — closing returns to whatever was underneath.
527
+ if (closingType === 'help' || closingType === 'worktree-move') {
362
528
  return { ...state, modal: emptyModal() }
363
529
  }
364
530
  const nextFocus: AppState['focusMode'] = closingType === 'git-commit' ? 'git' : 'navigation'
@@ -380,19 +546,28 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
380
546
  state.modal.type !== 'theme-picker' &&
381
547
  state.modal.type !== 'create-session' &&
382
548
  state.modal.type !== 'split-picker' &&
383
- state.modal.type !== 'update-available'
549
+ state.modal.type !== 'update-available' &&
550
+ state.modal.type !== 'worktree-move'
384
551
  ) {
385
552
  return state
386
553
  }
387
554
  if (state.modal.type === 'create-session' && state.modal.activeField !== 'directory') {
388
555
  return state
389
556
  }
557
+ if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
558
+ return state
559
+ }
390
560
  let optionCount: number
391
561
  if (state.modal.type === 'new-tab') {
392
- optionCount = filterAssistants(
393
- getAllAssistantOptions(state.customCommands),
394
- state.modal.editBuffer
395
- ).length
562
+ if (state.modal.step === 'worktree') {
563
+ if (state.modal.activeField === 'worktree-name') return state
564
+ optionCount = getCurrentWorktreeCount(state) + 1
565
+ } else {
566
+ optionCount = filterAssistants(
567
+ getAllAssistantOptions(state.customCommands),
568
+ state.modal.editBuffer
569
+ ).length
570
+ }
396
571
  } else if (state.modal.type === 'split-picker') {
397
572
  optionCount = getAllAssistantOptions(state.customCommands).length
398
573
  } else if (state.modal.type === 'create-session') {
@@ -404,6 +579,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
404
579
  optionCount = filterThemeIds(state.modal.editBuffer).length
405
580
  } else if (state.modal.type === 'update-available') {
406
581
  optionCount = 2
582
+ } else if (state.modal.type === 'worktree-move') {
583
+ optionCount = Math.max(0, getCurrentWorktreeCount(state) - 1)
407
584
  } else {
408
585
  const filtered = filterSessions(state.sessions, state.modal.editBuffer)
409
586
  optionCount = Math.max(1, filtered.length + 1)
@@ -416,6 +593,15 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
416
593
  modal: {
417
594
  ...state.modal,
418
595
  selectedIndex: (state.modal.selectedIndex + action.delta + optionCount) % optionCount,
596
+ ...(state.modal.type === 'new-tab' && state.modal.step === 'worktree'
597
+ ? {
598
+ createWorktree:
599
+ (state.modal.selectedIndex + action.delta + optionCount) % optionCount ===
600
+ optionCount - 1,
601
+ worktreeDeleteConfirmId: null,
602
+ worktreeDeleteMessage: null,
603
+ }
604
+ : null),
419
605
  },
420
606
  }
421
607
  }
@@ -428,18 +614,23 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
428
614
  state.modal.type !== 'create-session' &&
429
615
  state.modal.type !== 'split-picker' &&
430
616
  state.modal.type !== 'update-available' &&
617
+ state.modal.type !== 'worktree-move' &&
431
618
  state.modal.type !== 'help'
432
619
  ) {
433
620
  return state
434
621
  }
622
+ if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
623
+ return state
624
+ }
435
625
  let optionCount: number
436
626
  if (state.modal.type === 'help') {
437
627
  optionCount = state.modal.entryCount
438
628
  } else if (state.modal.type === 'new-tab') {
439
- optionCount = filterAssistants(
440
- getAllAssistantOptions(state.customCommands),
441
- state.modal.editBuffer
442
- ).length
629
+ optionCount =
630
+ state.modal.step === 'worktree'
631
+ ? getCurrentWorktreeCount(state) + 1
632
+ : filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
633
+ .length
443
634
  } else if (state.modal.type === 'split-picker') {
444
635
  optionCount = getAllAssistantOptions(state.customCommands).length
445
636
  } else if (state.modal.type === 'create-session') {
@@ -450,17 +641,48 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
450
641
  optionCount = filterThemeIds(state.modal.editBuffer).length
451
642
  } else if (state.modal.type === 'update-available') {
452
643
  optionCount = 2
644
+ } else if (state.modal.type === 'worktree-move') {
645
+ optionCount = Math.max(0, getCurrentWorktreeCount(state) - 1)
453
646
  } else {
454
647
  optionCount = Math.max(1, filterSessions(state.sessions, state.modal.editBuffer).length + 1)
455
648
  }
456
649
  if (optionCount === 0) return state
457
650
  const clamped = Math.max(0, Math.min(optionCount - 1, action.index))
651
+ if (state.modal.type === 'new-tab' && state.modal.step === 'worktree') {
652
+ if (clamped === state.modal.selectedIndex) return state
653
+ return {
654
+ ...state,
655
+ modal: {
656
+ ...state.modal,
657
+ createWorktree: clamped === optionCount - 1,
658
+ selectedIndex: clamped,
659
+ worktreeDeleteConfirmId: null,
660
+ worktreeDeleteMessage: null,
661
+ },
662
+ }
663
+ }
458
664
  if (clamped === state.modal.selectedIndex) return state
459
665
  return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
460
666
  }
461
667
  case 'move-modal-cursor': {
462
668
  if (state.modal.editBuffer === null) return state
463
- const len = state.modal.editBuffer.length
669
+ let editableValue = state.modal.editBuffer
670
+ if (state.modal.type === 'new-tab' && state.modal.editingCommand === null) {
671
+ if (state.modal.activeField === 'worktree-name') {
672
+ editableValue = state.modal.worktreeName
673
+ } else if (state.modal.activeField === 'branch-name') {
674
+ editableValue = state.modal.branchName
675
+ }
676
+ }
677
+ if (
678
+ state.modal.type === 'new-tab' &&
679
+ state.modal.editingCommand === null &&
680
+ state.modal.step === 'worktree' &&
681
+ state.modal.activeField === 'target-worktree'
682
+ ) {
683
+ return state
684
+ }
685
+ const len = editableValue.length
464
686
  const current = state.modal.cursorPos ?? len
465
687
  let next = current
466
688
  if (action.to === 'home') next = 0
@@ -474,7 +696,22 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
474
696
  if (state.modal.editBuffer === null) {
475
697
  return state
476
698
  }
477
- const buffer = state.modal.editBuffer
699
+ let buffer = state.modal.editBuffer
700
+ if (state.modal.type === 'new-tab' && state.modal.editingCommand === null) {
701
+ if (state.modal.activeField === 'worktree-name') {
702
+ buffer = state.modal.worktreeName
703
+ } else if (state.modal.activeField === 'branch-name') {
704
+ buffer = state.modal.branchName
705
+ }
706
+ }
707
+ if (
708
+ state.modal.type === 'new-tab' &&
709
+ state.modal.editingCommand === null &&
710
+ state.modal.step === 'worktree' &&
711
+ state.modal.activeField === 'target-worktree'
712
+ ) {
713
+ return state
714
+ }
478
715
  const cursor = clampCursor(state.modal.cursorPos ?? buffer.length, buffer.length)
479
716
  let nextBuffer: string
480
717
  let nextCursor: number
@@ -487,6 +724,35 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
487
724
  nextCursor = cursor + action.char.length
488
725
  }
489
726
  const isNewTabEditing = state.modal.type === 'new-tab' && state.modal.editingCommand !== null
727
+ if (
728
+ state.modal.type === 'new-tab' &&
729
+ state.modal.editingCommand === null &&
730
+ state.modal.activeField === 'worktree-name'
731
+ ) {
732
+ return {
733
+ ...state,
734
+ modal: {
735
+ ...state.modal,
736
+ cursorPos: nextCursor,
737
+ worktreeName: nextBuffer,
738
+ },
739
+ }
740
+ }
741
+ if (
742
+ state.modal.type === 'new-tab' &&
743
+ state.modal.editingCommand === null &&
744
+ state.modal.activeField === 'branch-name'
745
+ ) {
746
+ return {
747
+ ...state,
748
+ modal: {
749
+ ...state.modal,
750
+ branchError: null,
751
+ branchName: nextBuffer,
752
+ cursorPos: nextCursor,
753
+ },
754
+ }
755
+ }
490
756
  const resetIndex =
491
757
  !isNewTabEditing &&
492
758
  (state.modal.type === 'session-picker' ||
@@ -518,6 +784,50 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
518
784
  },
519
785
  }
520
786
  }
787
+ if (state.modal.type === 'new-tab') {
788
+ if (state.modal.step === 'worktree-create') {
789
+ const optionCount = getCurrentWorktreeCount(state) + 1
790
+ return {
791
+ ...state,
792
+ modal: {
793
+ ...state.modal,
794
+ activeField: 'target-worktree',
795
+ branchError: null,
796
+ createWorktree: false,
797
+ cursorPos: 0,
798
+ selectedIndex: Math.min(
799
+ state.modal.targetWorktreeIndex,
800
+ Math.max(0, optionCount - 1)
801
+ ),
802
+ step: 'worktree',
803
+ },
804
+ }
805
+ }
806
+ if (state.modal.step === 'worktree') {
807
+ const selectedAssistantId = state.modal.selectedAssistantId
808
+ const assistants = filterAssistants(
809
+ getAllAssistantOptions(state.customCommands),
810
+ state.modal.editBuffer
811
+ )
812
+ const assistantIndex = assistants.findIndex(
813
+ (assistant) => assistant.id === selectedAssistantId
814
+ )
815
+ return {
816
+ ...state,
817
+ modal: {
818
+ ...state.modal,
819
+ activeField: 'assistant',
820
+ createWorktree: false,
821
+ cursorPos: state.modal.editBuffer?.length ?? 0,
822
+ selectedIndex: Math.max(0, assistantIndex),
823
+ step: 'assistant',
824
+ worktreeDeleteConfirmId: null,
825
+ worktreeDeleteMessage: null,
826
+ },
827
+ }
828
+ }
829
+ return { ...state, focusMode: 'navigation', modal: emptyModal() }
830
+ }
521
831
  if (state.modal.type === 'create-session' || state.modal.type === 'snippet-editor') {
522
832
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
523
833
  }
@@ -526,7 +836,6 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
526
836
  state.modal.type === 'session-picker' ||
527
837
  state.modal.type === 'snippet-picker' ||
528
838
  state.modal.type === 'theme-picker' ||
529
- state.modal.type === 'new-tab' ||
530
839
  state.modal.type === 'help'
531
840
  ) {
532
841
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
@@ -553,14 +862,33 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
553
862
  }
554
863
  }
555
864
  if (state.modal.type === 'snippet-editor') {
556
- const nextField = state.modal.activeField === 'name' ? 'content' : 'name'
557
- const nextEdit = state.modal.contentBuffer
865
+ const current = state.modal.editBuffer ?? ''
866
+ // Save current edit buffer back to the field it belongs to.
867
+ const updatedBuffers = {
868
+ contentBuffer:
869
+ state.modal.activeField === 'content' ? current : state.modal.contentBuffer,
870
+ nameBuffer: state.modal.activeField === 'name' ? current : state.modal.nameBuffer,
871
+ triggerBuffer:
872
+ state.modal.activeField === 'trigger' ? current : state.modal.triggerBuffer,
873
+ }
874
+ const cycle: Record<'name' | 'trigger' | 'content', 'name' | 'trigger' | 'content'> = {
875
+ content: 'name',
876
+ name: 'trigger',
877
+ trigger: 'content',
878
+ }
879
+ const nextField = cycle[state.modal.activeField]
880
+ const nextEditByField = {
881
+ content: updatedBuffers.contentBuffer,
882
+ name: updatedBuffers.nameBuffer,
883
+ trigger: updatedBuffers.triggerBuffer,
884
+ }
885
+ const nextEdit = nextEditByField[nextField]
558
886
  return {
559
887
  ...state,
560
888
  modal: {
561
889
  ...state.modal,
890
+ ...updatedBuffers,
562
891
  activeField: nextField,
563
- contentBuffer: state.modal.editBuffer ?? '',
564
892
  cursorPos: nextEdit.length,
565
893
  editBuffer: nextEdit,
566
894
  },
@@ -580,6 +908,38 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
580
908
  },
581
909
  }
582
910
  }
911
+ if (state.modal.type === 'new-tab') {
912
+ if (state.modal.step === 'assistant') return state
913
+ if (state.modal.step === 'worktree-create') {
914
+ const nextField: typeof state.modal.activeField =
915
+ state.modal.activeField === 'worktree-name' ? 'branch-name' : 'worktree-name'
916
+ const nextValue =
917
+ nextField === 'branch-name' ? state.modal.branchName : state.modal.worktreeName
918
+ return {
919
+ ...state,
920
+ modal: {
921
+ ...state.modal,
922
+ activeField: nextField,
923
+ cursorPos: nextValue.length,
924
+ selectedIndex: 0,
925
+ },
926
+ }
927
+ }
928
+ let nextField: typeof state.modal.activeField = 'target-worktree'
929
+ if (state.modal.activeField === 'target-worktree' && state.modal.createWorktree) {
930
+ nextField = 'worktree-name'
931
+ }
932
+ const nextValue =
933
+ nextField === 'worktree-name' ? state.modal.worktreeName : (state.modal.editBuffer ?? '')
934
+ return {
935
+ ...state,
936
+ modal: {
937
+ ...state.modal,
938
+ activeField: nextField,
939
+ cursorPos: nextValue.length,
940
+ },
941
+ }
942
+ }
583
943
  return state
584
944
  }
585
945
  case 'select-directory': {
@@ -611,9 +971,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
611
971
  }
612
972
  }
613
973
  case 'open-rename-tab-modal': {
614
- const activeTab = state.activeTabId
615
- ? state.tabs.find((tab) => tab.id === state.activeTabId)
616
- : undefined
974
+ const activeTab =
975
+ state.activeTabId != null && state.activeTabId !== ''
976
+ ? state.tabs.find((tab) => tab.id === state.activeTabId)
977
+ : undefined
617
978
  if (!activeTab) {
618
979
  return state
619
980
  }
@@ -2,6 +2,7 @@ import type { AppAction, AppState } from '../types'
2
2
 
3
3
  import { filterSessions } from '../selectors'
4
4
  import { restoreWorkspaceState } from '../session-persistence'
5
+ import { withActiveWorktree } from '../session-worktrees'
5
6
 
6
7
  const CLOSED_MODAL = {
7
8
  editBuffer: null,
@@ -57,7 +58,7 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
57
58
  const newSessions = state.sessions.filter((session) => session.id !== action.sessionId)
58
59
  const nextStatuses = { ...state.sessionStatuses }
59
60
  delete nextStatuses[action.sessionId]
60
- if (action.openSessionPicker) {
61
+ if (action.openSessionPicker === true) {
61
62
  const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
62
63
  const maxIndex = filteredNew.length
63
64
  const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
@@ -120,6 +121,64 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
120
121
  sessionStatuses: { ...state.sessionStatuses, [action.sessionId]: action.status },
121
122
  }
122
123
  }
124
+ case 'add-worktree-record':
125
+ return {
126
+ ...state,
127
+ sessions: state.sessions.map((session) => {
128
+ if (session.id !== action.sessionId) return session
129
+ const next = {
130
+ ...session,
131
+ activeWorktreeId:
132
+ action.activate === true ? action.worktree.id : session.activeWorktreeId,
133
+ projectPath: action.activate === true ? action.worktree.path : session.projectPath,
134
+ updatedAt: new Date().toISOString(),
135
+ worktrees: [...(session.worktrees ?? []), action.worktree],
136
+ }
137
+ return next.activeWorktreeId != null && next.activeWorktreeId !== ''
138
+ ? next
139
+ : withActiveWorktree(next, action.worktree.id)
140
+ }),
141
+ }
142
+ case 'remove-worktree-record':
143
+ return {
144
+ ...state,
145
+ sessions: state.sessions.map((session) => {
146
+ if (session.id !== action.sessionId) return session
147
+ const remaining = (session.worktrees ?? []).filter((w) => w.id !== action.worktreeId)
148
+ if (remaining.length === 0) return session
149
+ if (session.activeWorktreeId !== action.worktreeId) {
150
+ return { ...session, updatedAt: new Date().toISOString(), worktrees: remaining }
151
+ }
152
+ return withActiveWorktree(
153
+ { ...session, activeWorktreeId: remaining[0]?.id, worktrees: remaining },
154
+ remaining[0]?.id ?? ''
155
+ )
156
+ }),
157
+ }
158
+ case 'set-active-worktree':
159
+ return {
160
+ ...state,
161
+ sessions: state.sessions.map((session) =>
162
+ session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
163
+ ),
164
+ }
165
+ case 'update-worktree-record':
166
+ return {
167
+ ...state,
168
+ sessions: state.sessions.map((session) =>
169
+ session.id === action.sessionId
170
+ ? {
171
+ ...session,
172
+ updatedAt: new Date().toISOString(),
173
+ worktrees: session.worktrees?.map((worktree) =>
174
+ worktree.id === action.worktreeId
175
+ ? { ...worktree, ...action.patch, updatedAt: new Date().toISOString() }
176
+ : worktree
177
+ ),
178
+ }
179
+ : session
180
+ ),
181
+ }
123
182
  default:
124
183
  return null
125
184
  }