@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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +242 -38
- package/src/app.tsx +30 -1
- package/src/config.ts +101 -0
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +4 -0
- package/src/input/modes/transitions.ts +8 -1
- package/src/input/modes/types.ts +15 -1
- package/src/state/reducers/modal-state.ts +232 -33
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +55 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/store.ts +4 -0
- package/src/state/types.ts +45 -8
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +181 -44
- package/src/ui/root.tsx +21 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.14.
|
|
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.
|
|
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",
|
|
@@ -16,16 +16,19 @@ import type { SessionBackend } from '../session-backend/types'
|
|
|
16
16
|
import type { AppAction, AppState, AssistantId, TabSession, WorktreeRecord } from '../state/types'
|
|
17
17
|
import type { ThemeId } from '../ui/themes'
|
|
18
18
|
|
|
19
|
-
import { loadConfig, saveConfig } from '../config'
|
|
19
|
+
import { loadConfig, saveConfig, type WorktreeTemplate, type WorktreeTemplatePane } from '../config'
|
|
20
20
|
import { logInputDebug } from '../debug/input-log'
|
|
21
21
|
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'
|
|
@@ -54,7 +57,12 @@ import {
|
|
|
54
57
|
type SplitDirection,
|
|
55
58
|
splitNode,
|
|
56
59
|
} from '../state/layout-tree'
|
|
57
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
filterAssistants,
|
|
62
|
+
filterSessions,
|
|
63
|
+
filterSnippets,
|
|
64
|
+
getTemplateNoneOffset,
|
|
65
|
+
} from '../state/selectors'
|
|
58
66
|
import { saveSessionCatalog } from '../state/session-catalog'
|
|
59
67
|
import { pruneSnapshotOfWorktree } from '../state/session-persistence'
|
|
60
68
|
import {
|
|
@@ -73,6 +81,7 @@ import { filterThemeIds } from '../ui/filter-themes'
|
|
|
73
81
|
import { scrollGitDiff } from '../ui/git-view-controls'
|
|
74
82
|
import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
|
|
75
83
|
import { triggerAutoCommitNow } from './auto-commit-ref'
|
|
84
|
+
import { writeToTab } from './pty-write'
|
|
76
85
|
import {
|
|
77
86
|
handleCreateSessionEffect,
|
|
78
87
|
handleDeleteSessionEffect,
|
|
@@ -88,6 +97,15 @@ import {
|
|
|
88
97
|
} from './snippet-actions'
|
|
89
98
|
|
|
90
99
|
const STARTUP_GRACE_MS = 5_000
|
|
100
|
+
/**
|
|
101
|
+
* Delay before injecting a template pane's `send` payload into its PTY.
|
|
102
|
+
* Short enough that shells (which print a prompt within ~100 ms) receive the
|
|
103
|
+
* command after their prompt is drawn; long enough to clear most PTY init
|
|
104
|
+
* races. Not tied to STARTUP_GRACE_MS because that is the assistant timeout,
|
|
105
|
+
* not a readiness signal. See review note: a `tab.status === 'running'`
|
|
106
|
+
* subscription would be more robust and is the planned follow-up.
|
|
107
|
+
*/
|
|
108
|
+
const TEMPLATE_SEND_DELAY_MS = 600
|
|
91
109
|
|
|
92
110
|
export interface SideEffectContext {
|
|
93
111
|
state: AppState
|
|
@@ -403,7 +421,9 @@ async function launchAssistantInNewWorktree(
|
|
|
403
421
|
assistant: AssistantId,
|
|
404
422
|
worktreeName: string,
|
|
405
423
|
branchName?: string,
|
|
406
|
-
sourceWorktreeId?: string
|
|
424
|
+
sourceWorktreeId?: string,
|
|
425
|
+
baseRef?: string,
|
|
426
|
+
templateId?: string
|
|
407
427
|
): Promise<void> {
|
|
408
428
|
const sessionId = ctx.state.currentSessionId
|
|
409
429
|
if (!(sessionId != null && sessionId !== '')) return
|
|
@@ -412,14 +432,27 @@ async function launchAssistantInNewWorktree(
|
|
|
412
432
|
sessionId,
|
|
413
433
|
worktreeName,
|
|
414
434
|
branchName,
|
|
415
|
-
|
|
435
|
+
baseRef,
|
|
416
436
|
sourceWorktreeId
|
|
417
437
|
)
|
|
418
438
|
if (!worktree) return
|
|
439
|
+
|
|
440
|
+
const template =
|
|
441
|
+
templateId != null && templateId !== ''
|
|
442
|
+
? ctx.state.worktreeTemplates.find((entry) => entry.id === templateId)
|
|
443
|
+
: undefined
|
|
444
|
+
|
|
445
|
+
ctx.dispatch({ type: 'close-modal' })
|
|
446
|
+
|
|
447
|
+
if (template) {
|
|
448
|
+
applyWorktreeTemplate(ctx, template, worktree.id, worktree.path)
|
|
449
|
+
ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
|
|
419
453
|
const customCommand = ctx.state.customCommands[assistant]
|
|
420
454
|
const tab = createTabSession(assistant, customCommand, ctx.state.customCommands, worktree.id)
|
|
421
455
|
ctx.dispatch({ tab, type: 'add-tab' })
|
|
422
|
-
ctx.dispatch({ type: 'close-modal' })
|
|
423
456
|
ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
|
|
424
457
|
startTabSession(
|
|
425
458
|
ctx.backend,
|
|
@@ -433,6 +466,126 @@ async function launchAssistantInNewWorktree(
|
|
|
433
466
|
)
|
|
434
467
|
}
|
|
435
468
|
|
|
469
|
+
function applyWorktreeTemplate(
|
|
470
|
+
ctx: SideEffectContext,
|
|
471
|
+
template: WorktreeTemplate,
|
|
472
|
+
worktreeId: string,
|
|
473
|
+
worktreePath: string
|
|
474
|
+
): void {
|
|
475
|
+
let firstTabId: string | null = null
|
|
476
|
+
|
|
477
|
+
for (const templateTab of template.tabs) {
|
|
478
|
+
const localToTabId = new Map<string, string>()
|
|
479
|
+
|
|
480
|
+
for (let i = 0; i < templateTab.panes.length; i++) {
|
|
481
|
+
const pane = templateTab.panes[i]
|
|
482
|
+
if (!pane) continue
|
|
483
|
+
const tab = createPaneTab(ctx, pane, worktreeId)
|
|
484
|
+
localToTabId.set(pane.id, tab.id)
|
|
485
|
+
|
|
486
|
+
if (i === 0) {
|
|
487
|
+
if (firstTabId == null) firstTabId = tab.id
|
|
488
|
+
ctx.dispatch({ tab, type: 'add-tab' })
|
|
489
|
+
startTabSession(
|
|
490
|
+
ctx.backend,
|
|
491
|
+
ctx.dispatch,
|
|
492
|
+
ctx.clearStartupGrace,
|
|
493
|
+
(tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
|
|
494
|
+
tab,
|
|
495
|
+
ctx.state.layout.terminalCols,
|
|
496
|
+
ctx.state.layout.terminalRows,
|
|
497
|
+
worktreePath
|
|
498
|
+
)
|
|
499
|
+
} else {
|
|
500
|
+
const splitFromId =
|
|
501
|
+
pane.splitFrom != null && pane.splitFrom !== ''
|
|
502
|
+
? localToTabId.get(pane.splitFrom)
|
|
503
|
+
: undefined
|
|
504
|
+
const direction: SplitDirection = pane.direction ?? 'vertical'
|
|
505
|
+
if (splitFromId == null || splitFromId === '') {
|
|
506
|
+
logInputDebug('template.splitFrom.unresolved', {
|
|
507
|
+
paneId: pane.id,
|
|
508
|
+
splitFrom: pane.splitFrom ?? null,
|
|
509
|
+
templateId: template.id,
|
|
510
|
+
})
|
|
511
|
+
continue
|
|
512
|
+
}
|
|
513
|
+
splitFromTab(ctx, splitFromId, direction, tab, worktreePath)
|
|
514
|
+
if (pane.ratio != null) {
|
|
515
|
+
const sourceRatio = clampSplitRatio(1 - pane.ratio)
|
|
516
|
+
ctx.dispatch({
|
|
517
|
+
axis: direction,
|
|
518
|
+
ratio: sourceRatio,
|
|
519
|
+
tabId: tab.id,
|
|
520
|
+
type: 'set-split-ratio',
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (pane.send != null && pane.send !== '') {
|
|
526
|
+
const payload = `${pane.send}\n`
|
|
527
|
+
const targetTabId = tab.id
|
|
528
|
+
setTimeout(() => {
|
|
529
|
+
const latest = ctx.getState()
|
|
530
|
+
const latestTab = latest.tabs.find((entry) => entry.id === targetTabId)
|
|
531
|
+
writeToTab(ctx.backend, targetTabId, latestTab, payload)
|
|
532
|
+
}, TEMPLATE_SEND_DELAY_MS)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (firstTabId != null) {
|
|
538
|
+
ctx.dispatch({ tabId: firstTabId, type: 'set-active-tab' })
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function createPaneTab(
|
|
543
|
+
ctx: SideEffectContext,
|
|
544
|
+
pane: WorktreeTemplatePane,
|
|
545
|
+
worktreeId: string
|
|
546
|
+
): TabSession {
|
|
547
|
+
// Accept `'shell'` as an alias for the registered `'terminal'` assistant so
|
|
548
|
+
// template examples using the more intuitive name don't silently fall back
|
|
549
|
+
// to Claude (createTabSession's unknown-id fallback resolves to index 0).
|
|
550
|
+
const assistantId = (pane.assistant === 'shell' ? 'terminal' : pane.assistant) as AssistantId
|
|
551
|
+
const customCommand = ctx.state.customCommands[assistantId]
|
|
552
|
+
return createTabSession(assistantId, customCommand, ctx.state.customCommands, worktreeId)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function splitFromTab(
|
|
556
|
+
ctx: SideEffectContext,
|
|
557
|
+
baseTabId: string,
|
|
558
|
+
direction: SplitDirection,
|
|
559
|
+
newTab: TabSession,
|
|
560
|
+
cwd?: string
|
|
561
|
+
): void {
|
|
562
|
+
ctx.dispatch({ tabId: baseTabId, type: 'set-active-tab' })
|
|
563
|
+
|
|
564
|
+
const latest = ctx.getState()
|
|
565
|
+
const existingTree = getTreeForTab(latest.layoutTrees, latest.tabGroupMap, baseTabId)
|
|
566
|
+
const baseTree = existingTree ?? createLeaf(baseTabId)
|
|
567
|
+
const newTree = splitNode(baseTree, baseTabId, direction, newTab.id)
|
|
568
|
+
const bounds = createTerminalBounds(latest.layout.terminalCols, latest.layout.terminalRows)
|
|
569
|
+
const paneRect = computePaneRects(newTree, bounds).get(newTab.id)
|
|
570
|
+
|
|
571
|
+
ctx.dispatch({ direction, newTab, type: 'split-pane' })
|
|
572
|
+
startTabSession(
|
|
573
|
+
ctx.backend,
|
|
574
|
+
ctx.dispatch,
|
|
575
|
+
ctx.clearStartupGrace,
|
|
576
|
+
(tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
|
|
577
|
+
newTab,
|
|
578
|
+
Math.max(1, (paneRect?.cols ?? latest.layout.terminalCols) - PANE_BORDER * 2),
|
|
579
|
+
Math.max(1, (paneRect?.rows ?? latest.layout.terminalRows) - PANE_BORDER * 2),
|
|
580
|
+
cwd
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function clampSplitRatio(value: number): number {
|
|
585
|
+
if (!Number.isFinite(value)) return 0.5
|
|
586
|
+
return Math.min(0.85, Math.max(0.15, value))
|
|
587
|
+
}
|
|
588
|
+
|
|
436
589
|
function getTabProjectPath(
|
|
437
590
|
ctx: SideEffectContext,
|
|
438
591
|
tab: Pick<TabSession, 'worktreeId'>
|
|
@@ -504,11 +657,28 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
504
657
|
return
|
|
505
658
|
}
|
|
506
659
|
case 'launch-selected-assistant': {
|
|
660
|
+
if (
|
|
661
|
+
state.modal.type === 'new-tab' &&
|
|
662
|
+
state.modal.step === 'worktree-create' &&
|
|
663
|
+
state.worktreeTemplates.length > 0
|
|
664
|
+
) {
|
|
665
|
+
dispatch({ type: 'enter-new-tab-template-pick' })
|
|
666
|
+
return
|
|
667
|
+
}
|
|
507
668
|
const option = getSelectedAssistantOption(state)
|
|
508
669
|
if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
|
|
509
670
|
const worktreeName = state.modal.worktreeName
|
|
510
671
|
const branchName = state.modal.branchName
|
|
672
|
+
const baseRef = state.modal.baseRef
|
|
511
673
|
const sourceWorktreeId = getNewTabTargetWorktreeId(state)
|
|
674
|
+
let templateId: string | undefined
|
|
675
|
+
if (state.modal.step === 'template') {
|
|
676
|
+
const templateIndex =
|
|
677
|
+
state.modal.selectedIndex - getTemplateNoneOffset(state.modal.selectedAssistantId)
|
|
678
|
+
if (templateIndex >= 0) {
|
|
679
|
+
templateId = state.worktreeTemplates[templateIndex]?.id
|
|
680
|
+
}
|
|
681
|
+
}
|
|
512
682
|
void (async () => {
|
|
513
683
|
try {
|
|
514
684
|
await enqueueGitOp(async () =>
|
|
@@ -517,7 +687,9 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
517
687
|
option.id,
|
|
518
688
|
worktreeName,
|
|
519
689
|
branchName,
|
|
520
|
-
sourceWorktreeId
|
|
690
|
+
sourceWorktreeId,
|
|
691
|
+
baseRef !== '' ? baseRef : undefined,
|
|
692
|
+
templateId
|
|
521
693
|
)
|
|
522
694
|
)
|
|
523
695
|
} catch (error) {
|
|
@@ -534,6 +706,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
534
706
|
dispatch({ assistantId: option.id, type: 'open-edit-custom-command' })
|
|
535
707
|
return
|
|
536
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
|
+
}
|
|
537
720
|
case 'confirm-selected-session': {
|
|
538
721
|
handleSessionSelection(ctx)
|
|
539
722
|
return
|
|
@@ -554,27 +737,40 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
554
737
|
{ ...ctx, state: ctx.getState() },
|
|
555
738
|
effect.sessionId,
|
|
556
739
|
effect.worktreeId,
|
|
557
|
-
!!(effect.force === true)
|
|
740
|
+
!!(effect.force === true),
|
|
741
|
+
!!(effect.closeTabs === true)
|
|
558
742
|
)
|
|
559
743
|
)
|
|
560
744
|
} catch (error) {
|
|
561
745
|
const message = error instanceof Error ? error.message : String(error)
|
|
562
|
-
|
|
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
|
+
}
|
|
563
755
|
const latest = ctx.getState()
|
|
564
756
|
if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
757
|
+
ctx.dispatch({
|
|
758
|
+
prompt: { reason: message, worktreeId: effect.worktreeId },
|
|
759
|
+
type: 'set-new-tab-worktree-delete-prompt',
|
|
760
|
+
})
|
|
761
|
+
return
|
|
571
762
|
}
|
|
763
|
+
const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
|
|
764
|
+
const worktree = session?.worktrees?.find((entry) => entry.id === effect.worktreeId)
|
|
572
765
|
ctx.dispatch({
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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',
|
|
576
773
|
})
|
|
577
|
-
ctx.dispatch({ message, type: 'git-mode-set-message' })
|
|
578
774
|
}
|
|
579
775
|
})()
|
|
580
776
|
return
|
|
@@ -1378,7 +1574,8 @@ async function runDeleteWorktree(
|
|
|
1378
1574
|
ctx: SideEffectContext,
|
|
1379
1575
|
sessionId: string,
|
|
1380
1576
|
worktreeId: string,
|
|
1381
|
-
force: boolean
|
|
1577
|
+
force: boolean,
|
|
1578
|
+
closeTabs = false
|
|
1382
1579
|
): Promise<void> {
|
|
1383
1580
|
const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
|
|
1384
1581
|
const worktree = session?.worktrees?.find((entry) => entry.id === worktreeId)
|
|
@@ -1388,36 +1585,43 @@ async function runDeleteWorktree(
|
|
|
1388
1585
|
if (worktree.source === 'primary') throw new Error('root worktree cannot be deleted')
|
|
1389
1586
|
|
|
1390
1587
|
const tabsInWorktree = ctx.state.tabs.filter((tab) => tab.worktreeId === worktreeId)
|
|
1391
|
-
|
|
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) {
|
|
1392
1593
|
throw new ActiveWorktreeTabsError(tabsInWorktree.length)
|
|
1393
1594
|
}
|
|
1394
1595
|
disposeWorktreeTabs(ctx, worktreeId)
|
|
1395
1596
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
) {
|
|
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()
|
|
1402
1613
|
removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
|
|
1403
1614
|
return
|
|
1404
1615
|
}
|
|
1405
1616
|
|
|
1406
|
-
if (
|
|
1407
|
-
worktree.source === 'aimux-temp' &&
|
|
1408
|
-
worktree.createdByAimux &&
|
|
1409
|
-
isInsideAimuxWorktreeRoot(worktree.path)
|
|
1410
|
-
) {
|
|
1617
|
+
if (isAimuxTemp && isInsideAimuxWorktreeRoot(worktree.path)) {
|
|
1411
1618
|
await assertSafeAimuxWorktreePath(worktree.path)
|
|
1412
|
-
await removeGitWorktree({
|
|
1413
|
-
force,
|
|
1414
|
-
repoPath: resolveWorktreeGitDir(session, worktree),
|
|
1415
|
-
targetPath: worktree.path,
|
|
1416
|
-
})
|
|
1619
|
+
await removeGitWorktree({ force, repoPath, targetPath: worktree.path })
|
|
1417
1620
|
} else if (worktree.source === 'aimux-temp' || worktree.createdByAimux) {
|
|
1418
1621
|
throw new Error(`refusing unsafe worktree delete: ${worktree.path}`)
|
|
1419
1622
|
}
|
|
1420
1623
|
|
|
1624
|
+
await cleanupAimuxBranch()
|
|
1421
1625
|
removeWorktreeRecordFromSession(ctx, sessionId, session, worktreeId)
|
|
1422
1626
|
}
|
|
1423
1627
|
|
|
@@ -1528,7 +1732,7 @@ function removeWorktreeRecordFromSession(
|
|
|
1528
1732
|
type: 'set-modal-selection-index',
|
|
1529
1733
|
})
|
|
1530
1734
|
}
|
|
1531
|
-
ctx.dispatch({
|
|
1735
|
+
ctx.dispatch({ prompt: null, type: 'set-new-tab-worktree-delete-prompt' })
|
|
1532
1736
|
}
|
|
1533
1737
|
|
|
1534
1738
|
function isForceableWorktreeDeleteError(message: string): boolean {
|
|
@@ -1540,7 +1744,7 @@ function isForceableWorktreeDeleteError(message: string): boolean {
|
|
|
1540
1744
|
class ActiveWorktreeTabsError extends Error {
|
|
1541
1745
|
constructor(tabCount: number) {
|
|
1542
1746
|
super(
|
|
1543
|
-
`active assistant tabs are using this worktree (${tabCount})
|
|
1747
|
+
`active assistant tabs are using this worktree (${tabCount}) — they will be closed if you confirm.`
|
|
1544
1748
|
)
|
|
1545
1749
|
}
|
|
1546
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 {
|
|
@@ -152,6 +155,10 @@ export function App({
|
|
|
152
155
|
gitPane: gitPaneOverrides,
|
|
153
156
|
sessionBarVisible,
|
|
154
157
|
sidebar: sidebarOverrides,
|
|
158
|
+
worktreeTemplates:
|
|
159
|
+
resolvedConfig.worktreeTemplates.length > 0
|
|
160
|
+
? resolvedConfig.worktreeTemplates
|
|
161
|
+
: json.worktreeTemplates,
|
|
155
162
|
}
|
|
156
163
|
)
|
|
157
164
|
// Replace the module-level default with the fully-resolved initial state.
|
|
@@ -246,6 +253,28 @@ export function App({
|
|
|
246
253
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
247
254
|
}, [])
|
|
248
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
|
+
|
|
249
278
|
const resizingRef = useRef(false)
|
|
250
279
|
// Seeded with state.layout's DEFAULT 80x24; reassigned below once
|
|
251
280
|
// useTerminalResize has produced the open-loop estimate from real dimensions.
|
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
+
import type { SplitDirection } from './state/layout-tree'
|
|
3
4
|
import type { GitFileListMode, WorkspaceSnapshotV1 } from './state/types'
|
|
4
5
|
|
|
5
6
|
import { logDebug } from './debug/input-log'
|
|
@@ -32,6 +33,26 @@ export interface PersistedSidebar {
|
|
|
32
33
|
width: number
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
export interface WorktreeTemplatePane {
|
|
37
|
+
id: string
|
|
38
|
+
assistant: string
|
|
39
|
+
splitFrom?: string
|
|
40
|
+
direction?: SplitDirection
|
|
41
|
+
ratio?: number
|
|
42
|
+
send?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WorktreeTemplateTab {
|
|
46
|
+
panes: WorktreeTemplatePane[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorktreeTemplate {
|
|
50
|
+
id: string
|
|
51
|
+
name: string
|
|
52
|
+
description?: string
|
|
53
|
+
tabs: WorktreeTemplateTab[]
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
export interface AimuxConfig {
|
|
36
57
|
version: 2
|
|
37
58
|
customCommands: Record<string, string>
|
|
@@ -43,6 +64,9 @@ export interface AimuxConfig {
|
|
|
43
64
|
sessionBarVisible?: boolean
|
|
44
65
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
45
66
|
skippedUpdateVersion?: string
|
|
67
|
+
/** One-shot guard: orphan `aimux/` branches were pruned from existing repos. */
|
|
68
|
+
prunedOrphanAimuxBranches?: boolean
|
|
69
|
+
worktreeTemplates?: WorktreeTemplate[]
|
|
46
70
|
}
|
|
47
71
|
|
|
48
72
|
function isPersistedGitPane(value: unknown): value is PersistedGitPane {
|
|
@@ -105,6 +129,77 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
|
|
|
105
129
|
return true
|
|
106
130
|
}
|
|
107
131
|
|
|
132
|
+
function isRatioValid(value: unknown): boolean {
|
|
133
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0.15 && value < 0.85
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isWorktreeTemplateTab(value: unknown): value is WorktreeTemplateTab {
|
|
137
|
+
if (typeof value !== 'object' || value === null) return false
|
|
138
|
+
const v = value as Record<string, unknown>
|
|
139
|
+
if (!Array.isArray(v.panes) || v.panes.length === 0) return false
|
|
140
|
+
const seenIds = new Set<string>()
|
|
141
|
+
for (let i = 0; i < v.panes.length; i++) {
|
|
142
|
+
const rawPane = v.panes[i]
|
|
143
|
+
if (typeof rawPane !== 'object' || rawPane === null) return false
|
|
144
|
+
const pane = rawPane as Record<string, unknown>
|
|
145
|
+
if (typeof pane.id !== 'string' || pane.id.length === 0) return false
|
|
146
|
+
if (seenIds.has(pane.id)) return false
|
|
147
|
+
seenIds.add(pane.id)
|
|
148
|
+
if (typeof pane.assistant !== 'string' || pane.assistant.length === 0) return false
|
|
149
|
+
if (i === 0) {
|
|
150
|
+
if (pane.splitFrom !== undefined) return false
|
|
151
|
+
if (pane.direction !== undefined) return false
|
|
152
|
+
if (pane.ratio !== undefined) return false
|
|
153
|
+
} else {
|
|
154
|
+
if (typeof pane.splitFrom !== 'string' || !seenIds.has(pane.splitFrom)) return false
|
|
155
|
+
if (pane.splitFrom === pane.id) return false
|
|
156
|
+
if (pane.direction !== 'horizontal' && pane.direction !== 'vertical') return false
|
|
157
|
+
if (pane.ratio !== undefined && !isRatioValid(pane.ratio)) return false
|
|
158
|
+
}
|
|
159
|
+
if (pane.send !== undefined && typeof pane.send !== 'string') return false
|
|
160
|
+
}
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isWorktreeTemplate(value: unknown): value is WorktreeTemplate {
|
|
165
|
+
if (typeof value !== 'object' || value === null) return false
|
|
166
|
+
const v = value as Record<string, unknown>
|
|
167
|
+
if (typeof v.id !== 'string' || v.id.length === 0) return false
|
|
168
|
+
if (typeof v.name !== 'string' || v.name.length === 0) return false
|
|
169
|
+
if (v.description !== undefined && typeof v.description !== 'string') return false
|
|
170
|
+
if (!Array.isArray(v.tabs) || v.tabs.length === 0) return false
|
|
171
|
+
for (const tab of v.tabs) {
|
|
172
|
+
if (!isWorktreeTemplateTab(tab)) return false
|
|
173
|
+
}
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseWorktreeTemplates(
|
|
178
|
+
value: unknown,
|
|
179
|
+
issues: string[]
|
|
180
|
+
): WorktreeTemplate[] | undefined {
|
|
181
|
+
if (value === undefined) return undefined
|
|
182
|
+
if (!Array.isArray(value)) {
|
|
183
|
+
issues.push('ignored invalid worktreeTemplates (not an array)')
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
const seen = new Set<string>()
|
|
187
|
+
const valid: WorktreeTemplate[] = []
|
|
188
|
+
for (const entry of value) {
|
|
189
|
+
if (!isWorktreeTemplate(entry)) {
|
|
190
|
+
issues.push('ignored invalid worktreeTemplate entry')
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (seen.has(entry.id)) {
|
|
194
|
+
issues.push(`ignored duplicate worktreeTemplate id "${entry.id}"`)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
seen.add(entry.id)
|
|
198
|
+
valid.push(entry)
|
|
199
|
+
}
|
|
200
|
+
return valid.length > 0 ? valid : undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
108
203
|
function isPersistedSidebar(value: unknown): value is PersistedSidebar {
|
|
109
204
|
if (typeof value !== 'object' || value === null) return false
|
|
110
205
|
const v = value as Record<string, unknown>
|
|
@@ -162,6 +257,8 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
162
257
|
sessionBarVisible?: unknown
|
|
163
258
|
workspaceSnapshot?: unknown
|
|
164
259
|
skippedUpdateVersion?: unknown
|
|
260
|
+
prunedOrphanAimuxBranches?: unknown
|
|
261
|
+
worktreeTemplates?: unknown
|
|
165
262
|
}
|
|
166
263
|
|
|
167
264
|
const issues: string[] = []
|
|
@@ -246,6 +343,8 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
246
343
|
issues.push('ignored invalid skippedUpdateVersion')
|
|
247
344
|
}
|
|
248
345
|
|
|
346
|
+
const validWorktreeTemplates = parseWorktreeTemplates(parsed.worktreeTemplates, issues)
|
|
347
|
+
|
|
249
348
|
if (issues.length > 0) {
|
|
250
349
|
logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
|
|
251
350
|
}
|
|
@@ -254,6 +353,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
254
353
|
config: {
|
|
255
354
|
customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
|
|
256
355
|
gitPane: validGitPane,
|
|
356
|
+
prunedOrphanAimuxBranches: parsed.prunedOrphanAimuxBranches === true ? true : undefined,
|
|
257
357
|
sessionBarVisible: validSessionBarVisible,
|
|
258
358
|
sidebar: validSidebar,
|
|
259
359
|
skippedUpdateVersion: validSkippedUpdateVersion,
|
|
@@ -264,6 +364,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
264
364
|
workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
|
265
365
|
? parsed.workspaceSnapshot
|
|
266
366
|
: undefined,
|
|
367
|
+
worktreeTemplates: validWorktreeTemplates,
|
|
267
368
|
},
|
|
268
369
|
issues,
|
|
269
370
|
source: 'file',
|