@brimveyn/aimux 1.14.15 → 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,6 +1,10 @@
1
- import type { MouseEvent as OtuiMouseEvent, ScrollBoxRenderable } from '@opentui/core'
1
+ import type {
2
+ BoxRenderable,
3
+ MouseEvent as OtuiMouseEvent,
4
+ ScrollBoxRenderable,
5
+ } from '@opentui/core'
2
6
 
3
- import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
7
+ import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
4
8
 
5
9
  import type { FocusMode, TabSession } from '../../../state/types'
6
10
 
@@ -8,7 +12,8 @@ import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-p
8
12
  import { useAppStore } from '../../../state/app-store'
9
13
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
10
14
  import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
11
- import { buildTabEntries, type GroupEntry } from '../../../state/tab-entries'
15
+ import { buildTabEntries, type GroupEntry, type TabEntry } from '../../../state/tab-entries'
16
+ import { moveIdToIdPosition } from '../../session-ordering'
12
17
  import { useTheme } from '../../theme'
13
18
  import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
14
19
  import { TabItem } from './sidebar/tab-item'
@@ -43,27 +48,51 @@ const TopTabCell = memo(function TopTabCell({
43
48
  backgroundColor,
44
49
  children,
45
50
  entryId,
46
- onActivate,
51
+ onDrag,
52
+ onDragCancel,
53
+ onDragStart,
54
+ onDrop,
55
+ setCellRef,
47
56
  }: {
48
57
  entryId: string
49
58
  active: boolean
50
59
  backgroundColor: string | undefined
51
- onActivate?: (entryId: string) => void
60
+ setCellRef: (entryId: string, ref: BoxRenderable | null) => void
61
+ onDragStart: (entryId: string) => void
62
+ onDrag: (event: OtuiMouseEvent) => void
63
+ onDrop: () => void
64
+ onDragCancel: () => void
52
65
  children: ReactNode
53
66
  }) {
67
+ const handleRef = useCallback(
68
+ (r: BoxRenderable | null) => setCellRef(entryId, r),
69
+ [setCellRef, entryId]
70
+ )
54
71
  const handleMouseDown = useCallback(
55
72
  (event: OtuiMouseEvent) => {
73
+ event.preventDefault()
56
74
  event.stopPropagation()
57
- onActivate?.(entryId)
75
+ onDragStart(entryId)
76
+ },
77
+ [onDragStart, entryId]
78
+ )
79
+ const handleMouseUp = useCallback(
80
+ (event: OtuiMouseEvent) => {
81
+ event.preventDefault()
82
+ onDrop()
58
83
  },
59
- [onActivate, entryId]
84
+ [onDrop]
60
85
  )
61
86
  return (
62
87
  <box
88
+ ref={handleRef}
63
89
  backgroundColor={backgroundColor}
64
90
  flexDirection="row"
65
91
  flexShrink={0}
66
92
  onMouseDown={handleMouseDown}
93
+ onMouseDrag={onDrag}
94
+ onMouseUp={handleMouseUp}
95
+ onMouseDragEnd={onDragCancel}
67
96
  data-active={active ? 'true' : undefined}
68
97
  >
69
98
  {children}
@@ -147,6 +176,19 @@ function GroupTabItem({
147
176
  )
148
177
  }
149
178
 
179
+ function arraysEqual(a: string[], b: string[]): boolean {
180
+ if (a.length !== b.length) return false
181
+ for (let i = 0; i < a.length; i++) {
182
+ if (a[i] !== b[i]) return false
183
+ }
184
+ return true
185
+ }
186
+
187
+ /** Flatten an entry into the underlying tab ids, in display order. */
188
+ function entryTabIds(entry: TabEntry): string[] {
189
+ return entry.kind === 'single' ? [entry.tab.id] : entry.tabs.map((tab) => tab.id)
190
+ }
191
+
150
192
  export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
151
193
  const t = useTheme()
152
194
  const headerBg = t.backgroundPanel
@@ -214,6 +256,89 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
214
256
  [entries, activeTabId]
215
257
  )
216
258
 
259
+ // --- Drag-and-drop reorder of the tab strip ---------------------------------
260
+ // Mirrors the workspace-list drag, but on the horizontal axis: entries are
261
+ // laid out left-to-right inside a scrollX box, so hit-testing is on x/width.
262
+ const [draggingId, setDraggingId] = useState<string | null>(null)
263
+ const [dragOrder, setDragOrder] = useState<string[] | null>(null)
264
+ const lastSwapWithRef = useRef<string | null>(null)
265
+ const cellRefs = useRef(new Map<string, BoxRenderable>())
266
+
267
+ const baselineOrder = useMemo(() => entries.map((e) => e.id), [entries])
268
+
269
+ const setCellRef = useCallback((id: string, ref: BoxRenderable | null): void => {
270
+ if (ref) cellRefs.current.set(id, ref)
271
+ else cellRefs.current.delete(id)
272
+ }, [])
273
+
274
+ const findEntryAtX = useCallback((x: number): string | null => {
275
+ for (const [id, ref] of cellRefs.current) {
276
+ if (x >= ref.x && x < ref.x + ref.width) return id
277
+ }
278
+ return null
279
+ }, [])
280
+
281
+ const handleDragStart = useCallback(
282
+ (id: string) => {
283
+ setDraggingId(id)
284
+ setDragOrder(baselineOrder)
285
+ lastSwapWithRef.current = null
286
+ },
287
+ [baselineOrder]
288
+ )
289
+
290
+ const handleDrag = useCallback(
291
+ (event: OtuiMouseEvent) => {
292
+ if (!(draggingId != null && draggingId !== '')) return
293
+ const hit = findEntryAtX(event.x)
294
+ if (hit === null || hit === draggingId) {
295
+ lastSwapWithRef.current = null
296
+ return
297
+ }
298
+ if (hit === lastSwapWithRef.current) return
299
+ setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
300
+ lastSwapWithRef.current = hit
301
+ },
302
+ [draggingId, findEntryAtX]
303
+ )
304
+
305
+ const commitDrop = useCallback(() => {
306
+ const source = draggingId
307
+ const finalOrder = dragOrder
308
+ setDraggingId(null)
309
+ setDragOrder(null)
310
+ lastSwapWithRef.current = null
311
+
312
+ if (source == null || source === '' || !finalOrder) return
313
+
314
+ if (!arraysEqual(finalOrder, baselineOrder)) {
315
+ // Expand entries (groups collapse multiple tabs) back into a flat tab-id
316
+ // order, then let the reducer rewrite only the visible tabs' slots.
317
+ const byId = new Map(entries.map((e) => [e.id, e]))
318
+ const orderedTabIds = finalOrder.flatMap((id) => {
319
+ const entry = byId.get(id)
320
+ return entry ? entryTabIds(entry) : []
321
+ })
322
+ dispatchGlobal({ orderedTabIds, type: 'reorder-tabs' })
323
+ return
324
+ }
325
+
326
+ // No reorder happened → treat as a plain click on the entry.
327
+ handleEntryActivate(source)
328
+ }, [baselineOrder, dragOrder, draggingId, entries, handleEntryActivate])
329
+
330
+ const cancelDrag = useCallback(() => {
331
+ setDraggingId(null)
332
+ setDragOrder(null)
333
+ lastSwapWithRef.current = null
334
+ }, [])
335
+
336
+ const visibleEntries = useMemo(() => {
337
+ if (dragOrder === null) return entries
338
+ const byId = new Map(entries.map((e) => [e.id, e]))
339
+ return dragOrder.map((id) => byId.get(id)).filter((e): e is TabEntry => e != null)
340
+ }, [dragOrder, entries])
341
+
217
342
  const handleNewTab = useCallback((e: OtuiMouseEvent) => {
218
343
  e.stopPropagation()
219
344
  dispatchGlobal({ type: 'open-new-tab-modal' })
@@ -245,10 +370,11 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
245
370
  viewportCulling
246
371
  contentOptions={ROW_CONTENT_OPTIONS}
247
372
  >
248
- {entries.map((entry, index) => {
373
+ {visibleEntries.map((entry, index) => {
249
374
  // [N] is shown only for the first 9 entries — that's the range
250
375
  // Leader+1..9 can address.
251
376
  const indexLabel = index < 9 ? `[${index + 1}]` : undefined
377
+ const dragging = entry.id === draggingId
252
378
  if (entry.kind === 'single') {
253
379
  const tab: TabSession = entry.tab
254
380
  const isActive = tab.id === activeTabId
@@ -257,8 +383,12 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
257
383
  key={entry.id}
258
384
  entryId={entry.id}
259
385
  active={isActive}
260
- onActivate={handleEntryActivate}
261
- backgroundColor={isActive ? t.backgroundElement : undefined}
386
+ setCellRef={setCellRef}
387
+ onDragStart={handleDragStart}
388
+ onDrag={handleDrag}
389
+ onDrop={commitDrop}
390
+ onDragCancel={cancelDrag}
391
+ backgroundColor={isActive || dragging ? t.backgroundElement : undefined}
262
392
  >
263
393
  <TabItem
264
394
  id={`top-tab-${tab.id}`}
@@ -277,8 +407,12 @@ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
277
407
  key={entry.id}
278
408
  entryId={entry.id}
279
409
  active={isActive}
280
- onActivate={handleEntryActivate}
281
- backgroundColor={isActive ? t.backgroundElement : undefined}
410
+ setCellRef={setCellRef}
411
+ onDragStart={handleDragStart}
412
+ onDrag={handleDrag}
413
+ onDrop={commitDrop}
414
+ onDragCancel={cancelDrag}
415
+ backgroundColor={isActive || dragging ? t.backgroundElement : undefined}
282
416
  >
283
417
  <GroupTabItem
284
418
  entry={entry}
@@ -0,0 +1,39 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
2
+
3
+ import { useTheme } from '../../../theme'
4
+ import { uiTokens } from '../../../ui-tokens'
5
+ import { Form } from './form'
6
+
7
+ interface WorktreeDeleteConfirmProps {
8
+ keybindsModeId: ModeId
9
+ reason: string
10
+ worktreeLabel: string
11
+ }
12
+
13
+ /**
14
+ * Shared confirmation dialog for a recoverable worktree delete. Used both inside
15
+ * the new-tab picker and as a standalone modal (sidebar "Remove worktree"); the
16
+ * keybinds mode wires Enter/y to confirm and Esc/n to cancel for each context.
17
+ */
18
+ export function WorktreeDeleteConfirm({
19
+ keybindsModeId,
20
+ reason,
21
+ worktreeLabel,
22
+ }: WorktreeDeleteConfirmProps) {
23
+ const t = useTheme()
24
+ return (
25
+ <Form
26
+ title="Delete worktree?"
27
+ keybindsModeId={keybindsModeId}
28
+ width={uiTokens.modalWidth.md}
29
+ footer={<text fg={t.textMuted}>Enter / y to delete · Esc / n to cancel</text>}
30
+ >
31
+ <box flexDirection="column" gap={1}>
32
+ <text fg={t.text}>
33
+ Delete <strong>{worktreeLabel}</strong>?
34
+ </text>
35
+ <text fg={t.warning}>{reason}</text>
36
+ </box>
37
+ </Form>
38
+ )
39
+ }
@@ -5,11 +5,16 @@ import type { AssistantId, WorktreeRecord } from '../../../../state/types'
5
5
 
6
6
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
7
7
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
8
- import { filterAssistants, getTemplateNoneOffset } from '../../../../state/selectors'
8
+ import {
9
+ buildBaseRefOptions,
10
+ filterAssistants,
11
+ getTemplateNoneOffset,
12
+ } from '../../../../state/selectors'
9
13
  import { useTheme } from '../../../theme'
10
14
  import { uiTokens } from '../../../ui-tokens'
11
- import { Form, TextField } from '../shared/form'
15
+ import { AutoComplete, Form, type FormOptionItem, TextField } from '../shared/form'
12
16
  import { Picker, type PickerItem } from '../shared/picker'
17
+ import { WorktreeDeleteConfirm } from '../shared/worktree-delete-confirm'
13
18
 
14
19
  interface NewTabModalProps {
15
20
  selectedIndex: number
@@ -19,16 +24,18 @@ interface NewTabModalProps {
19
24
  currentSessionId: string | null
20
25
  editingCommand: AssistantId | null
21
26
  editBuffer: string
22
- activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
27
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
23
28
  branchError: string | null
24
29
  branchName: string
25
30
  createWorktree: boolean
26
31
  selectedAssistantId: AssistantId | null
27
32
  step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
28
- worktreeDeleteConfirmId: string | null
29
- worktreeDeleteMessage: string | null
33
+ worktreeDeletePrompt: { worktreeId: string; reason: string } | null
30
34
  worktrees: WorktreeRecord[]
31
35
  worktreeName: string
36
+ baseQuery: string
37
+ baseRef: string
38
+ baseBranches: string[]
32
39
  worktreeTemplates: WorktreeTemplate[]
33
40
  /**
34
41
  * True when this modal is the worktree-aware new-tab flow. False when the
@@ -63,6 +70,9 @@ function getDeleteBlockedReason({
63
70
  export function NewTabModal({
64
71
  activeField,
65
72
  allowTemplateShortcut,
73
+ baseBranches,
74
+ baseQuery,
75
+ baseRef,
66
76
  branchError,
67
77
  branchName,
68
78
  createWorktree,
@@ -75,8 +85,7 @@ export function NewTabModal({
75
85
  selectedAssistantId,
76
86
  selectedIndex,
77
87
  step,
78
- worktreeDeleteConfirmId,
79
- worktreeDeleteMessage,
88
+ worktreeDeletePrompt,
80
89
  worktreeName,
81
90
  worktrees,
82
91
  worktreeTemplates,
@@ -107,9 +116,8 @@ export function NewTabModal({
107
116
  active && canDelete && currentSessionId != null && currentSessionId !== ''
108
117
  ? () => {
109
118
  dispatchGlobal({ index, type: 'set-modal-selection-index' })
110
- dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
111
119
  runSideEffectGlobal({
112
- force: worktreeDeleteConfirmId === worktree.id,
120
+ force: false,
113
121
  sessionId: currentSessionId,
114
122
  type: 'delete-worktree',
115
123
  worktreeId: worktree.id,
@@ -127,12 +135,15 @@ export function NewTabModal({
127
135
  }),
128
136
  {
129
137
  key: '__create-worktree__',
130
- 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
+ },
131
142
  subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
132
143
  title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
133
144
  },
134
145
  ],
135
- [createWorktree, currentSessionId, selectedIndex, t, worktreeDeleteConfirmId, worktrees]
146
+ [createWorktree, currentSessionId, selectedIndex, t, worktrees]
136
147
  )
137
148
 
138
149
  const noneOffset = getTemplateNoneOffset(selectedAssistantId)
@@ -216,6 +227,24 @@ export function NewTabModal({
216
227
  ]
217
228
  }, [customCommands, filtered, showShortcutEntry, selectedIndex, t])
218
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]
246
+ )
247
+
219
248
  if (editingCommand !== null) {
220
249
  const option = options.find((o) => o.id === editingCommand) ?? getAssistantOption(0)
221
250
  return (
@@ -260,6 +289,7 @@ export function NewTabModal({
260
289
  if (step === 'worktree-create') {
261
290
  const selectedAssistant =
262
291
  options.find((option) => option.id === selectedAssistantId) ?? options[0]
292
+ const baseActive = activeField === 'base'
263
293
  return (
264
294
  <Form
265
295
  title={`New worktree: ${selectedAssistant?.label ?? 'assistant'}`}
@@ -286,7 +316,20 @@ export function NewTabModal({
286
316
  <text fg={t.error}>{branchError}</text>
287
317
  ) : null}
288
318
  </box>
289
- <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>
290
333
  </box>
291
334
  </Form>
292
335
  )
@@ -297,6 +340,19 @@ export function NewTabModal({
297
340
  options.find((option) => option.id === selectedAssistantId) ??
298
341
  filtered[selectedIndex] ??
299
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
+
300
356
  const selectedWorktree = worktrees[selectedIndex]
301
357
  const deleteBlockedReason = getDeleteBlockedReason({
302
358
  currentSessionId,
@@ -316,17 +372,8 @@ export function NewTabModal({
316
372
  onHover={handleHover}
317
373
  footer={
318
374
  <box flexDirection="column">
319
- {(worktreeDeleteMessage != null && worktreeDeleteMessage !== '') ||
320
- (deleteBlockedReason != null && deleteBlockedReason !== '') ? (
321
- <text
322
- fg={
323
- worktreeDeleteMessage != null && worktreeDeleteMessage !== ''
324
- ? t.error
325
- : t.textMuted
326
- }
327
- >
328
- {worktreeDeleteMessage ?? deleteBlockedReason}
329
- </text>
375
+ {deleteBlockedReason != null && deleteBlockedReason !== '' ? (
376
+ <text fg={t.textMuted}>{deleteBlockedReason}</text>
330
377
  ) : null}
331
378
  <text fg={t.textMuted}>Step 2/2: choose worktree</text>
332
379
  <text fg={t.textMuted}>Enter launches, Ctrl+d deletes selected worktree</text>
package/src/ui/root.tsx CHANGED
@@ -34,6 +34,7 @@ import { GitCommitModal } from './components/modals/git/git-commit-modal'
34
34
  import { CreateSessionModal } from './components/modals/sessions/create-session-modal'
35
35
  import { SessionNameModal } from './components/modals/sessions/session-name-modal'
36
36
  import { SessionPickerModal } from './components/modals/sessions/session-picker-modal'
37
+ import { WorktreeDeleteConfirm } from './components/modals/shared/worktree-delete-confirm'
37
38
  import { SnippetEditorModal } from './components/modals/snippets/snippet-editor-modal'
38
39
  import { SnippetPickerModal } from './components/modals/snippets/snippet-picker-modal'
39
40
  import { NewTabModal } from './components/modals/tabs/new-tab-modal'
@@ -46,6 +47,7 @@ import { ToastViewport } from './components/overlays/toast/toast-viewport'
46
47
  import { useTheme } from './theme'
47
48
 
48
49
  const EMPTY_WORKTREES: WorktreeRecord[] = []
50
+ const EMPTY_BASE_BRANCHES: string[] = []
49
51
 
50
52
  function getCreateSessionFields(modal: ModalState) {
51
53
  if (modal.type !== 'create-session') {
@@ -114,8 +116,10 @@ function renderModal(
114
116
  createWorktree={modal.type === 'new-tab' ? modal.createWorktree : false}
115
117
  selectedAssistantId={modal.type === 'new-tab' ? modal.selectedAssistantId : null}
116
118
  step={modal.type === 'new-tab' ? modal.step : 'assistant'}
117
- worktreeDeleteConfirmId={modal.type === 'new-tab' ? modal.worktreeDeleteConfirmId : null}
118
- 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}
119
123
  worktrees={
120
124
  options.currentSessionId != null && options.currentSessionId !== ''
121
125
  ? (options.sessions.find((session) => session.id === options.currentSessionId)
@@ -225,6 +229,14 @@ function renderModal(
225
229
  )
226
230
  case 'ai-usage':
227
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
+ )
228
240
  case 'git-commit': {
229
241
  const titleText =
230
242
  modal.activeField === 'title' ? (modal.editBuffer ?? '') : modal.contentBuffer