@brimveyn/aimux 1.14.14 → 1.14.15

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.15",
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.9",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -16,7 +16,7 @@ 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'
@@ -54,7 +54,12 @@ import {
54
54
  type SplitDirection,
55
55
  splitNode,
56
56
  } from '../state/layout-tree'
57
- import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
57
+ import {
58
+ filterAssistants,
59
+ filterSessions,
60
+ filterSnippets,
61
+ getTemplateNoneOffset,
62
+ } from '../state/selectors'
58
63
  import { saveSessionCatalog } from '../state/session-catalog'
59
64
  import { pruneSnapshotOfWorktree } from '../state/session-persistence'
60
65
  import {
@@ -73,6 +78,7 @@ import { filterThemeIds } from '../ui/filter-themes'
73
78
  import { scrollGitDiff } from '../ui/git-view-controls'
74
79
  import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
75
80
  import { triggerAutoCommitNow } from './auto-commit-ref'
81
+ import { writeToTab } from './pty-write'
76
82
  import {
77
83
  handleCreateSessionEffect,
78
84
  handleDeleteSessionEffect,
@@ -88,6 +94,15 @@ import {
88
94
  } from './snippet-actions'
89
95
 
90
96
  const STARTUP_GRACE_MS = 5_000
97
+ /**
98
+ * Delay before injecting a template pane's `send` payload into its PTY.
99
+ * Short enough that shells (which print a prompt within ~100 ms) receive the
100
+ * command after their prompt is drawn; long enough to clear most PTY init
101
+ * races. Not tied to STARTUP_GRACE_MS because that is the assistant timeout,
102
+ * not a readiness signal. See review note: a `tab.status === 'running'`
103
+ * subscription would be more robust and is the planned follow-up.
104
+ */
105
+ const TEMPLATE_SEND_DELAY_MS = 600
91
106
 
92
107
  export interface SideEffectContext {
93
108
  state: AppState
@@ -403,7 +418,8 @@ async function launchAssistantInNewWorktree(
403
418
  assistant: AssistantId,
404
419
  worktreeName: string,
405
420
  branchName?: string,
406
- sourceWorktreeId?: string
421
+ sourceWorktreeId?: string,
422
+ templateId?: string
407
423
  ): Promise<void> {
408
424
  const sessionId = ctx.state.currentSessionId
409
425
  if (!(sessionId != null && sessionId !== '')) return
@@ -416,10 +432,23 @@ async function launchAssistantInNewWorktree(
416
432
  sourceWorktreeId
417
433
  )
418
434
  if (!worktree) return
435
+
436
+ const template =
437
+ templateId != null && templateId !== ''
438
+ ? ctx.state.worktreeTemplates.find((entry) => entry.id === templateId)
439
+ : undefined
440
+
441
+ ctx.dispatch({ type: 'close-modal' })
442
+
443
+ if (template) {
444
+ applyWorktreeTemplate(ctx, template, worktree.id, worktree.path)
445
+ ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
446
+ return
447
+ }
448
+
419
449
  const customCommand = ctx.state.customCommands[assistant]
420
450
  const tab = createTabSession(assistant, customCommand, ctx.state.customCommands, worktree.id)
421
451
  ctx.dispatch({ tab, type: 'add-tab' })
422
- ctx.dispatch({ type: 'close-modal' })
423
452
  ctx.dispatch({ focusMode: 'terminal-input', type: 'set-focus-mode' })
424
453
  startTabSession(
425
454
  ctx.backend,
@@ -433,6 +462,126 @@ async function launchAssistantInNewWorktree(
433
462
  )
434
463
  }
435
464
 
465
+ function applyWorktreeTemplate(
466
+ ctx: SideEffectContext,
467
+ template: WorktreeTemplate,
468
+ worktreeId: string,
469
+ worktreePath: string
470
+ ): void {
471
+ let firstTabId: string | null = null
472
+
473
+ for (const templateTab of template.tabs) {
474
+ const localToTabId = new Map<string, string>()
475
+
476
+ for (let i = 0; i < templateTab.panes.length; i++) {
477
+ const pane = templateTab.panes[i]
478
+ if (!pane) continue
479
+ const tab = createPaneTab(ctx, pane, worktreeId)
480
+ localToTabId.set(pane.id, tab.id)
481
+
482
+ if (i === 0) {
483
+ if (firstTabId == null) firstTabId = tab.id
484
+ ctx.dispatch({ tab, type: 'add-tab' })
485
+ startTabSession(
486
+ ctx.backend,
487
+ ctx.dispatch,
488
+ ctx.clearStartupGrace,
489
+ (tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
490
+ tab,
491
+ ctx.state.layout.terminalCols,
492
+ ctx.state.layout.terminalRows,
493
+ worktreePath
494
+ )
495
+ } else {
496
+ const splitFromId =
497
+ pane.splitFrom != null && pane.splitFrom !== ''
498
+ ? localToTabId.get(pane.splitFrom)
499
+ : undefined
500
+ const direction: SplitDirection = pane.direction ?? 'vertical'
501
+ if (splitFromId == null || splitFromId === '') {
502
+ logInputDebug('template.splitFrom.unresolved', {
503
+ paneId: pane.id,
504
+ splitFrom: pane.splitFrom ?? null,
505
+ templateId: template.id,
506
+ })
507
+ continue
508
+ }
509
+ splitFromTab(ctx, splitFromId, direction, tab, worktreePath)
510
+ if (pane.ratio != null) {
511
+ const sourceRatio = clampSplitRatio(1 - pane.ratio)
512
+ ctx.dispatch({
513
+ axis: direction,
514
+ ratio: sourceRatio,
515
+ tabId: tab.id,
516
+ type: 'set-split-ratio',
517
+ })
518
+ }
519
+ }
520
+
521
+ if (pane.send != null && pane.send !== '') {
522
+ const payload = `${pane.send}\n`
523
+ const targetTabId = tab.id
524
+ setTimeout(() => {
525
+ const latest = ctx.getState()
526
+ const latestTab = latest.tabs.find((entry) => entry.id === targetTabId)
527
+ writeToTab(ctx.backend, targetTabId, latestTab, payload)
528
+ }, TEMPLATE_SEND_DELAY_MS)
529
+ }
530
+ }
531
+ }
532
+
533
+ if (firstTabId != null) {
534
+ ctx.dispatch({ tabId: firstTabId, type: 'set-active-tab' })
535
+ }
536
+ }
537
+
538
+ function createPaneTab(
539
+ ctx: SideEffectContext,
540
+ pane: WorktreeTemplatePane,
541
+ worktreeId: string
542
+ ): TabSession {
543
+ // Accept `'shell'` as an alias for the registered `'terminal'` assistant so
544
+ // template examples using the more intuitive name don't silently fall back
545
+ // to Claude (createTabSession's unknown-id fallback resolves to index 0).
546
+ const assistantId = (pane.assistant === 'shell' ? 'terminal' : pane.assistant) as AssistantId
547
+ const customCommand = ctx.state.customCommands[assistantId]
548
+ return createTabSession(assistantId, customCommand, ctx.state.customCommands, worktreeId)
549
+ }
550
+
551
+ function splitFromTab(
552
+ ctx: SideEffectContext,
553
+ baseTabId: string,
554
+ direction: SplitDirection,
555
+ newTab: TabSession,
556
+ cwd?: string
557
+ ): void {
558
+ ctx.dispatch({ tabId: baseTabId, type: 'set-active-tab' })
559
+
560
+ const latest = ctx.getState()
561
+ const existingTree = getTreeForTab(latest.layoutTrees, latest.tabGroupMap, baseTabId)
562
+ const baseTree = existingTree ?? createLeaf(baseTabId)
563
+ const newTree = splitNode(baseTree, baseTabId, direction, newTab.id)
564
+ const bounds = createTerminalBounds(latest.layout.terminalCols, latest.layout.terminalRows)
565
+ const paneRect = computePaneRects(newTree, bounds).get(newTab.id)
566
+
567
+ ctx.dispatch({ direction, newTab, type: 'split-pane' })
568
+ startTabSession(
569
+ ctx.backend,
570
+ ctx.dispatch,
571
+ ctx.clearStartupGrace,
572
+ (tabId) => ctx.startStartupGrace(tabId, STARTUP_GRACE_MS),
573
+ newTab,
574
+ Math.max(1, (paneRect?.cols ?? latest.layout.terminalCols) - PANE_BORDER * 2),
575
+ Math.max(1, (paneRect?.rows ?? latest.layout.terminalRows) - PANE_BORDER * 2),
576
+ cwd
577
+ )
578
+ }
579
+
580
+ function clampSplitRatio(value: number): number {
581
+ if (!Number.isFinite(value)) return 0.5
582
+ return Math.min(0.85, Math.max(0.15, value))
583
+ }
584
+
436
585
  function getTabProjectPath(
437
586
  ctx: SideEffectContext,
438
587
  tab: Pick<TabSession, 'worktreeId'>
@@ -504,11 +653,27 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
504
653
  return
505
654
  }
506
655
  case 'launch-selected-assistant': {
656
+ if (
657
+ state.modal.type === 'new-tab' &&
658
+ state.modal.step === 'worktree-create' &&
659
+ state.worktreeTemplates.length > 0
660
+ ) {
661
+ dispatch({ type: 'enter-new-tab-template-pick' })
662
+ return
663
+ }
507
664
  const option = getSelectedAssistantOption(state)
508
665
  if (state.modal.type === 'new-tab' && state.modal.createWorktree) {
509
666
  const worktreeName = state.modal.worktreeName
510
667
  const branchName = state.modal.branchName
511
668
  const sourceWorktreeId = getNewTabTargetWorktreeId(state)
669
+ let templateId: string | undefined
670
+ if (state.modal.step === 'template') {
671
+ const templateIndex =
672
+ state.modal.selectedIndex - getTemplateNoneOffset(state.modal.selectedAssistantId)
673
+ if (templateIndex >= 0) {
674
+ templateId = state.worktreeTemplates[templateIndex]?.id
675
+ }
676
+ }
512
677
  void (async () => {
513
678
  try {
514
679
  await enqueueGitOp(async () =>
@@ -517,7 +682,8 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
517
682
  option.id,
518
683
  worktreeName,
519
684
  branchName,
520
- sourceWorktreeId
685
+ sourceWorktreeId,
686
+ templateId
521
687
  )
522
688
  )
523
689
  } catch (error) {
package/src/app.tsx CHANGED
@@ -152,6 +152,10 @@ export function App({
152
152
  gitPane: gitPaneOverrides,
153
153
  sessionBarVisible,
154
154
  sidebar: sidebarOverrides,
155
+ worktreeTemplates:
156
+ resolvedConfig.worktreeTemplates.length > 0
157
+ ? resolvedConfig.worktreeTemplates
158
+ : json.worktreeTemplates,
155
159
  }
156
160
  )
157
161
  // Replace the module-level default with the fully-resolved initial state.
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,7 @@ export interface AimuxConfig {
43
64
  sessionBarVisible?: boolean
44
65
  workspaceSnapshot?: WorkspaceSnapshotV1
45
66
  skippedUpdateVersion?: string
67
+ worktreeTemplates?: WorktreeTemplate[]
46
68
  }
47
69
 
48
70
  function isPersistedGitPane(value: unknown): value is PersistedGitPane {
@@ -105,6 +127,77 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
105
127
  return true
106
128
  }
107
129
 
130
+ function isRatioValid(value: unknown): boolean {
131
+ return typeof value === 'number' && Number.isFinite(value) && value > 0.15 && value < 0.85
132
+ }
133
+
134
+ function isWorktreeTemplateTab(value: unknown): value is WorktreeTemplateTab {
135
+ if (typeof value !== 'object' || value === null) return false
136
+ const v = value as Record<string, unknown>
137
+ if (!Array.isArray(v.panes) || v.panes.length === 0) return false
138
+ const seenIds = new Set<string>()
139
+ for (let i = 0; i < v.panes.length; i++) {
140
+ const rawPane = v.panes[i]
141
+ if (typeof rawPane !== 'object' || rawPane === null) return false
142
+ const pane = rawPane as Record<string, unknown>
143
+ if (typeof pane.id !== 'string' || pane.id.length === 0) return false
144
+ if (seenIds.has(pane.id)) return false
145
+ seenIds.add(pane.id)
146
+ if (typeof pane.assistant !== 'string' || pane.assistant.length === 0) return false
147
+ if (i === 0) {
148
+ if (pane.splitFrom !== undefined) return false
149
+ if (pane.direction !== undefined) return false
150
+ if (pane.ratio !== undefined) return false
151
+ } else {
152
+ if (typeof pane.splitFrom !== 'string' || !seenIds.has(pane.splitFrom)) return false
153
+ if (pane.splitFrom === pane.id) return false
154
+ if (pane.direction !== 'horizontal' && pane.direction !== 'vertical') return false
155
+ if (pane.ratio !== undefined && !isRatioValid(pane.ratio)) return false
156
+ }
157
+ if (pane.send !== undefined && typeof pane.send !== 'string') return false
158
+ }
159
+ return true
160
+ }
161
+
162
+ export function isWorktreeTemplate(value: unknown): value is WorktreeTemplate {
163
+ if (typeof value !== 'object' || value === null) return false
164
+ const v = value as Record<string, unknown>
165
+ if (typeof v.id !== 'string' || v.id.length === 0) return false
166
+ if (typeof v.name !== 'string' || v.name.length === 0) return false
167
+ if (v.description !== undefined && typeof v.description !== 'string') return false
168
+ if (!Array.isArray(v.tabs) || v.tabs.length === 0) return false
169
+ for (const tab of v.tabs) {
170
+ if (!isWorktreeTemplateTab(tab)) return false
171
+ }
172
+ return true
173
+ }
174
+
175
+ export function parseWorktreeTemplates(
176
+ value: unknown,
177
+ issues: string[]
178
+ ): WorktreeTemplate[] | undefined {
179
+ if (value === undefined) return undefined
180
+ if (!Array.isArray(value)) {
181
+ issues.push('ignored invalid worktreeTemplates (not an array)')
182
+ return undefined
183
+ }
184
+ const seen = new Set<string>()
185
+ const valid: WorktreeTemplate[] = []
186
+ for (const entry of value) {
187
+ if (!isWorktreeTemplate(entry)) {
188
+ issues.push('ignored invalid worktreeTemplate entry')
189
+ continue
190
+ }
191
+ if (seen.has(entry.id)) {
192
+ issues.push(`ignored duplicate worktreeTemplate id "${entry.id}"`)
193
+ continue
194
+ }
195
+ seen.add(entry.id)
196
+ valid.push(entry)
197
+ }
198
+ return valid.length > 0 ? valid : undefined
199
+ }
200
+
108
201
  function isPersistedSidebar(value: unknown): value is PersistedSidebar {
109
202
  if (typeof value !== 'object' || value === null) return false
110
203
  const v = value as Record<string, unknown>
@@ -162,6 +255,7 @@ export function loadConfigResult(): ConfigLoadResult {
162
255
  sessionBarVisible?: unknown
163
256
  workspaceSnapshot?: unknown
164
257
  skippedUpdateVersion?: unknown
258
+ worktreeTemplates?: unknown
165
259
  }
166
260
 
167
261
  const issues: string[] = []
@@ -246,6 +340,8 @@ export function loadConfigResult(): ConfigLoadResult {
246
340
  issues.push('ignored invalid skippedUpdateVersion')
247
341
  }
248
342
 
343
+ const validWorktreeTemplates = parseWorktreeTemplates(parsed.worktreeTemplates, issues)
344
+
249
345
  if (issues.length > 0) {
250
346
  logDebug('config.load.validationIssue', { issues, path: CONFIG_PATH })
251
347
  }
@@ -264,6 +360,7 @@ export function loadConfigResult(): ConfigLoadResult {
264
360
  workspaceSnapshot: isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
265
361
  ? parsed.workspaceSnapshot
266
362
  : undefined,
363
+ worktreeTemplates: validWorktreeTemplates,
267
364
  },
268
365
  issues,
269
366
  source: 'file',
@@ -6,7 +6,12 @@ import { collectHelpEntries } from '../../input/keymap/help-entries'
6
6
  import { getActiveKeymap } from '../../input/keymap/keymap-ref'
7
7
  import { getAllAssistantOptions } from '../../pty/command-registry'
8
8
  import { filterThemeIds } from '../../ui/filter-themes'
9
- import { filterAssistants, filterSessions, filterSnippets } from '../selectors'
9
+ import {
10
+ filterAssistants,
11
+ filterSessions,
12
+ filterSnippets,
13
+ getTemplateNoneOffset,
14
+ } from '../selectors'
10
15
  import { reduceAutoCommitState } from './auto-commit-state'
11
16
 
12
17
  function emptyModal() {
@@ -90,6 +95,36 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
90
95
  },
91
96
  }
92
97
  }
98
+ case 'enter-new-tab-template-pick': {
99
+ if (state.modal.type !== 'new-tab') return state
100
+ return {
101
+ ...state,
102
+ modal: {
103
+ ...state.modal,
104
+ activeField: 'target-worktree',
105
+ cursorPos: 0,
106
+ selectedIndex: 0,
107
+ step: 'template',
108
+ },
109
+ }
110
+ }
111
+ case 'enter-new-tab-template-shortcut': {
112
+ if (state.modal.type !== 'new-tab') return state
113
+ const defaultName = state.modal.worktreeName || 'wt-template'
114
+ return {
115
+ ...state,
116
+ modal: {
117
+ ...state.modal,
118
+ activeField: 'worktree-name',
119
+ createWorktree: true,
120
+ cursorPos: defaultName.length,
121
+ selectedAssistantId: null,
122
+ selectedIndex: 0,
123
+ step: 'worktree-create',
124
+ worktreeName: defaultName,
125
+ },
126
+ }
127
+ }
93
128
  case 'set-new-tab-worktree-delete-state': {
94
129
  if (state.modal.type !== 'new-tab') return state
95
130
  return {
@@ -115,6 +150,32 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
115
150
  }
116
151
  case 'select-new-tab-assistant': {
117
152
  if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
153
+ if (
154
+ action.assistantId === undefined &&
155
+ state.modal.step === 'assistant' &&
156
+ state.worktreeTemplates.length > 0
157
+ ) {
158
+ const filtered = filterAssistants(
159
+ getAllAssistantOptions(state.customCommands),
160
+ state.modal.editBuffer
161
+ )
162
+ if (state.modal.selectedIndex >= filtered.length) {
163
+ const defaultName = state.modal.worktreeName || 'wt-template'
164
+ return {
165
+ ...state,
166
+ modal: {
167
+ ...state.modal,
168
+ activeField: 'worktree-name',
169
+ createWorktree: true,
170
+ cursorPos: defaultName.length,
171
+ selectedAssistantId: null,
172
+ selectedIndex: 0,
173
+ step: 'worktree-create',
174
+ worktreeName: defaultName,
175
+ },
176
+ }
177
+ }
178
+ }
118
179
  const option = getSelectedNewTabAssistant(state, action.assistantId)
119
180
  if (!option) return state
120
181
  const targetWorktreeIndex = getCurrentWorktreeIndex(state)
@@ -562,11 +623,13 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
562
623
  if (state.modal.step === 'worktree') {
563
624
  if (state.modal.activeField === 'worktree-name') return state
564
625
  optionCount = getCurrentWorktreeCount(state) + 1
626
+ } else if (state.modal.step === 'template') {
627
+ optionCount =
628
+ state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
565
629
  } else {
566
- optionCount = filterAssistants(
567
- getAllAssistantOptions(state.customCommands),
568
- state.modal.editBuffer
569
- ).length
630
+ optionCount =
631
+ filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
632
+ .length + (state.worktreeTemplates.length > 0 ? 1 : 0)
570
633
  }
571
634
  } else if (state.modal.type === 'split-picker') {
572
635
  optionCount = getAllAssistantOptions(state.customCommands).length
@@ -626,11 +689,16 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
626
689
  if (state.modal.type === 'help') {
627
690
  optionCount = state.modal.entryCount
628
691
  } else if (state.modal.type === 'new-tab') {
629
- optionCount =
630
- state.modal.step === 'worktree'
631
- ? getCurrentWorktreeCount(state) + 1
632
- : filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
633
- .length
692
+ if (state.modal.step === 'worktree') {
693
+ optionCount = getCurrentWorktreeCount(state) + 1
694
+ } else if (state.modal.step === 'template') {
695
+ optionCount =
696
+ state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
697
+ } else {
698
+ optionCount =
699
+ filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
700
+ .length + (state.worktreeTemplates.length > 0 ? 1 : 0)
701
+ }
634
702
  } else if (state.modal.type === 'split-picker') {
635
703
  optionCount = getAllAssistantOptions(state.customCommands).length
636
704
  } else if (state.modal.type === 'create-session') {
@@ -677,8 +745,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
677
745
  if (
678
746
  state.modal.type === 'new-tab' &&
679
747
  state.modal.editingCommand === null &&
680
- state.modal.step === 'worktree' &&
681
- state.modal.activeField === 'target-worktree'
748
+ (state.modal.step === 'template' ||
749
+ (state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
682
750
  ) {
683
751
  return state
684
752
  }
@@ -707,8 +775,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
707
775
  if (
708
776
  state.modal.type === 'new-tab' &&
709
777
  state.modal.editingCommand === null &&
710
- state.modal.step === 'worktree' &&
711
- state.modal.activeField === 'target-worktree'
778
+ (state.modal.step === 'template' ||
779
+ (state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
712
780
  ) {
713
781
  return state
714
782
  }
@@ -785,6 +853,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
785
853
  }
786
854
  }
787
855
  if (state.modal.type === 'new-tab') {
856
+ if (state.modal.step === 'template') {
857
+ return {
858
+ ...state,
859
+ modal: {
860
+ ...state.modal,
861
+ activeField: 'worktree-name',
862
+ cursorPos: state.modal.worktreeName.length,
863
+ selectedIndex: 0,
864
+ step: 'worktree-create',
865
+ },
866
+ }
867
+ }
788
868
  if (state.modal.step === 'worktree-create') {
789
869
  const optionCount = getCurrentWorktreeCount(state) + 1
790
870
  return {
@@ -1,5 +1,15 @@
1
1
  import type { AssistantOption } from '../pty/command-registry'
2
- import type { SessionRecord, SnippetRecord } from './types'
2
+ import type { AssistantId, SessionRecord, SnippetRecord } from './types'
3
+
4
+ /**
5
+ * 0 when the template picker should NOT show the "None" fallback (no assistant
6
+ * was picked — typical for the template shortcut path), 1 otherwise. Shared
7
+ * by the modal reducer, the side-effect that resolves templateId, and the
8
+ * picker UI so the three stay in sync.
9
+ */
10
+ export function getTemplateNoneOffset(selectedAssistantId: AssistantId | null): 0 | 1 {
11
+ return selectedAssistantId == null ? 0 : 1
12
+ }
3
13
 
4
14
  export function filterAssistants(
5
15
  options: AssistantOption[],
@@ -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'
@@ -327,7 +328,7 @@ export interface ModalNewTab extends ModalBase {
327
328
  branchName: string
328
329
  createWorktree: boolean
329
330
  selectedAssistantId: AssistantId | null
330
- step: 'assistant' | 'worktree' | 'worktree-create'
331
+ step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
331
332
  targetWorktreeIndex: number
332
333
  worktreeDeleteConfirmId: string | null
333
334
  worktreeDeleteMessage: string | null
@@ -504,6 +505,8 @@ export interface AppState {
504
505
  lastActiveTabByWorktree: Record<string, string>
505
506
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
506
507
  pendingChords: string[] | null
508
+ /** User-defined templates applied at worktree creation. Loaded from aimux.json. */
509
+ worktreeTemplates: WorktreeTemplate[]
507
510
  }
508
511
 
509
512
  // -- Modal actions --
@@ -517,6 +520,8 @@ export type ModalAction =
517
520
  message: string | null
518
521
  }
519
522
  | { type: 'enter-new-tab-worktree-create' }
523
+ | { type: 'enter-new-tab-template-pick' }
524
+ | { type: 'enter-new-tab-template-shortcut' }
520
525
  | { type: 'select-new-tab-assistant'; assistantId?: AssistantId }
521
526
  | { type: 'toggle-new-tab-worktree'; assistantId?: AssistantId }
522
527
  | { type: 'open-edit-custom-command'; assistantId: AssistantId }
@@ -1,10 +1,11 @@
1
1
  import { useCallback, useMemo } from 'react'
2
2
 
3
+ import type { WorktreeTemplate } from '../../../../config'
3
4
  import type { AssistantId, WorktreeRecord } from '../../../../state/types'
4
5
 
5
6
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
6
7
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
7
- import { filterAssistants } from '../../../../state/selectors'
8
+ import { filterAssistants, getTemplateNoneOffset } from '../../../../state/selectors'
8
9
  import { useTheme } from '../../../theme'
9
10
  import { uiTokens } from '../../../ui-tokens'
10
11
  import { Form, TextField } from '../shared/form'
@@ -23,13 +24,22 @@ interface NewTabModalProps {
23
24
  branchName: string
24
25
  createWorktree: boolean
25
26
  selectedAssistantId: AssistantId | null
26
- step: 'assistant' | 'worktree' | 'worktree-create'
27
+ step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
27
28
  worktreeDeleteConfirmId: string | null
28
29
  worktreeDeleteMessage: string | null
29
30
  worktrees: WorktreeRecord[]
30
31
  worktreeName: string
32
+ worktreeTemplates: WorktreeTemplate[]
33
+ /**
34
+ * True when this modal is the worktree-aware new-tab flow. False when the
35
+ * component is reused for `split-picker`, which can only choose an
36
+ * assistant — the template shortcut entry must be hidden there.
37
+ */
38
+ allowTemplateShortcut: boolean
31
39
  }
32
40
 
41
+ const TEMPLATE_SHORTCUT_KEY = '__template-shortcut__'
42
+
33
43
  function getDeleteBlockedReason({
34
44
  currentSessionId,
35
45
  worktree,
@@ -52,6 +62,7 @@ function getDeleteBlockedReason({
52
62
 
53
63
  export function NewTabModal({
54
64
  activeField,
65
+ allowTemplateShortcut,
55
66
  branchError,
56
67
  branchName,
57
68
  createWorktree,
@@ -68,6 +79,7 @@ export function NewTabModal({
68
79
  worktreeDeleteMessage,
69
80
  worktreeName,
70
81
  worktrees,
82
+ worktreeTemplates,
71
83
  }: NewTabModalProps) {
72
84
  const t = useTheme()
73
85
  const options = useMemo(() => getAllAssistantOptions(customCommands), [customCommands])
@@ -123,30 +135,86 @@ export function NewTabModal({
123
135
  [createWorktree, currentSessionId, selectedIndex, t, worktreeDeleteConfirmId, worktrees]
124
136
  )
125
137
 
126
- const items = useMemo<PickerItem[]>(
127
- () =>
128
- filtered.map((option, index) => {
129
- const active = index === selectedIndex
130
- const customCmd = customCommands[option.id]
138
+ const noneOffset = getTemplateNoneOffset(selectedAssistantId)
139
+ const showNoneOption = noneOffset === 1
140
+ const templateItems = useMemo<PickerItem[]>(() => {
141
+ const noneItem: PickerItem[] = showNoneOption
142
+ ? [
143
+ {
144
+ key: '__none__',
145
+ onClick: () => {
146
+ dispatchGlobal({ index: 0, type: 'set-modal-selection-index' })
147
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
148
+ },
149
+ subtitle: <text fg={t.textMuted}>Single pane — current assistant only</text>,
150
+ title: <text fg={selectedIndex === 0 ? t.text : t.textMuted}>None</text>,
151
+ },
152
+ ]
153
+ : []
154
+ return [
155
+ ...noneItem,
156
+ ...worktreeTemplates.map((template, idx) => {
157
+ const itemIndex = idx + noneOffset
158
+ const active = itemIndex === selectedIndex
159
+ const tabCount = template.tabs.length
160
+ const paneCount = template.tabs.reduce((sum, tab) => sum + tab.panes.length, 0)
131
161
  return {
132
- key: option.id,
133
- onClick: () =>
134
- dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
135
- onEdit: () =>
136
- dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
137
- subtitle: (
138
- <box flexDirection="column">
139
- <text fg={t.textMuted}>{option.description}</text>
140
- {customCmd != null && customCmd !== '' ? (
141
- <text fg={t.primary}>{customCmd}</text>
142
- ) : null}
143
- </box>
144
- ),
145
- title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
162
+ key: template.id,
163
+ onClick: () => {
164
+ dispatchGlobal({ index: itemIndex, type: 'set-modal-selection-index' })
165
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
166
+ },
167
+ subtitle:
168
+ template.description != null && template.description !== '' ? (
169
+ <text fg={t.textMuted}>{template.description}</text>
170
+ ) : (
171
+ <text fg={t.textMuted}>
172
+ {tabCount} tab{tabCount === 1 ? '' : 's'}, {paneCount} pane
173
+ {paneCount === 1 ? '' : 's'}
174
+ </text>
175
+ ),
176
+ title: <text fg={active ? t.text : t.textMuted}>{template.name}</text>,
146
177
  }
147
178
  }),
148
- [customCommands, filtered, selectedIndex, t]
149
- )
179
+ ]
180
+ }, [noneOffset, selectedIndex, showNoneOption, t, worktreeTemplates])
181
+
182
+ const showShortcutEntry = allowTemplateShortcut && worktreeTemplates.length > 0
183
+ const items = useMemo<PickerItem[]>(() => {
184
+ const assistantItems: PickerItem[] = filtered.map((option, index) => {
185
+ const active = index === selectedIndex
186
+ const customCmd = customCommands[option.id]
187
+ return {
188
+ key: option.id,
189
+ onClick: () => dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
190
+ onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
191
+ subtitle: (
192
+ <box flexDirection="column">
193
+ <text fg={t.textMuted}>{option.description}</text>
194
+ {customCmd != null && customCmd !== '' ? <text fg={t.primary}>{customCmd}</text> : null}
195
+ </box>
196
+ ),
197
+ title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
198
+ }
199
+ })
200
+ if (!showShortcutEntry) return assistantItems
201
+ const shortcutIndex = filtered.length
202
+ const shortcutActive = shortcutIndex === selectedIndex
203
+ return [
204
+ ...assistantItems,
205
+ {
206
+ key: TEMPLATE_SHORTCUT_KEY,
207
+ onClick: () => {
208
+ dispatchGlobal({ index: shortcutIndex, type: 'set-modal-selection-index' })
209
+ dispatchGlobal({ type: 'enter-new-tab-template-shortcut' })
210
+ },
211
+ subtitle: (
212
+ <text fg={t.textMuted}>Create a worktree and pick a template — no assistant step</text>
213
+ ),
214
+ title: <text fg={shortcutActive ? t.text : t.textMuted}>Worktree from template…</text>,
215
+ },
216
+ ]
217
+ }, [customCommands, filtered, showShortcutEntry, selectedIndex, t])
150
218
 
151
219
  if (editingCommand !== null) {
152
220
  const option = options.find((o) => o.id === editingCommand) ?? getAssistantOption(0)
@@ -167,6 +235,28 @@ export function NewTabModal({
167
235
  )
168
236
  }
169
237
 
238
+ if (step === 'template') {
239
+ return (
240
+ <Picker
241
+ title="Pick worktree template"
242
+ keybindsModeId="modal.new-tab.command-edit"
243
+ width={uiTokens.modalWidth.md}
244
+ gap={1}
245
+ filter={null}
246
+ items={templateItems}
247
+ selectedIndex={selectedIndex}
248
+ emptyState={<text fg={t.textMuted}>No templates configured.</text>}
249
+ onHover={handleHover}
250
+ footer={
251
+ <box flexDirection="column">
252
+ <text fg={t.textMuted}>Step 4/4: choose template</text>
253
+ <text fg={t.textMuted}>Enter launches, Esc returns to worktree settings</text>
254
+ </box>
255
+ }
256
+ />
257
+ )
258
+ }
259
+
170
260
  if (step === 'worktree-create') {
171
261
  const selectedAssistant =
172
262
  options.find((option) => option.id === selectedAssistantId) ?? options[0]
package/src/ui/root.tsx CHANGED
@@ -3,6 +3,7 @@ import type { MouseEvent } from '@opentui/core'
3
3
  import { useCallback, useMemo } from 'react'
4
4
 
5
5
  import type { MeasuredPaneRect } from '../app-runtime/use-pane-size-report'
6
+ import type { WorktreeTemplate } from '../config'
6
7
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
7
8
  import type {
8
9
  FocusMode,
@@ -92,6 +93,7 @@ function renderModal(
92
93
  activeAssistant?: string
93
94
  autoCommitModel?: string
94
95
  worktreeDivergence: Record<string, { ahead: number; behind: number }>
96
+ worktreeTemplates: WorktreeTemplate[]
95
97
  }
96
98
  ) {
97
99
  switch (modal.type) {
@@ -121,6 +123,8 @@ function renderModal(
121
123
  : EMPTY_WORKTREES
122
124
  }
123
125
  worktreeName={modal.type === 'new-tab' ? modal.worktreeName : ''}
126
+ worktreeTemplates={options.worktreeTemplates}
127
+ allowTemplateShortcut={modal.type === 'new-tab'}
124
128
  />
125
129
  )
126
130
  case 'session-picker':
@@ -316,6 +320,7 @@ export function RootView({
316
320
  const sessions = useAppStore((s) => s.sessions)
317
321
  const currentSessionId = useAppStore((s) => s.currentSessionId)
318
322
  const worktreeDivergence = useAppStore((s) => s.worktreeDivergence)
323
+ const worktreeTemplates = useAppStore((s) => s.worktreeTemplates)
319
324
  const gitPaneMode = useAppStore((s) => s.gitPane.mode)
320
325
  const gitPaneVisible = useAppStore((s) => s.gitPane.visible)
321
326
  const gitPanePosition = useAppStore((s) => s.gitPane.position)
@@ -410,6 +415,7 @@ export function RootView({
410
415
  snippets,
411
416
  themeId,
412
417
  worktreeDivergence,
418
+ worktreeTemplates,
413
419
  })}
414
420
  <ToastViewport />
415
421
  </box>
@@ -507,6 +513,7 @@ export function RootView({
507
513
  snippets,
508
514
  themeId,
509
515
  worktreeDivergence,
516
+ worktreeTemplates,
510
517
  })}
511
518
  <ToastViewport />
512
519
  </box>