@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,5 +1,59 @@
1
1
  import type { AssistantOption } from '../pty/command-registry'
2
- import type { SessionRecord, SnippetRecord } from './types'
2
+ import type { AssistantId, SessionRecord, SnippetRecord, WorktreeRecord } from './types'
3
+
4
+ export interface BaseRefOption {
5
+ /** Git ref the new worktree is forked from. */
6
+ ref: string
7
+ label: string
8
+ kind: 'worktree' | 'branch'
9
+ /** Worktree name, for the 'worktree' kind. */
10
+ detail?: string
11
+ }
12
+
13
+ /**
14
+ * Ordered, filtered base-ref candidates for the worktree-create "Base" picker:
15
+ * branches checked out in the session's worktrees first (labelled with the
16
+ * worktree name), then the remaining local branches. A branch already surfaced
17
+ * via a worktree is not repeated. Throwaway `aimux/` branches are skipped unless
18
+ * a live worktree is on them — `git worktree remove` leaves the branch behind,
19
+ * so deleted temp worktrees would otherwise haunt the list as orphan branches.
20
+ */
21
+ export function buildBaseRefOptions(
22
+ worktrees: WorktreeRecord[],
23
+ localBranches: string[],
24
+ query: string
25
+ ): BaseRefOption[] {
26
+ const seen = new Set<string>()
27
+ const options: BaseRefOption[] = []
28
+ for (const worktree of worktrees) {
29
+ if (worktree.branch == null || worktree.branch === '' || seen.has(worktree.branch)) continue
30
+ seen.add(worktree.branch)
31
+ options.push({
32
+ detail: worktree.name,
33
+ kind: 'worktree',
34
+ label: worktree.branch,
35
+ ref: worktree.branch,
36
+ })
37
+ }
38
+ for (const branch of localBranches) {
39
+ if (seen.has(branch) || branch.startsWith('aimux/')) continue
40
+ seen.add(branch)
41
+ options.push({ kind: 'branch', label: branch, ref: branch })
42
+ }
43
+ const trimmed = query.trim().toLowerCase()
44
+ if (trimmed === '') return options
45
+ return options.filter((option) => option.label.toLowerCase().includes(trimmed))
46
+ }
47
+
48
+ /**
49
+ * 0 when the template picker should NOT show the "None" fallback (no assistant
50
+ * was picked — typical for the template shortcut path), 1 otherwise. Shared
51
+ * by the modal reducer, the side-effect that resolves templateId, and the
52
+ * picker UI so the three stay in sync.
53
+ */
54
+ export function getTemplateNoneOffset(selectedAssistantId: AssistantId | null): 0 | 1 {
55
+ return selectedAssistantId == null ? 0 : 1
56
+ }
3
57
 
4
58
  export function filterAssistants(
5
59
  options: AssistantOption[],
@@ -72,6 +72,27 @@ export interface RestoreOptions {
72
72
  // will overwrite the status with daemon truth — leaving the flag on would
73
73
  // briefly flash the "Restored snapshot" hint on every j/k cycle.
74
74
  forceDisconnected?: boolean
75
+ // When provided, drop tabs pinned to a worktree id that the session no
76
+ // longer owns. Some delete paths (notably the sidebar's "Remove worktree")
77
+ // historically removed the worktree record without closing its tabs, leaving
78
+ // orphans bound to a vanished id. Those orphans are invisible (filtered out
79
+ // by the active-worktree filter) yet keep a worktree id that a *future*
80
+ // delete can collide with — exactly what closes "another worktree's" tabs.
81
+ // Pruning them on restore both repairs corrupted catalogs and prevents the
82
+ // collision. Tabs with no worktree id (legacy/unbound) are always kept.
83
+ validWorktreeIds?: ReadonlySet<string>
84
+ }
85
+
86
+ // Drop tabs bound to a worktree id the session no longer owns. Unbound tabs
87
+ // (no worktreeId) are kept — they surface under the primary worktree.
88
+ function pruneOrphanedTabs(
89
+ tabs: TabSession[],
90
+ validWorktreeIds: ReadonlySet<string> | undefined
91
+ ): TabSession[] {
92
+ if (!validWorktreeIds) return tabs
93
+ return tabs.filter(
94
+ (tab) => tab.worktreeId == null || tab.worktreeId === '' || validWorktreeIds.has(tab.worktreeId)
95
+ )
75
96
  }
76
97
 
77
98
  export function restoreTabsFromWorkspace(
@@ -84,7 +105,7 @@ export function restoreTabsFromWorkspace(
84
105
 
85
106
  const forceDisconnected = options.forceDisconnected ?? true
86
107
 
87
- return snapshot.tabs
108
+ const restored: TabSession[] = snapshot.tabs
88
109
  .filter(
89
110
  (tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
90
111
  tab.status !== 'exited'
@@ -103,6 +124,7 @@ export function restoreTabsFromWorkspace(
103
124
  viewport: tab.viewport,
104
125
  worktreeId: tab.worktreeId,
105
126
  }))
127
+ return pruneOrphanedTabs(restored, options.validWorktreeIds)
106
128
  }
107
129
 
108
130
  export function restoreLayoutTrees(
@@ -1,3 +1,5 @@
1
+ import type { WorktreeTemplate } from '../config'
2
+
1
3
  import { reduceAutoCommit } from './reducers/auto-commit-state'
2
4
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
3
5
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
@@ -31,6 +33,7 @@ export interface InitialStateOverrides {
31
33
  gitPane?: Partial<GitPaneState>
32
34
  sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
33
35
  sessionBarVisible?: boolean
36
+ worktreeTemplates?: WorktreeTemplate[]
34
37
  }
35
38
 
36
39
  const DEFAULT_GIT_PANE: GitPaneState = {
@@ -116,6 +119,7 @@ export function createInitialState(
116
119
  tabGroupMap: {},
117
120
  tabs: [],
118
121
  worktreeDivergence: {},
122
+ worktreeTemplates: overrides.worktreeTemplates ?? [],
119
123
  }
120
124
  }
121
125
 
@@ -1,6 +1,7 @@
1
1
  import type { ModeId, SnippetVar } from '@brimveyn/aimux-config'
2
2
  import type { ThemedToken } from 'shiki'
3
3
 
4
+ import type { WorktreeTemplate } from '../config'
4
5
  import type { LayoutNode, SplitDirection } from './layout-tree'
5
6
 
6
7
  export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal' | 'antigravity'
@@ -46,6 +47,7 @@ export type ModalType =
46
47
  | 'update-available'
47
48
  | 'ai-usage'
48
49
  | 'worktree-move'
50
+ | 'worktree-delete-confirm'
49
51
  | null
50
52
 
51
53
  export interface TerminalSpan {
@@ -322,16 +324,21 @@ export interface ModalClosed extends ModalBase {
322
324
  export interface ModalNewTab extends ModalBase {
323
325
  type: 'new-tab'
324
326
  editingCommand: AssistantId | null
325
- activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
327
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
326
328
  branchError: string | null
327
329
  branchName: string
328
330
  createWorktree: boolean
329
331
  selectedAssistantId: AssistantId | null
330
- step: 'assistant' | 'worktree' | 'worktree-create'
332
+ step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
331
333
  targetWorktreeIndex: number
332
- worktreeDeleteConfirmId: string | null
333
- worktreeDeleteMessage: string | null
334
+ worktreeDeletePrompt: { worktreeId: string; reason: string } | null
334
335
  worktreeName: string
336
+ /** Filter text typed into the "Base" picker on the worktree-create step. */
337
+ baseQuery: string
338
+ /** Resolved base ref the new worktree is forked from (branch of a worktree or a local branch). */
339
+ baseRef: string
340
+ /** Local branches available as base refs, loaded when the create step opens. */
341
+ baseBranches: string[]
335
342
  }
336
343
 
337
344
  export interface ModalSessionPicker extends ModalBase {
@@ -416,6 +423,22 @@ export interface ModalWorktreeMove extends ModalBase {
416
423
  deleteSource: boolean
417
424
  }
418
425
 
426
+ /**
427
+ * Standalone confirmation for a recoverable worktree delete failure triggered
428
+ * outside the new-tab picker (e.g. the sidebar's "Remove worktree"). Carries the
429
+ * params needed to re-run the delete with force once confirmed.
430
+ */
431
+ export interface ModalWorktreeDeleteConfirm extends ModalBase {
432
+ type: 'worktree-delete-confirm'
433
+ sessionId: string
434
+ worktreeId: string
435
+ worktreeLabel: string
436
+ reason: string
437
+ closeTabs: boolean
438
+ /** Whether confirming force-deletes — true only after a recoverable failure. */
439
+ force: boolean
440
+ }
441
+
419
442
  export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
420
443
 
421
444
  export interface DirectoryResult {
@@ -439,6 +462,7 @@ export type ModalState =
439
462
  | ModalUpdateAvailable
440
463
  | ModalAIUsage
441
464
  | ModalWorktreeMove
465
+ | ModalWorktreeDeleteConfirm
442
466
 
443
467
  export interface LayoutState {
444
468
  terminalCols: number
@@ -504,6 +528,8 @@ export interface AppState {
504
528
  lastActiveTabByWorktree: Record<string, string>
505
529
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
506
530
  pendingChords: string[] | null
531
+ /** User-defined templates applied at worktree creation. Loaded from aimux.json. */
532
+ worktreeTemplates: WorktreeTemplate[]
507
533
  }
508
534
 
509
535
  // -- Modal actions --
@@ -512,11 +538,13 @@ export type ModalAction =
512
538
  | { type: 'open-new-tab-modal' }
513
539
  | { type: 'set-new-tab-branch-error'; message: string | null }
514
540
  | {
515
- type: 'set-new-tab-worktree-delete-state'
516
- confirmWorktreeId?: string | null
517
- message: string | null
541
+ type: 'set-new-tab-worktree-delete-prompt'
542
+ prompt: { worktreeId: string; reason: string } | null
518
543
  }
544
+ | { type: 'set-new-tab-base-branches'; branches: string[] }
519
545
  | { type: 'enter-new-tab-worktree-create' }
546
+ | { type: 'enter-new-tab-template-pick' }
547
+ | { type: 'enter-new-tab-template-shortcut' }
520
548
  | { type: 'select-new-tab-assistant'; assistantId?: AssistantId }
521
549
  | { type: 'toggle-new-tab-worktree'; assistantId?: AssistantId }
522
550
  | { type: 'open-edit-custom-command'; assistantId: AssistantId }
@@ -548,6 +576,15 @@ export type ModalAction =
548
576
  | { type: 'open-ai-usage-modal' }
549
577
  | { type: 'open-worktree-move-modal'; sourceWorktreeId: string }
550
578
  | { type: 'toggle-worktree-move-delete' }
579
+ | {
580
+ type: 'open-worktree-delete-confirm'
581
+ sessionId: string
582
+ worktreeId: string
583
+ worktreeLabel: string
584
+ reason: string
585
+ closeTabs: boolean
586
+ force: boolean
587
+ }
551
588
 
552
589
  // -- Session actions --
553
590
  export type SessionAction =
@@ -565,7 +602,6 @@ export type SessionAction =
565
602
  | { type: 'reorder-active-session'; delta: number }
566
603
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
567
604
  | { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
568
- | { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
569
605
  | { type: 'set-active-worktree'; sessionId: string; worktreeId: string }
570
606
  | {
571
607
  type: 'update-worktree-record'
@@ -590,6 +626,7 @@ export type TabAction =
590
626
  | { type: 'set-active-tab'; tabId: string }
591
627
  | { type: 'move-active-tab'; delta: number }
592
628
  | { type: 'reorder-active-tab'; delta: number }
629
+ | { type: 'reorder-tabs'; orderedTabIds: string[] }
593
630
  | { type: 'reset-tab-session'; tabId: string }
594
631
  | { type: 'rename-tab'; tabId: string; title: string }
595
632
  | { type: 'append-tab-buffer'; tabId: string; chunk: string }
@@ -68,14 +68,24 @@ export const WorktreeRow = memo(function WorktreeRow({
68
68
  [
69
69
  'Remove worktree',
70
70
  () =>
71
+ // Always confirm first. Confirming routes through the full delete side
72
+ // effect (closes the worktree's tabs, disposes their PTYs, prunes the
73
+ // snapshot, removes the git worktree). closeTabs (not force) cleans up
74
+ // the tabs while keeping the non-force `git worktree remove`, so
75
+ // uncommitted work in a temp worktree is still protected — a dirty
76
+ // worktree re-prompts for an explicit force-delete.
71
77
  dispatchGlobal({
78
+ closeTabs: true,
79
+ force: false,
80
+ reason: 'Its assistant tabs will be closed and the worktree removed.',
72
81
  sessionId: session.id,
73
- type: 'remove-worktree-record',
82
+ type: 'open-worktree-delete-confirm',
74
83
  worktreeId: worktree.id,
84
+ worktreeLabel: worktree.branch ?? worktree.name,
75
85
  }),
76
86
  ],
77
87
  ]
78
- }, [session.id, worktree.id, worktree.source])
88
+ }, [session.id, worktree.branch, worktree.id, worktree.name, worktree.source])
79
89
 
80
90
  let bgColor: string | undefined
81
91
  if (isActiveItem) {
@@ -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
+ }