@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.15",
3
+ "version": "1.14.16",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.6.9",
63
+ "@brimveyn/aimux-config": "0.6.10",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -22,10 +22,13 @@ import { enqueueGitOp } from '../git/command-queue'
22
22
  import { moveWorktree } from '../git/move-worktree'
23
23
  import {
24
24
  createGitWorktree,
25
+ deleteGitBranch,
25
26
  getCurrentBranch,
26
27
  getHeadSha,
27
28
  getMainWorktreeRoot,
28
29
  listGitWorktrees,
30
+ listLocalBranches,
31
+ pruneGitWorktrees,
29
32
  removeGitWorktree,
30
33
  } from '../git/worktree'
31
34
  import { createPrefixedId } from '../platform/id'
@@ -419,6 +422,7 @@ async function launchAssistantInNewWorktree(
419
422
  worktreeName: string,
420
423
  branchName?: string,
421
424
  sourceWorktreeId?: string,
425
+ baseRef?: string,
422
426
  templateId?: string
423
427
  ): Promise<void> {
424
428
  const sessionId = ctx.state.currentSessionId
@@ -428,7 +432,7 @@ async function launchAssistantInNewWorktree(
428
432
  sessionId,
429
433
  worktreeName,
430
434
  branchName,
431
- undefined,
435
+ baseRef,
432
436
  sourceWorktreeId
433
437
  )
434
438
  if (!worktree) return
@@ -665,6 +669,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
665
669
  if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
666
670
  const worktreeName = state.modal.worktreeName
667
671
  const branchName = state.modal.branchName
672
+ const baseRef = state.modal.baseRef
668
673
  const sourceWorktreeId = getNewTabTargetWorktreeId(state)
669
674
  let templateId: string | undefined
670
675
  if (state.modal.step === 'template') {
@@ -683,6 +688,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
683
688
  worktreeName,
684
689
  branchName,
685
690
  sourceWorktreeId,
691
+ baseRef !== '' ? baseRef : undefined,
686
692
  templateId
687
693
  )
688
694
  )
@@ -700,6 +706,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
700
706
  dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
701
707
  return
702
708
  }
709
+ case 'load-new-tab-base-branches': {
710
+ void (async () => {
711
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
712
+ const sourcePath = getActiveWorktree(session)?.path ?? getSessionProjectPath(session)
713
+ if (!(sourcePath != null && sourcePath !== '')) return
714
+ const branches = await listLocalBranches(sourcePath)
715
+ if (ctx.getState().modal.type !== 'new-tab') return
716
+ ctx.dispatch({ branches, type: 'set-new-tab-base-branches' })
717
+ })()
718
+ return
719
+ }
703
720
  case 'confirm-selected-session': {
704
721
  handleSessionSelection(ctx)
705
722
  return
@@ -720,27 +737,40 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
720
737
  { ...ctx, state: ctx.getState() },
721
738
  effect.sessionId,
722
739
  effect.worktreeId,
723
- !!(effect.force === true)
740
+ !!(effect.force === true),
741
+ !!(effect.closeTabs === true)
724
742
  )
725
743
  )
726
744
  } catch (error) {
727
745
  const message = error instanceof Error ? error.message : String(error)
728
- const forceable = isForceableWorktreeDeleteError(message)
746
+ // Real errors surface as a toast. Recoverable failures (dirty tree,
747
+ // active tabs, …) open a confirmation so the user can opt into a
748
+ // force-delete: in-place inside the new-tab worktree picker (preserving
749
+ // it), or as a standalone modal elsewhere (e.g. the sidebar's "Remove
750
+ // worktree").
751
+ if (!isForceableWorktreeDeleteError(message)) {
752
+ toast.error(`Could not delete worktree: ${message}`)
753
+ return
754
+ }
729
755
  const latest = ctx.getState()
730
756
  if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
731
- const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
732
- const selected = session?.worktrees?.[latest.modal.selectedIndex]
733
- if (selected && selected.id !== effect.worktreeId) {
734
- ctx.dispatch({ message, type: 'git-mode-set-message' })
735
- return
736
- }
757
+ ctx.dispatch({
758
+ prompt: { reason: message, worktreeId: effect.worktreeId },
759
+ type: 'set-new-tab-worktree-delete-prompt',
760
+ })
761
+ return
737
762
  }
763
+ const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
764
+ const worktree = session?.worktrees?.find((entry) => entry.id === effect.worktreeId)
738
765
  ctx.dispatch({
739
- confirmWorktreeId: forceable ? effect.worktreeId : null,
740
- message: forceable ? message : `Could not delete worktree: ${message}`,
741
- type: 'set-new-tab-worktree-delete-state',
766
+ closeTabs: effect.closeTabs === true,
767
+ force: true,
768
+ reason: message,
769
+ sessionId: effect.sessionId,
770
+ type: 'open-worktree-delete-confirm',
771
+ worktreeId: effect.worktreeId,
772
+ worktreeLabel: worktree?.branch ?? worktree?.name ?? 'this worktree',
742
773
  })
743
- ctx.dispatch({ message, type: 'git-mode-set-message' })
744
774
  }
745
775
  })()
746
776
  return
@@ -1544,7 +1574,8 @@ async function runDeleteWorktree(
1544
1574
  ctx: SideEffectContext,
1545
1575
  sessionId: string,
1546
1576
  worktreeId: string,
1547
- force: boolean
1577
+ force: boolean,
1578
+ closeTabs = false
1548
1579
  ): Promise<void> {
1549
1580
  const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1550
1581
  const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
@@ -1554,36 +1585,43 @@ async function runDeleteWorktree(
1554
1585
  if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
1555
1586
 
1556
1587
  const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
1557
- if (tabsInWorktree.length > 0 && !force) {
1588
+ // The active-tabs guard asks the modal user to confirm before closing tabs.
1589
+ // `closeTabs` (the sidebar's "Remove worktree") opts into closing them
1590
+ // directly without forcing the git removal, so dirty temp worktrees are still
1591
+ // protected by the non-force `git worktree remove`.
1592
+ if (tabsInWorktree.length > 0 && !force && !closeTabs) {
1558
1593
  throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1559
1594
  }
1560
1595
  disposeWorktreeTabs(ctx, worktreeId)
1561
1596
 
1562
- if (
1563
- worktree.source === 'aimux-temp' &&
1564
- worktree.createdByAimux &&
1565
- isInsideAimuxWorktreeRoot(worktree.path) &&
1566
- !existsSync(worktree.path)
1567
- ) {
1597
+ const repoPath = resolveWorktreeGitDir(session, worktree)
1598
+ const isAimuxTemp = worktree.source === 'aimux-temp' && worktree.createdByAimux
1599
+ // Drop the throwaway aimux branch alongside the worktree so deleted temp
1600
+ // worktrees don't accumulate in the repo or haunt the base picker. Scoped to
1601
+ // the `aimux/` namespace (matches the picker filter); best-effort.
1602
+ const cleanupAimuxBranch = async (): Promise<void> => {
1603
+ const branch = worktree.branch
1604
+ if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
1605
+ await deleteGitBranch(repoPath, branch)
1606
+ }
1607
+ }
1608
+
1609
+ if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path) && !existsSync(worktree.path)) {
1610
+ // The dir vanished but git may still pin the branch to a stale worktree entry.
1611
+ await pruneGitWorktrees(repoPath)
1612
+ await cleanupAimuxBranch()
1568
1613
  removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1569
1614
  return
1570
1615
  }
1571
1616
 
1572
- if (
1573
- worktree.source === 'aimux-temp' &&
1574
- worktree.createdByAimux &&
1575
- isInsideAimuxWorktreeRoot(worktree.path)
1576
- ) {
1617
+ if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path)) {
1577
1618
  await assertSafeAimuxWorktreePath(worktree.path)
1578
- await removeGitWorktree({
1579
- force,
1580
- repoPath: resolveWorktreeGitDir(session, worktree),
1581
- targetPath: worktree.path,
1582
- })
1619
+ await removeGitWorktree({ force, repoPath, targetPath: worktree.path })
1583
1620
  } else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
1584
1621
  throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
1585
1622
  }
1586
1623
 
1624
+ await cleanupAimuxBranch()
1587
1625
  removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
1588
1626
  }
1589
1627
 
@@ -1694,7 +1732,7 @@ function removeWorktreeRecordFromSession(
1694
1732
  type: 'set-modal-selection-index',
1695
1733
  })
1696
1734
  }
1697
- ctx.dispatch({ message: null, type: 'set-new-tab-worktree-delete-state' })
1735
+ ctx.dispatch({ prompt: null, type: 'set-new-tab-worktree-delete-prompt' })
1698
1736
  }
1699
1737
 
1700
1738
  function isForceableWorktreeDeleteError(message: string): boolean {
@@ -1706,7 +1744,7 @@ function isForceableWorktreeDeleteError(message: string): boolean {
1706
1744
  class ActiveWorktreeTabsError extends Error {
1707
1745
  constructor(tabCount: number) {
1708
1746
  super(
1709
- `active assistant tabs are using this worktree (${tabCount}). Click [del] again to close them and delete the worktree.`
1747
+ `active assistant tabs are using this worktree (${tabCount}) they will be closed if you confirm.`
1710
1748
  )
1711
1749
  }
1712
1750
  }
package/src/app.tsx CHANGED
@@ -22,7 +22,9 @@ import { useMouseHandlers } from './app-runtime/use-mouse-handlers'
22
22
  import { useRendererBindings } from './app-runtime/use-renderer-bindings'
23
23
  import { useTerminalResize } from './app-runtime/use-terminal-resize'
24
24
  import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
25
- import { loadConfig } from './config'
25
+ import { loadConfig, saveConfig } from './config'
26
+ import { enqueueGitOp } from './git/command-queue'
27
+ import { pruneOrphanAimuxBranches } from './git/worktree'
26
28
  import { setActiveKeymap } from './input/keymap/keymap-ref'
27
29
  import { deriveModeId } from './input/modes/bridge'
28
30
  import { registerAllModes } from './input/modes/handlers'
@@ -39,6 +41,7 @@ import { findMostRecentSession, loadSessionCatalog } from './state/session-catal
39
41
  import { getSessionProjectPath } from './state/session-worktrees'
40
42
  import { loadSnippetCatalog, mergeConfigSnippets } from './state/snippet-catalog'
41
43
  import { createInitialState } from './state/store'
44
+ import { toast } from './state/toast-store'
42
45
  import { KeymapContext } from './ui/keymap-context'
43
46
  import { RootView } from './ui/root'
44
47
  import {
@@ -250,6 +253,28 @@ export function App({
250
253
  // eslint-disable-next-line react-hooks/exhaustive-deps
251
254
  }, [])
252
255
 
256
+ useEffect(() => {
257
+ // One-shot cleanup: prune `aimux/` branches orphaned by temp worktrees that
258
+ // were deleted before delete-time branch cleanup existed. Gated by a config
259
+ // flag so it runs once per machine. All git calls are best-effort (nothrow)
260
+ // and git protects branches still checked out in a live worktree.
261
+ if (loadConfig().prunedOrphanAimuxBranches === true) return
262
+ void (async () => {
263
+ const repoRoots = new Set<string>()
264
+ for (const session of loadSessionCatalog()) {
265
+ for (const worktree of session.worktrees ?? []) {
266
+ if (worktree.repoRoot !== '') repoRoots.add(worktree.repoRoot)
267
+ }
268
+ }
269
+ let removed = 0
270
+ for (const repoRoot of repoRoots) {
271
+ removed += await enqueueGitOp(() => pruneOrphanAimuxBranches(repoRoot))
272
+ }
273
+ saveConfig({ ...loadConfig(), prunedOrphanAimuxBranches: true })
274
+ if (removed > 0) toast.success(`Cleaned ${removed} orphaned aimux branch(es)`)
275
+ })()
276
+ }, [])
277
+
253
278
  const resizingRef = useRef(false)
254
279
  // Seeded with state.layout's DEFAULT 80x24; reassigned below once
255
280
  // useTerminalResize has produced the open-loop estimate from real dimensions.
package/src/config.ts CHANGED
@@ -64,6 +64,8 @@ export interface AimuxConfig {
64
64
  sessionBarVisible?: boolean
65
65
  workspaceSnapshot?: WorkspaceSnapshotV1
66
66
  skippedUpdateVersion?: string
67
+ /** One-shot guard: orphan `aimux/` branches were pruned from existing repos. */
68
+ prunedOrphanAimuxBranches?: boolean
67
69
  worktreeTemplates?: WorktreeTemplate[]
68
70
  }
69
71
 
@@ -255,6 +257,7 @@ export function loadConfigResult(): ConfigLoadResult {
255
257
  sessionBarVisible?: unknown
256
258
  workspaceSnapshot?: unknown
257
259
  skippedUpdateVersion?: unknown
260
+ prunedOrphanAimuxBranches?: unknown
258
261
  worktreeTemplates?: unknown
259
262
  }
260
263
 
@@ -350,6 +353,7 @@ export function loadConfigResult(): ConfigLoadResult {
350
353
  config: {
351
354
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
352
355
  gitPane: validGitPane,
356
+ prunedOrphanAimuxBranches: parsed.prunedOrphanAimuxBranches === true ? true : undefined,
353
357
  sessionBarVisible: validSessionBarVisible,
354
358
  sidebar: validSidebar,
355
359
  skippedUpdateVersion: validSkippedUpdateVersion,
@@ -33,6 +33,24 @@ export async function getHeadSha(cwd: string): Promise<string | undefined> {
33
33
  return result.text().trim() || undefined
34
34
  }
35
35
 
36
+ // Local branch names, ordered most-recently-committed first so the likely base
37
+ // surfaces near the top of the picker.
38
+ export async function listLocalBranches(cwd: string): Promise<string[]> {
39
+ // The format must be interpolated, not inlined: Bun's shell parses a bare
40
+ // `%(refname:short)` and chokes on the parentheses.
41
+ const format = '%(refname:short)'
42
+ const result =
43
+ await $`git -C ${cwd} for-each-ref --sort=-committerdate refs/heads --format=${format}`
44
+ .quiet()
45
+ .nothrow()
46
+ if (result.exitCode !== 0) return []
47
+ return result
48
+ .text()
49
+ .split('\n')
50
+ .map((line) => line.trim())
51
+ .filter((line) => line !== '')
52
+ }
53
+
36
54
  export async function createGitWorktree({
37
55
  baseRef,
38
56
  branchName,
@@ -52,6 +70,33 @@ export async function createGitWorktree({
52
70
  }
53
71
  }
54
72
 
73
+ // Force-delete a local branch. Returns false (without throwing) when git
74
+ // refuses — notably when the branch is still checked out in a live worktree,
75
+ // which is exactly how orphan-pruning stays safe.
76
+ export async function deleteGitBranch(repoPath: string, branch: string): Promise<boolean> {
77
+ const result = await $`git -C ${repoPath} branch -D ${branch}`.quiet().nothrow()
78
+ return result.exitCode === 0
79
+ }
80
+
81
+ export async function pruneGitWorktrees(repoPath: string): Promise<void> {
82
+ await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
83
+ }
84
+
85
+ // Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
86
+ // to delete branches still checked out in a live worktree, so this only removes
87
+ // true orphans. Returns the number of branches removed.
88
+ export async function pruneOrphanAimuxBranches(repoPath: string): Promise<number> {
89
+ await pruneGitWorktrees(repoPath)
90
+ const branches = (await listLocalBranches(repoPath)).filter((branch) =>
91
+ branch.startsWith('aimux/')
92
+ )
93
+ let removed = 0
94
+ for (const branch of branches) {
95
+ if (await deleteGitBranch(repoPath, branch)) removed++
96
+ }
97
+ return removed
98
+ }
99
+
55
100
  export async function removeGitWorktree({
56
101
  force,
57
102
  repoPath,
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
27
  'ai-usage': 'modal.ai-usage',
28
28
  'update-available': 'modal.update-available',
29
+ 'worktree-delete-confirm': 'modal.worktree-delete-confirm',
29
30
  'worktree-move': 'modal.worktree-move',
30
31
  }
31
32
 
@@ -51,6 +52,9 @@ export function deriveModeId(state: AppState): ModeId {
51
52
  if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
52
53
  return 'modal.new-tab.editing-command'
53
54
  }
55
+ if (state.modal.type === 'new-tab' && state.modal.worktreeDeletePrompt !== null) {
56
+ return 'modal.new-tab.worktree-delete-confirm'
57
+ }
54
58
  if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
55
59
  return 'modal.git-commit.confirm'
56
60
  }
@@ -8,8 +8,13 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
8
8
  'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
9
9
  'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
10
10
  'modal.help.filtering': ['navigation'],
11
- 'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
11
+ 'modal.new-tab.command-edit': [
12
+ 'navigation',
13
+ 'modal.new-tab.editing-command',
14
+ 'modal.new-tab.worktree-delete-confirm',
15
+ ],
12
16
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
17
+ 'modal.new-tab.worktree-delete-confirm': ['navigation', 'modal.new-tab.command-edit'],
13
18
  'modal.rename-tab': ['navigation'],
14
19
  'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
15
20
  'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
@@ -18,6 +23,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
18
23
  'modal.split-picker': ['navigation', 'terminal-input'],
19
24
  'modal.theme-picker.filtering': ['navigation'],
20
25
  'modal.update-available': ['navigation'],
26
+ 'modal.worktree-delete-confirm': ['navigation'],
21
27
  'modal.worktree-move': ['git-mode', 'navigation'],
22
28
  'navigation': [
23
29
  'terminal-input',
@@ -30,6 +36,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
30
36
  'modal.rename-tab',
31
37
  'modal.update-available',
32
38
  'modal.ai-usage',
39
+ 'modal.worktree-delete-confirm',
33
40
  'git-mode',
34
41
  ],
35
42
  'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
@@ -9,6 +9,8 @@ export type ModeId =
9
9
  | 'git-mode'
10
10
  | 'modal.new-tab.command-edit'
11
11
  | 'modal.new-tab.editing-command'
12
+ | 'modal.new-tab.worktree-delete-confirm'
13
+ | 'modal.worktree-delete-confirm'
12
14
  | 'modal.session-picker.filtering'
13
15
  | 'modal.session-name'
14
16
  | 'modal.create-session'
@@ -28,6 +30,7 @@ export type ModeId =
28
30
  export type SideEffect =
29
31
  | { type: 'quit'; state: AppState }
30
32
  | { type: 'launch-selected-assistant' }
33
+ | { type: 'load-new-tab-base-branches' }
31
34
  | { type: 'edit-selected-assistant' }
32
35
  | { type: 'confirm-selected-session' }
33
36
  | { type: 'delete-selected-session' }
@@ -71,7 +74,18 @@ export type SideEffect =
71
74
  | { type: 'cycle-sidebar-item'; direction: 1 | -1 }
72
75
  | { type: 'switch-tab-by-index'; index: number }
73
76
  | { type: 'delete-session'; sessionId: string }
74
- | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
77
+ | {
78
+ type: 'delete-worktree'
79
+ sessionId: string
80
+ worktreeId: string
81
+ // Force the git worktree removal (discards uncommitted changes in the
82
+ // worktree). Also implies closing the worktree's tabs.
83
+ force?: boolean
84
+ // Close the worktree's tabs without forcing the git removal. Lets the
85
+ // sidebar "Remove worktree" clean up tabs (avoiding orphans) while still
86
+ // refusing to discard uncommitted work in a temp worktree.
87
+ closeTabs?: boolean
88
+ }
75
89
  | {
76
90
  type: 'move-worktree'
77
91
  sessionId: string