@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 +2 -2
- package/src/app-runtime/side-effects.ts +171 -5
- package/src/app.tsx +4 -0
- package/src/config.ts +97 -0
- package/src/state/reducers/modal-state.ts +94 -14
- package/src/state/reducers/session-state.ts +40 -9
- package/src/state/selectors.ts +11 -1
- package/src/state/session-persistence.ts +24 -1
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +19 -1
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +113 -23
- package/src/ui/root.tsx +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.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.
|
|
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 {
|
|
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 {
|
|
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 =
|
|
567
|
-
getAllAssistantOptions(state.customCommands),
|
|
568
|
-
|
|
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
|
-
|
|
630
|
-
state
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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 === '
|
|
681
|
-
|
|
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 === '
|
|
711
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
206
|
-
//
|
|
207
|
-
// pane then renders the "this
|
|
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
|
-
|
|
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 {
|
package/src/state/selectors.ts
CHANGED
|
@@ -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
|
}
|
package/src/state/store.ts
CHANGED
|
@@ -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
|
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
133
|
-
onClick: () =>
|
|
134
|
-
dispatchGlobal({
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
subtitle:
|
|
138
|
-
|
|
139
|
-
<text fg={t.textMuted}>{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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>
|