@brimveyn/aimux 1.14.13 → 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.13",
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 {
@@ -28,12 +28,24 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
28
28
  // honor the patched worktree by filtering the restored tab list.
29
29
  const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
30
30
  const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
31
- const activeTabId =
31
+ // Prefer the snapshot's tab; if it isn't visible under the active
32
+ // worktree (e.g. a multi-worktree session restored onto a different
33
+ // worktree), fall back to the last tab we viewed in that worktree,
34
+ // then to the first visible tab.
35
+ const rememberedForWorktree =
36
+ loadedSession?.activeWorktreeId != null && loadedSession.activeWorktreeId !== ''
37
+ ? state.lastActiveTabByWorktree[loadedSession.activeWorktreeId]
38
+ : undefined
39
+ const snapshotTabVisible =
32
40
  restored.activeTabId != null &&
33
41
  restored.activeTabId !== '' &&
34
42
  visible.some((t) => t.id === restored.activeTabId)
35
- ? restored.activeTabId
36
- : (visible[0]?.id ?? null)
43
+ const rememberedTabVisible =
44
+ rememberedForWorktree != null &&
45
+ rememberedForWorktree !== '' &&
46
+ visible.some((t) => t.id === rememberedForWorktree)
47
+ const fallbackTabId = rememberedTabVisible ? rememberedForWorktree : (visible[0]?.id ?? null)
48
+ const activeTabId = snapshotTabVisible ? restored.activeTabId : fallbackTabId
37
49
  return {
38
50
  ...state,
39
51
  ...restored,
@@ -196,15 +208,29 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
196
208
  const sessions = state.sessions.map((session) =>
197
209
  session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
198
210
  )
211
+ // Remember the tab we're leaving so coming back to this worktree later
212
+ // restores it instead of snapping to the first tab. Keyed by the
213
+ // worktree we're departing (the current session's active one).
214
+ const leavingSession = state.sessions.find((s) => s.id === action.sessionId)
215
+ const leavingWorktreeId = leavingSession?.activeWorktreeId
216
+ const lastActiveTabByWorktree =
217
+ action.sessionId === state.currentSessionId &&
218
+ leavingWorktreeId != null &&
219
+ leavingWorktreeId !== '' &&
220
+ state.activeTabId != null &&
221
+ state.activeTabId !== ''
222
+ ? { ...state.lastActiveTabByWorktree, [leavingWorktreeId]: state.activeTabId }
223
+ : state.lastActiveTabByWorktree
199
224
  // Changing the worktree of a non-current session has no effect on
200
225
  // which tab the user is looking at — leave activeTabId alone.
201
226
  if (action.sessionId !== state.currentSessionId) {
202
- return { ...state, sessions }
227
+ return { ...state, lastActiveTabByWorktree, sessions }
203
228
  }
204
229
  // For the current session: if the existing activeTabId belongs to
205
- // the new worktree, keep it. Otherwise hop to the first tab visible
206
- // under that worktree (or clear it if the worktree is empty the
207
- // pane then renders the "this worktree has no tabs" placeholder).
230
+ // the new worktree, keep it. Otherwise restore the last tab we viewed
231
+ // in that worktree, falling back to the first visible tab (or clearing
232
+ // it if the worktree is empty — the pane then renders the "this
233
+ // worktree has no tabs" placeholder).
208
234
  const updated = sessions.find((s) => s.id === action.sessionId)
209
235
  const visible = filterTabsForActiveWorktree(state.tabs, updated)
210
236
  const currentStillVisible =
@@ -212,9 +238,14 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
212
238
  state.activeTabId !== '' &&
213
239
  visible.some((t) => t.id === state.activeTabId)
214
240
  if (currentStillVisible) {
215
- return { ...state, sessions }
241
+ return { ...state, lastActiveTabByWorktree, sessions }
216
242
  }
217
- return { ...state, activeTabId: visible[0]?.id ?? null, sessions }
243
+ const remembered = lastActiveTabByWorktree[action.worktreeId]
244
+ const nextActiveTabId =
245
+ remembered != null && remembered !== '' && visible.some((t) => t.id === remembered)
246
+ ? remembered
247
+ : (visible[0]?.id ?? null)
248
+ return { ...state, activeTabId: nextActiveTabId, lastActiveTabByWorktree, sessions }
218
249
  }
219
250
  case 'update-worktree-record':
220
251
  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[],
@@ -15,6 +15,15 @@ export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
15
15
  }
16
16
  }
17
17
 
18
+ /**
19
+ * The per-worktree last-tab memory is serialized into every snapshot now so the
20
+ * data accrues across sessions, but restoring it at startup stays dormant until
21
+ * a future (non-breaking) change is ready to consume it. Flipping this to true
22
+ * makes restart restore the last-viewed tab per worktree; until then restart
23
+ * behavior is unchanged and the live in-memory map is never clobbered on switch.
24
+ */
25
+ const RESTORE_LAST_ACTIVE_TAB_BY_WORKTREE: boolean = false
26
+
18
27
  function getDisconnectedStatus(status: TabStatus): TabStatus {
19
28
  if (status === 'running' || status === 'starting') {
20
29
  return 'disconnected'
@@ -26,6 +35,10 @@ function getDisconnectedStatus(status: TabStatus): TabStatus {
26
35
  export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
27
36
  return {
28
37
  activeTabId: state.activeTabId,
38
+ lastActiveTabByWorktree:
39
+ Object.keys(state.lastActiveTabByWorktree).length > 0
40
+ ? state.lastActiveTabByWorktree
41
+ : undefined,
29
42
  layoutTree: Object.values(state.layoutTrees)[0] ?? undefined,
30
43
  layoutTrees: Object.keys(state.layoutTrees).length > 0 ? state.layoutTrees : undefined,
31
44
  savedAt: new Date().toISOString(),
@@ -241,7 +254,8 @@ export function restoreWorkspaceState(
241
254
  ): Pick<
242
255
  AppState,
243
256
  'tabs' | 'activeTabId' | 'focusMode' | 'sidebar' | 'layoutTrees' | 'tabGroupMap'
244
- > {
257
+ > &
258
+ Partial<Pick<AppState, 'lastActiveTabByWorktree'>> {
245
259
  const tabs = restoreTabsFromWorkspace(workspaceSnapshot, options)
246
260
  const activeTabId =
247
261
  workspaceSnapshot?.activeTabId != null &&
@@ -253,6 +267,14 @@ export function restoreWorkspaceState(
253
267
  const { layoutTrees, tabGroupMap } = restoreLayoutTrees(workspaceSnapshot, tabs)
254
268
  const orderedTabs = normalizeGroupedTabOrder(tabs, layoutTrees, tabGroupMap)
255
269
 
270
+ // Dormant until the gate flips: merge the persisted per-worktree memory over
271
+ // the live in-memory map. While off, the key is omitted entirely so callers
272
+ // that spread the result never overwrite their existing map on a switch.
273
+ const persistedLastActiveTab =
274
+ RESTORE_LAST_ACTIVE_TAB_BY_WORKTREE && workspaceSnapshot?.lastActiveTabByWorktree
275
+ ? { ...state.lastActiveTabByWorktree, ...workspaceSnapshot.lastActiveTabByWorktree }
276
+ : undefined
277
+
256
278
  return {
257
279
  activeTabId,
258
280
  focusMode: 'navigation',
@@ -260,5 +282,6 @@ export function restoreWorkspaceState(
260
282
  sidebar: state.sidebar,
261
283
  tabGroupMap,
262
284
  tabs: orderedTabs,
285
+ ...(persistedLastActiveTab ? { lastActiveTabByWorktree: persistedLastActiveTab } : {}),
263
286
  }
264
287
  }
@@ -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 = {
@@ -84,6 +87,7 @@ export function createInitialState(
84
87
  position: gitPanePosition,
85
88
  },
86
89
  gitPanel: emptyGitPanel(),
90
+ lastActiveTabByWorktree: {},
87
91
  layout: {
88
92
  terminalCols: DEFAULT_TERMINAL_COLS,
89
93
  terminalRows: DEFAULT_TERMINAL_ROWS,
@@ -115,6 +119,7 @@ export function createInitialState(
115
119
  tabGroupMap: {},
116
120
  tabs: [],
117
121
  worktreeDivergence: {},
122
+ worktreeTemplates: overrides.worktreeTemplates ?? [],
118
123
  }
119
124
  }
120
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'
@@ -117,6 +118,13 @@ export interface WorkspaceSnapshotV1 {
117
118
  layoutTree?: LayoutNode
118
119
  layoutTrees?: Record<string, LayoutNode>
119
120
  tabGroupMap?: Record<string, string>
121
+ /**
122
+ * Last viewed tab per worktree, keyed by worktree id. Optional and additive
123
+ * (no version bump): older builds ignore it, newer builds tolerate its
124
+ * absence. Written now so the data accrues, but restoring it at startup is
125
+ * gated off until a future change flips RESTORE_LAST_ACTIVE_TAB_BY_WORKTREE.
126
+ */
127
+ lastActiveTabByWorktree?: Record<string, string>
120
128
  }
121
129
 
122
130
  export type WorktreeSource = 'primary' | 'aimux-temp' | 'external'
@@ -320,7 +328,7 @@ export interface ModalNewTab extends ModalBase {
320
328
  branchName: string
321
329
  createWorktree: boolean
322
330
  selectedAssistantId: AssistantId | null
323
- step: 'assistant' | 'worktree' | 'worktree-create'
331
+ step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
324
332
  targetWorktreeIndex: number
325
333
  worktreeDeleteConfirmId: string | null
326
334
  worktreeDeleteMessage: string | null
@@ -489,8 +497,16 @@ export interface AppState {
489
497
  * keyed by worktree id. Ephemeral (polled); not persisted to the catalog.
490
498
  */
491
499
  worktreeDivergence: Record<string, BranchDivergence>
500
+ /**
501
+ * Last active tab a user viewed within each worktree, keyed by worktree id.
502
+ * Lets switching back to a worktree restore its last-viewed tab instead of
503
+ * snapping to the first one. Ephemeral (in-memory); not persisted to the catalog.
504
+ */
505
+ lastActiveTabByWorktree: Record<string, string>
492
506
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
493
507
  pendingChords: string[] | null
508
+ /** User-defined templates applied at worktree creation. Loaded from aimux.json. */
509
+ worktreeTemplates: WorktreeTemplate[]
494
510
  }
495
511
 
496
512
  // -- Modal actions --
@@ -504,6 +520,8 @@ export type ModalAction =
504
520
  message: string | null
505
521
  }
506
522
  | { type: 'enter-new-tab-worktree-create' }
523
+ | { type: 'enter-new-tab-template-pick' }
524
+ | { type: 'enter-new-tab-template-shortcut' }
507
525
  | { type: 'select-new-tab-assistant'; assistantId?: AssistantId }
508
526
  | { type: 'toggle-new-tab-worktree'; assistantId?: AssistantId }
509
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>