@brimveyn/aimux 1.14.14 → 1.14.16

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.
@@ -1,14 +1,20 @@
1
1
  import { useCallback, useMemo } from 'react'
2
2
 
3
+ import type { WorktreeTemplate } from '../../../../config'
3
4
  import type { AssistantId, WorktreeRecord } from '../../../../state/types'
4
5
 
5
6
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
6
7
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
7
- import { filterAssistants } from '../../../../state/selectors'
8
+ import {
9
+ buildBaseRefOptions,
10
+ filterAssistants,
11
+ getTemplateNoneOffset,
12
+ } from '../../../../state/selectors'
8
13
  import { useTheme } from '../../../theme'
9
14
  import { uiTokens } from '../../../ui-tokens'
10
- import { Form, TextField } from '../shared/form'
15
+ import { AutoComplete, Form, type FormOptionItem, TextField } from '../shared/form'
11
16
  import { Picker, type PickerItem } from '../shared/picker'
17
+ import { WorktreeDeleteConfirm } from '../shared/worktree-delete-confirm'
12
18
 
13
19
  interface NewTabModalProps {
14
20
  selectedIndex: number
@@ -18,18 +24,29 @@ interface NewTabModalProps {
18
24
  currentSessionId: string | null
19
25
  editingCommand: AssistantId | null
20
26
  editBuffer: string
21
- activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
27
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
22
28
  branchError: string | null
23
29
  branchName: string
24
30
  createWorktree: boolean
25
31
  selectedAssistantId: AssistantId | null
26
- step: 'assistant' | 'worktree' | 'worktree-create'
27
- worktreeDeleteConfirmId: string | null
28
- worktreeDeleteMessage: string | null
32
+ step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
33
+ worktreeDeletePrompt: { worktreeId: string; reason: string } | null
29
34
  worktrees: WorktreeRecord[]
30
35
  worktreeName: string
36
+ baseQuery: string
37
+ baseRef: string
38
+ baseBranches: string[]
39
+ worktreeTemplates: WorktreeTemplate[]
40
+ /**
41
+ * True when this modal is the worktree-aware new-tab flow. False when the
42
+ * component is reused for `split-picker`, which can only choose an
43
+ * assistant — the template shortcut entry must be hidden there.
44
+ */
45
+ allowTemplateShortcut: boolean
31
46
  }
32
47
 
48
+ const TEMPLATE_SHORTCUT_KEY = '__template-shortcut__'
49
+
33
50
  function getDeleteBlockedReason({
34
51
  currentSessionId,
35
52
  worktree,
@@ -52,6 +69,10 @@ function getDeleteBlockedReason({
52
69
 
53
70
  export function NewTabModal({
54
71
  activeField,
72
+ allowTemplateShortcut,
73
+ baseBranches,
74
+ baseQuery,
75
+ baseRef,
55
76
  branchError,
56
77
  branchName,
57
78
  createWorktree,
@@ -64,10 +85,10 @@ export function NewTabModal({
64
85
  selectedAssistantId,
65
86
  selectedIndex,
66
87
  step,
67
- worktreeDeleteConfirmId,
68
- worktreeDeleteMessage,
88
+ worktreeDeletePrompt,
69
89
  worktreeName,
70
90
  worktrees,
91
+ worktreeTemplates,
71
92
  }: NewTabModalProps) {
72
93
  const t = useTheme()
73
94
  const options = useMemo(() => getAllAssistantOptions(customCommands), [customCommands])
@@ -95,9 +116,8 @@ export function NewTabModal({
95
116
  active && canDelete && currentSessionId != null && currentSessionId !== ''
96
117
  ? () => {
97
118
  dispatchGlobal({ index, type: 'set-modal-selection-index' })
98
- dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
99
119
  runSideEffectGlobal({
100
- force: worktreeDeleteConfirmId === worktree.id,
120
+ force: false,
101
121
  sessionId: currentSessionId,
102
122
  type: 'delete-worktree',
103
123
  worktreeId: worktree.id,
@@ -115,37 +135,114 @@ export function NewTabModal({
115
135
  }),
116
136
  {
117
137
  key: '__create-worktree__',
118
- onClick: () => dispatchGlobal({ type: 'enter-new-tab-worktree-create' }),
138
+ onClick: () => {
139
+ dispatchGlobal({ type: 'enter-new-tab-worktree-create' })
140
+ runSideEffectGlobal({ type: 'load-new-tab-base-branches' })
141
+ },
119
142
  subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
120
143
  title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
121
144
  },
122
145
  ],
123
- [createWorktree, currentSessionId, selectedIndex, t, worktreeDeleteConfirmId, worktrees]
146
+ [createWorktree, currentSessionId, selectedIndex, t, worktrees]
124
147
  )
125
148
 
126
- const items = useMemo<PickerItem[]>(
127
- () =>
128
- filtered.map((option, index) => {
129
- const active = index === selectedIndex
130
- const customCmd = customCommands[option.id]
149
+ const noneOffset = getTemplateNoneOffset(selectedAssistantId)
150
+ const showNoneOption = noneOffset === 1
151
+ const templateItems = useMemo<PickerItem[]>(() => {
152
+ const noneItem: PickerItem[] = showNoneOption
153
+ ? [
154
+ {
155
+ key: '__none__',
156
+ onClick: () => {
157
+ dispatchGlobal({ index: 0, type: 'set-modal-selection-index' })
158
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
159
+ },
160
+ subtitle: <text fg={t.textMuted}>Single pane — current assistant only</text>,
161
+ title: <text fg={selectedIndex === 0 ? t.text : t.textMuted}>None</text>,
162
+ },
163
+ ]
164
+ : []
165
+ return [
166
+ ...noneItem,
167
+ ...worktreeTemplates.map((template, idx) => {
168
+ const itemIndex = idx + noneOffset
169
+ const active = itemIndex === selectedIndex
170
+ const tabCount = template.tabs.length
171
+ const paneCount = template.tabs.reduce((sum, tab) => sum + tab.panes.length, 0)
131
172
  return {
132
- key: option.id,
133
- onClick: () =>
134
- dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
135
- onEdit: () =>
136
- dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
137
- subtitle: (
138
- <box flexDirection="column">
139
- <text fg={t.textMuted}>{option.description}</text>
140
- {customCmd != null && customCmd !== '' ? (
141
- <text fg={t.primary}>{customCmd}</text>
142
- ) : null}
143
- </box>
144
- ),
145
- title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
173
+ key: template.id,
174
+ onClick: () => {
175
+ dispatchGlobal({ index: itemIndex, type: 'set-modal-selection-index' })
176
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
177
+ },
178
+ subtitle:
179
+ template.description != null && template.description !== '' ? (
180
+ <text fg={t.textMuted}>{template.description}</text>
181
+ ) : (
182
+ <text fg={t.textMuted}>
183
+ {tabCount} tab{tabCount === 1 ? '' : 's'}, {paneCount} pane
184
+ {paneCount === 1 ? '' : 's'}
185
+ </text>
186
+ ),
187
+ title: <text fg={active ? t.text : t.textMuted}>{template.name}</text>,
146
188
  }
147
189
  }),
148
- [customCommands, filtered, selectedIndex, t]
190
+ ]
191
+ }, [noneOffset, selectedIndex, showNoneOption, t, worktreeTemplates])
192
+
193
+ const showShortcutEntry = allowTemplateShortcut && worktreeTemplates.length > 0
194
+ const items = useMemo<PickerItem[]>(() => {
195
+ const assistantItems: PickerItem[] = filtered.map((option, index) => {
196
+ const active = index === selectedIndex
197
+ const customCmd = customCommands[option.id]
198
+ return {
199
+ key: option.id,
200
+ onClick: () => dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
201
+ onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
202
+ subtitle: (
203
+ <box flexDirection="column">
204
+ <text fg={t.textMuted}>{option.description}</text>
205
+ {customCmd != null && customCmd !== '' ? <text fg={t.primary}>{customCmd}</text> : null}
206
+ </box>
207
+ ),
208
+ title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
209
+ }
210
+ })
211
+ if (!showShortcutEntry) return assistantItems
212
+ const shortcutIndex = filtered.length
213
+ const shortcutActive = shortcutIndex === selectedIndex
214
+ return [
215
+ ...assistantItems,
216
+ {
217
+ key: TEMPLATE_SHORTCUT_KEY,
218
+ onClick: () => {
219
+ dispatchGlobal({ index: shortcutIndex, type: 'set-modal-selection-index' })
220
+ dispatchGlobal({ type: 'enter-new-tab-template-shortcut' })
221
+ },
222
+ subtitle: (
223
+ <text fg={t.textMuted}>Create a worktree and pick a template — no assistant step</text>
224
+ ),
225
+ title: <text fg={shortcutActive ? t.text : t.textMuted}>Worktree from template…</text>,
226
+ },
227
+ ]
228
+ }, [customCommands, filtered, showShortcutEntry, selectedIndex, t])
229
+
230
+ const baseItems = useMemo<FormOptionItem[]>(
231
+ () =>
232
+ buildBaseRefOptions(worktrees, baseBranches, baseQuery).map((option) => ({
233
+ key: option.ref,
234
+ leading: (
235
+ <text fg={option.kind === 'worktree' ? t.warning : t.textMuted}>
236
+ {option.kind === 'worktree' ? '\u{e728}' : '\u{e702}'}
237
+ </text>
238
+ ),
239
+ subtitle:
240
+ option.kind === 'worktree' ? (
241
+ <text fg={t.textMuted}>worktree: {option.detail}</text>
242
+ ) : null,
243
+ title: (active) => <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
244
+ })),
245
+ [baseBranches, baseQuery, t, worktrees]
149
246
  )
150
247
 
151
248
  if (editingCommand !== null) {
@@ -167,9 +264,32 @@ export function NewTabModal({
167
264
  )
168
265
  }
169
266
 
267
+ if (step === 'template') {
268
+ return (
269
+ <Picker
270
+ title="Pick worktree template"
271
+ keybindsModeId="modal.new-tab.command-edit"
272
+ width={uiTokens.modalWidth.md}
273
+ gap={1}
274
+ filter={null}
275
+ items={templateItems}
276
+ selectedIndex={selectedIndex}
277
+ emptyState={<text fg={t.textMuted}>No templates configured.</text>}
278
+ onHover={handleHover}
279
+ footer={
280
+ <box flexDirection="column">
281
+ <text fg={t.textMuted}>Step 4/4: choose template</text>
282
+ <text fg={t.textMuted}>Enter launches, Esc returns to worktree settings</text>
283
+ </box>
284
+ }
285
+ />
286
+ )
287
+ }
288
+
170
289
  if (step === 'worktree-create') {
171
290
  const selectedAssistant =
172
291
  options.find((option) => option.id === selectedAssistantId) ?? options[0]
292
+ const baseActive = activeField === 'base'
173
293
  return (
174
294
  <Form
175
295
  title={`New worktree: ${selectedAssistant?.label ?? 'assistant'}`}
@@ -196,7 +316,20 @@ export function NewTabModal({
196
316
  <text fg={t.error}>{branchError}</text>
197
317
  ) : null}
198
318
  </box>
199
- <text fg={t.textMuted}>Step 3/3: configure new worktree</text>
319
+ <AutoComplete
320
+ active={baseActive}
321
+ label="Base (fork from)"
322
+ placeholder="branch or worktree to fork from..."
323
+ value={baseQuery}
324
+ displayValue={baseRef !== '' ? baseRef : 'current branch'}
325
+ items={baseItems}
326
+ selectedIndex={selectedIndex}
327
+ cursorPos={baseActive ? cursorPos : undefined}
328
+ maxVisibleRows={6}
329
+ onHover={handleHover}
330
+ emptyState={<text fg={t.textMuted}>No branches found</text>}
331
+ />
332
+ <text fg={t.textMuted}>Step 3/3: configure new worktree · Tab switches fields</text>
200
333
  </box>
201
334
  </Form>
202
335
  )
@@ -207,6 +340,19 @@ export function NewTabModal({
207
340
  options.find((option) => option.id === selectedAssistantId) ??
208
341
  filtered[selectedIndex] ??
209
342
  options[0]
343
+
344
+ if (worktreeDeletePrompt != null) {
345
+ const target = worktrees.find((worktree) => worktree.id === worktreeDeletePrompt.worktreeId)
346
+ const label = target?.branch ?? target?.name ?? 'this worktree'
347
+ return (
348
+ <WorktreeDeleteConfirm
349
+ keybindsModeId="modal.new-tab.worktree-delete-confirm"
350
+ reason={worktreeDeletePrompt.reason}
351
+ worktreeLabel={label}
352
+ />
353
+ )
354
+ }
355
+
210
356
  const selectedWorktree = worktrees[selectedIndex]
211
357
  const deleteBlockedReason = getDeleteBlockedReason({
212
358
  currentSessionId,
@@ -226,17 +372,8 @@ export function NewTabModal({
226
372
  onHover={handleHover}
227
373
  footer={
228
374
  <box flexDirection="column">
229
- {(worktreeDeleteMessage != null && worktreeDeleteMessage !== '') ||
230
- (deleteBlockedReason != null && deleteBlockedReason !== '') ? (
231
- <text
232
- fg={
233
- worktreeDeleteMessage != null && worktreeDeleteMessage !== ''
234
- ? t.error
235
- : t.textMuted
236
- }
237
- >
238
- {worktreeDeleteMessage ?? deleteBlockedReason}
239
- </text>
375
+ {deleteBlockedReason != null && deleteBlockedReason !== '' ? (
376
+ <text fg={t.textMuted}>{deleteBlockedReason}</text>
240
377
  ) : null}
241
378
  <text fg={t.textMuted}>Step 2/2: choose worktree</text>
242
379
  <text fg={t.textMuted}>Enter launches, Ctrl+d deletes selected worktree</text>
package/src/ui/root.tsx CHANGED
@@ -3,6 +3,7 @@ import type { MouseEvent } from '@opentui/core'
3
3
  import { useCallback, useMemo } from 'react'
4
4
 
5
5
  import type { MeasuredPaneRect } from '../app-runtime/use-pane-size-report'
6
+ import type { WorktreeTemplate } from '../config'
6
7
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
7
8
  import type {
8
9
  FocusMode,
@@ -33,6 +34,7 @@ import { GitCommitModal } from './components/modals/git/git-commit-modal'
33
34
  import { CreateSessionModal } from './components/modals/sessions/create-session-modal'
34
35
  import { SessionNameModal } from './components/modals/sessions/session-name-modal'
35
36
  import { SessionPickerModal } from './components/modals/sessions/session-picker-modal'
37
+ import { WorktreeDeleteConfirm } from './components/modals/shared/worktree-delete-confirm'
36
38
  import { SnippetEditorModal } from './components/modals/snippets/snippet-editor-modal'
37
39
  import { SnippetPickerModal } from './components/modals/snippets/snippet-picker-modal'
38
40
  import { NewTabModal } from './components/modals/tabs/new-tab-modal'
@@ -45,6 +47,7 @@ import { ToastViewport } from './components/overlays/toast/toast-viewport'
45
47
  import { useTheme } from './theme'
46
48
 
47
49
  const EMPTY_WORKTREES: WorktreeRecord[] = []
50
+ const EMPTY_BASE_BRANCHES: string[] = []
48
51
 
49
52
  function getCreateSessionFields(modal: ModalState) {
50
53
  if (modal.type !== 'create-session') {
@@ -92,6 +95,7 @@ function renderModal(
92
95
  activeAssistant?: string
93
96
  autoCommitModel?: string
94
97
  worktreeDivergence: Record<string, { ahead: number; behind: number }>
98
+ worktreeTemplates: WorktreeTemplate[]
95
99
  }
96
100
  ) {
97
101
  switch (modal.type) {
@@ -112,8 +116,10 @@ function renderModal(
112
116
  createWorktree={modal.type === 'new-tab' ? modal.createWorktree : false}
113
117
  selectedAssistantId={modal.type === 'new-tab' ? modal.selectedAssistantId : null}
114
118
  step={modal.type === 'new-tab' ? modal.step : 'assistant'}
115
- worktreeDeleteConfirmId={modal.type === 'new-tab' ? modal.worktreeDeleteConfirmId : null}
116
- worktreeDeleteMessage={modal.type === 'new-tab' ? modal.worktreeDeleteMessage : null}
119
+ baseQuery={modal.type === 'new-tab' ? modal.baseQuery : ''}
120
+ baseRef={modal.type === 'new-tab' ? modal.baseRef : ''}
121
+ baseBranches={modal.type === 'new-tab' ? modal.baseBranches : EMPTY_BASE_BRANCHES}
122
+ worktreeDeletePrompt={modal.type === 'new-tab' ? modal.worktreeDeletePrompt : null}
117
123
  worktrees={
118
124
  options.currentSessionId != null && options.currentSessionId !== ''
119
125
  ? (options.sessions.find((session) => session.id === options.currentSessionId)
@@ -121,6 +127,8 @@ function renderModal(
121
127
  : EMPTY_WORKTREES
122
128
  }
123
129
  worktreeName={modal.type === 'new-tab' ? modal.worktreeName : ''}
130
+ worktreeTemplates={options.worktreeTemplates}
131
+ allowTemplateShortcut={modal.type === 'new-tab'}
124
132
  />
125
133
  )
126
134
  case 'session-picker':
@@ -221,6 +229,14 @@ function renderModal(
221
229
  )
222
230
  case 'ai-usage':
223
231
  return <AIUsageModal />
232
+ case 'worktree-delete-confirm':
233
+ return (
234
+ <WorktreeDeleteConfirm
235
+ keybindsModeId="modal.worktree-delete-confirm"
236
+ reason={modal.reason}
237
+ worktreeLabel={modal.worktreeLabel}
238
+ />
239
+ )
224
240
  case 'git-commit': {
225
241
  const titleText =
226
242
  modal.activeField === 'title' ? (modal.editBuffer ?? '') : modal.contentBuffer
@@ -316,6 +332,7 @@ export function RootView({
316
332
  const sessions = useAppStore((s) => s.sessions)
317
333
  const currentSessionId = useAppStore((s) => s.currentSessionId)
318
334
  const worktreeDivergence = useAppStore((s) => s.worktreeDivergence)
335
+ const worktreeTemplates = useAppStore((s) => s.worktreeTemplates)
319
336
  const gitPaneMode = useAppStore((s) => s.gitPane.mode)
320
337
  const gitPaneVisible = useAppStore((s) => s.gitPane.visible)
321
338
  const gitPanePosition = useAppStore((s) => s.gitPane.position)
@@ -410,6 +427,7 @@ export function RootView({
410
427
  snippets,
411
428
  themeId,
412
429
  worktreeDivergence,
430
+ worktreeTemplates,
413
431
  })}
414
432
  <ToastViewport />
415
433
  </box>
@@ -507,6 +525,7 @@ export function RootView({
507
525
  snippets,
508
526
  themeId,
509
527
  worktreeDivergence,
528
+ worktreeTemplates,
510
529
  })}
511
530
  <ToastViewport />
512
531
  </box>