@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.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.8",
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 { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
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
- undefined,
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
- 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
+ }
563
755
  const latest = ctx.getState()
564
756
  if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
565
- const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
566
- const selected = session?.worktrees?.[latest.modal.selectedIndex]
567
- if (selected && selected.id !== effect.worktreeId) {
568
- ctx.dispatch({ message, type: 'git-mode-set-message' })
569
- return
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
- confirmWorktreeId: forceable ? effect.worktreeId : null,
574
- message: forceable ? message : `Could not delete worktree: ${message}`,
575
- 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',
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
- 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) {
1392
1593
  throw new ActiveWorktreeTabsError(tabsInWorktree.length)
1393
1594
  }
1394
1595
  disposeWorktreeTabs(ctx, worktreeId)
1395
1596
 
1396
- if (
1397
- worktree.source === 'aimux-temp' &&
1398
- worktree.createdByAimux &&
1399
- isInsideAimuxWorktreeRoot(worktree.path) &&
1400
- !existsSync(worktree.path)
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({ message: null, type: 'set-new-tab-worktree-delete-state' })
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}). 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.`
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',