@brimveyn/aimux 1.14.15 → 1.15.0
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 +122 -43
- package/src/app.tsx +26 -1
- package/src/config.ts +4 -0
- package/src/git/move-worktree.ts +87 -17
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +5 -0
- package/src/input/modes/transitions.ts +10 -1
- package/src/input/modes/types.ts +20 -1
- package/src/ipc/manager-protocol.ts +9 -1
- package/src/ipc/protocol.ts +9 -1
- package/src/pty/ghostty-shell-integration.ts +34 -0
- package/src/pty/pty-manager.ts +60 -6
- package/src/pty/terminal-snapshot.ts +29 -13
- package/src/state/reducers/modal-state.ts +166 -19
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +45 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/types.ts +83 -7
- package/src/state/validation.ts +9 -1
- package/src/ui/components/layout/sidebar/tab-item.tsx +4 -2
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/terminal-pane.tsx +112 -8
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +70 -23
- package/src/ui/components/modals/worktree/worktree-move-confirm-modal.tsx +65 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +18 -1
- package/src/ui/root.tsx +25 -2
- package/src/ui/status-bar-model.ts +2 -0
package/src/input/modes/types.ts
CHANGED
|
@@ -9,6 +9,8 @@ export type ModeId =
|
|
|
9
9
|
| 'git-mode'
|
|
10
10
|
| 'modal.new-tab.command-edit'
|
|
11
11
|
| 'modal.new-tab.editing-command'
|
|
12
|
+
| 'modal.new-tab.worktree-delete-confirm'
|
|
13
|
+
| 'modal.worktree-delete-confirm'
|
|
12
14
|
| 'modal.session-picker.filtering'
|
|
13
15
|
| 'modal.session-name'
|
|
14
16
|
| 'modal.create-session'
|
|
@@ -23,11 +25,13 @@ export type ModeId =
|
|
|
23
25
|
| 'modal.git-commit.generating'
|
|
24
26
|
| 'modal.update-available'
|
|
25
27
|
| 'modal.worktree-move'
|
|
28
|
+
| 'modal.worktree-move-confirm'
|
|
26
29
|
| 'modal.ai-usage'
|
|
27
30
|
|
|
28
31
|
export type SideEffect =
|
|
29
32
|
| { type: 'quit'; state: AppState }
|
|
30
33
|
| { type: 'launch-selected-assistant' }
|
|
34
|
+
| { type: 'load-new-tab-base-branches' }
|
|
31
35
|
| { type: 'edit-selected-assistant' }
|
|
32
36
|
| { type: 'confirm-selected-session' }
|
|
33
37
|
| { type: 'delete-selected-session' }
|
|
@@ -71,14 +75,29 @@ export type SideEffect =
|
|
|
71
75
|
| { type: 'cycle-sidebar-item'; direction: 1 | -1 }
|
|
72
76
|
| { type: 'switch-tab-by-index'; index: number }
|
|
73
77
|
| { type: 'delete-session'; sessionId: string }
|
|
74
|
-
| {
|
|
78
|
+
| {
|
|
79
|
+
type: 'delete-worktree'
|
|
80
|
+
sessionId: string
|
|
81
|
+
worktreeId: string
|
|
82
|
+
// Force the git worktree removal (discards uncommitted changes in the
|
|
83
|
+
// worktree). Also implies closing the worktree's tabs.
|
|
84
|
+
force?: boolean
|
|
85
|
+
// Close the worktree's tabs without forcing the git removal. Lets the
|
|
86
|
+
// sidebar "Remove worktree" clean up tabs (avoiding orphans) while still
|
|
87
|
+
// refusing to discard uncommitted work in a temp worktree.
|
|
88
|
+
closeTabs?: boolean
|
|
89
|
+
}
|
|
75
90
|
| {
|
|
76
91
|
type: 'move-worktree'
|
|
77
92
|
sessionId: string
|
|
78
93
|
sourceWorktreeId: string
|
|
79
94
|
targetWorktreeId: string
|
|
80
95
|
deleteSource?: boolean
|
|
96
|
+
// Retry flags set by the worktree-move-confirm dialog.
|
|
97
|
+
stashTarget?: boolean
|
|
98
|
+
keepConflicts?: boolean
|
|
81
99
|
}
|
|
100
|
+
| { type: 'load-worktree-move-stats' }
|
|
82
101
|
| { type: 'toggle-transparent' }
|
|
83
102
|
| { type: 'toggle-mode' }
|
|
84
103
|
| { type: 'open-file-in-editor'; path: string }
|
|
@@ -197,7 +197,15 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
197
197
|
) &&
|
|
198
198
|
isFiniteNumber(value.viewportY) &&
|
|
199
199
|
isFiniteNumber(value.baseY) &&
|
|
200
|
-
typeof value.cursorVisible === 'boolean'
|
|
200
|
+
typeof value.cursorVisible === 'boolean' &&
|
|
201
|
+
(value.cursorStyle === undefined ||
|
|
202
|
+
value.cursorStyle === 'block' ||
|
|
203
|
+
value.cursorStyle === 'underline' ||
|
|
204
|
+
value.cursorStyle === 'bar' ||
|
|
205
|
+
value.cursorStyle === 'default') &&
|
|
206
|
+
(value.cursorBlink === undefined || typeof value.cursorBlink === 'boolean') &&
|
|
207
|
+
(value.cursorRow === undefined || isFiniteNumber(value.cursorRow)) &&
|
|
208
|
+
(value.cursorCol === undefined || isFiniteNumber(value.cursorCol))
|
|
201
209
|
)
|
|
202
210
|
}
|
|
203
211
|
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -175,7 +175,15 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
175
175
|
) &&
|
|
176
176
|
isFiniteNumber(value.viewportY) &&
|
|
177
177
|
isFiniteNumber(value.baseY) &&
|
|
178
|
-
typeof value.cursorVisible === 'boolean'
|
|
178
|
+
typeof value.cursorVisible === 'boolean' &&
|
|
179
|
+
(value.cursorStyle === undefined ||
|
|
180
|
+
value.cursorStyle === 'block' ||
|
|
181
|
+
value.cursorStyle === 'underline' ||
|
|
182
|
+
value.cursorStyle === 'bar' ||
|
|
183
|
+
value.cursorStyle === 'default') &&
|
|
184
|
+
(value.cursorBlink === undefined || typeof value.cursorBlink === 'boolean') &&
|
|
185
|
+
(value.cursorRow === undefined || isFiniteNumber(value.cursorRow)) &&
|
|
186
|
+
(value.cursorCol === undefined || isFiniteNumber(value.cursorCol))
|
|
179
187
|
)
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type PtyEnv = Record<string, string>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Replicate Ghostty's automatic shell-integration injection for the nested
|
|
8
|
+
* shells aimux spawns. Ghostty only performs it for shells it launches
|
|
9
|
+
* directly (by pointing ZDOTDIR at its integration dir, whose .zshenv
|
|
10
|
+
* restores the user's ZDOTDIR and then loads the integration), so aimux
|
|
11
|
+
* panes would otherwise miss the integration's zle hooks — notably the
|
|
12
|
+
* DECSCUSR cursor-shape reporting (bar at prompt, block in vicmd) that the
|
|
13
|
+
* hardware-cursor pass-through relies on for native-parity rendering.
|
|
14
|
+
*/
|
|
15
|
+
export function applyGhosttyShellIntegration(
|
|
16
|
+
env: PtyEnv,
|
|
17
|
+
command: string,
|
|
18
|
+
integrationFileExists: (path: string) => boolean = existsSync
|
|
19
|
+
): PtyEnv {
|
|
20
|
+
const resourcesDir = env.GHOSTTY_RESOURCES_DIR
|
|
21
|
+
if (resourcesDir === undefined || resourcesDir === '') return env
|
|
22
|
+
// bash and fish use different bootstrap mechanisms; only zsh is supported.
|
|
23
|
+
if (basename(command) !== 'zsh') return env
|
|
24
|
+
|
|
25
|
+
const integrationDir = join(resourcesDir, 'shell-integration', 'zsh')
|
|
26
|
+
if (env.ZDOTDIR === integrationDir) return env
|
|
27
|
+
if (!integrationFileExists(join(integrationDir, '.zshenv'))) return env
|
|
28
|
+
|
|
29
|
+
const next: PtyEnv = { ...env, ZDOTDIR: integrationDir }
|
|
30
|
+
if (env.ZDOTDIR !== undefined && env.ZDOTDIR !== '') {
|
|
31
|
+
next.GHOSTTY_ZSH_ZDOTDIR = env.ZDOTDIR
|
|
32
|
+
}
|
|
33
|
+
return next
|
|
34
|
+
}
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { Terminal as XTerm } from '@xterm/headless'
|
|
|
2
2
|
import { type IPty, spawn } from 'bun-pty'
|
|
3
3
|
import { EventEmitter } from 'node:events'
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ScrollIntent,
|
|
7
|
+
TerminalCursorStyle,
|
|
8
|
+
TerminalModeState,
|
|
9
|
+
TerminalSnapshot,
|
|
10
|
+
} from '../state/types'
|
|
6
11
|
|
|
7
12
|
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import { applyGhosttyShellIntegration } from './ghostty-shell-integration'
|
|
8
14
|
import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
|
|
9
15
|
|
|
10
16
|
interface PtyManagerEvents {
|
|
@@ -21,6 +27,10 @@ interface SessionHandle {
|
|
|
21
27
|
lastTerminalModes?: TerminalModeState
|
|
22
28
|
alternateScrollMode: boolean
|
|
23
29
|
cursorVisible: boolean
|
|
30
|
+
/** Last DECSCUSR shape; 'default' = the host terminal's configured cursor. */
|
|
31
|
+
cursorStyle: TerminalCursorStyle
|
|
32
|
+
/** Last DECSCUSR blink flag; undefined until an explicit DECSCUSR arrives. */
|
|
33
|
+
cursorBlink: boolean | undefined
|
|
24
34
|
pendingModeSequence: string
|
|
25
35
|
pendingWrites: number
|
|
26
36
|
pendingExitCode: number | null
|
|
@@ -75,6 +85,28 @@ function trackPrivateModes(
|
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
// DECSCUSR (CSI Ps SP q): 0 resets to the terminal's configured cursor,
|
|
89
|
+
// odd values blink, 1/2 = block, 3/4 = underline, 5/6 = bar.
|
|
90
|
+
const DECSCUSR_STYLES: readonly TerminalCursorStyle[] = [
|
|
91
|
+
'block',
|
|
92
|
+
'block',
|
|
93
|
+
'underline',
|
|
94
|
+
'underline',
|
|
95
|
+
'bar',
|
|
96
|
+
'bar',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
function decscusrToCursorState(ps: number): {
|
|
100
|
+
cursorStyle: TerminalCursorStyle
|
|
101
|
+
cursorBlink: boolean | undefined
|
|
102
|
+
} {
|
|
103
|
+
const style = DECSCUSR_STYLES[ps - 1]
|
|
104
|
+
if (ps === 0 || style === undefined) {
|
|
105
|
+
return { cursorBlink: undefined, cursorStyle: 'default' }
|
|
106
|
+
}
|
|
107
|
+
return { cursorBlink: ps % 2 === 1, cursorStyle: style }
|
|
108
|
+
}
|
|
109
|
+
|
|
78
110
|
function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): TerminalModeState {
|
|
79
111
|
return {
|
|
80
112
|
alternateScrollMode,
|
|
@@ -192,7 +224,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
192
224
|
|
|
193
225
|
private emitRenderIfChanged(session: SessionHandle): void {
|
|
194
226
|
if (!this.broadcastEnabled) return
|
|
195
|
-
const nextSnapshot = snapshotTerminal(session.emulator,
|
|
227
|
+
const nextSnapshot = snapshotTerminal(session.emulator, {
|
|
228
|
+
cursorBlink: session.cursorBlink,
|
|
229
|
+
cursorStyle: session.cursorStyle,
|
|
230
|
+
cursorVisible: session.cursorVisible,
|
|
231
|
+
})
|
|
196
232
|
const nextTerminalModes = getTerminalModes(session.emulator, session.alternateScrollMode)
|
|
197
233
|
const snapshotChanged = !areTerminalSnapshotsEqual(session.lastSnapshot, nextSnapshot)
|
|
198
234
|
const modesChanged =
|
|
@@ -259,21 +295,28 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
259
295
|
scrollback: 1000,
|
|
260
296
|
})
|
|
261
297
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
cwd: options.cwd ?? process.cwd(),
|
|
265
|
-
env: {
|
|
298
|
+
const env = applyGhosttyShellIntegration(
|
|
299
|
+
{
|
|
266
300
|
...process.env,
|
|
267
301
|
COLORTERM: 'truecolor',
|
|
268
302
|
TERM: 'xterm-256color',
|
|
269
303
|
...options.env,
|
|
270
304
|
},
|
|
305
|
+
options.command
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
const pty = spawn(options.command, options.args ?? [], {
|
|
309
|
+
cols: options.cols,
|
|
310
|
+
cwd: options.cwd ?? process.cwd(),
|
|
311
|
+
env,
|
|
271
312
|
name: 'xterm-256color',
|
|
272
313
|
rows: options.rows,
|
|
273
314
|
})
|
|
274
315
|
|
|
275
316
|
const session: SessionHandle = {
|
|
276
317
|
alternateScrollMode: false,
|
|
318
|
+
cursorBlink: undefined,
|
|
319
|
+
cursorStyle: 'default',
|
|
277
320
|
cursorVisible: true,
|
|
278
321
|
emulator,
|
|
279
322
|
lastScrollIntent: undefined,
|
|
@@ -287,6 +330,17 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
287
330
|
tabId: options.tabId,
|
|
288
331
|
}
|
|
289
332
|
|
|
333
|
+
// xterm parses DECSCUSR itself but keeps the result in private state;
|
|
334
|
+
// mirror it here so snapshots can carry the shape to the renderer.
|
|
335
|
+
// Returning false lets xterm's own handler still run.
|
|
336
|
+
emulator.parser.registerCsiHandler({ final: 'q', intermediates: ' ' }, (params) => {
|
|
337
|
+
const raw = params[0]
|
|
338
|
+
const tracked = decscusrToCursorState(typeof raw === 'number' ? raw : 0)
|
|
339
|
+
session.cursorStyle = tracked.cursorStyle
|
|
340
|
+
session.cursorBlink = tracked.cursorBlink
|
|
341
|
+
return false
|
|
342
|
+
})
|
|
343
|
+
|
|
290
344
|
pty.onData((data) => {
|
|
291
345
|
logDebug('ptyManager.data', {
|
|
292
346
|
byteLength: Buffer.byteLength(data, 'utf8'),
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Terminal } from '@xterm/headless'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
TerminalCursorStyle,
|
|
5
|
+
TerminalLine,
|
|
6
|
+
TerminalSnapshot,
|
|
7
|
+
TerminalSpan,
|
|
8
|
+
} from '../state/types'
|
|
4
9
|
|
|
5
10
|
import { getCurrentTheme } from '../ui/theme'
|
|
6
11
|
|
|
@@ -95,14 +100,10 @@ function buildLine(
|
|
|
95
100
|
bg = resolvedFg
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
// The cursor cell is only flagged here; how it renders (soft inverted
|
|
104
|
+
// block vs. the host terminal's hardware cursor) is a presentation
|
|
105
|
+
// decision made in the renderer process (terminal-pane.tsx).
|
|
98
106
|
const isCursorCell = cursorVisible && cursorColumn === column
|
|
99
|
-
if (isCursorCell) {
|
|
100
|
-
const tokens = getCurrentTheme()
|
|
101
|
-
const resolvedFg: CellColor = fg ?? { hex: tokens.text, kind: 'rgb' }
|
|
102
|
-
const resolvedBg: CellColor = bg ?? { hex: tokens.background, kind: 'rgb' }
|
|
103
|
-
fg = resolvedBg
|
|
104
|
-
bg = resolvedFg
|
|
105
|
-
}
|
|
106
107
|
|
|
107
108
|
const span: TerminalSpan = {
|
|
108
109
|
bold: current.isBold() ? true : undefined,
|
|
@@ -143,11 +144,8 @@ function buildLine(
|
|
|
143
144
|
if (leading > 0) {
|
|
144
145
|
pushSpan(spans, { text: ' '.repeat(leading) })
|
|
145
146
|
}
|
|
146
|
-
const tokens = getCurrentTheme()
|
|
147
147
|
pushSpan(spans, {
|
|
148
|
-
bg: tokens.text,
|
|
149
148
|
cursor: true,
|
|
150
|
-
fg: tokens.background,
|
|
151
149
|
text: ' ',
|
|
152
150
|
})
|
|
153
151
|
if (trailing > 0) {
|
|
@@ -161,7 +159,17 @@ function buildLine(
|
|
|
161
159
|
return { spans }
|
|
162
160
|
}
|
|
163
161
|
|
|
164
|
-
export
|
|
162
|
+
export interface SnapshotCursorOptions {
|
|
163
|
+
cursorVisible: boolean
|
|
164
|
+
cursorStyle?: TerminalCursorStyle
|
|
165
|
+
cursorBlink?: boolean
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function snapshotTerminal(
|
|
169
|
+
terminal: Terminal,
|
|
170
|
+
cursorOptions: SnapshotCursorOptions = { cursorVisible: true }
|
|
171
|
+
): TerminalSnapshot {
|
|
172
|
+
const { cursorBlink, cursorStyle, cursorVisible } = cursorOptions
|
|
165
173
|
const buffer = terminal.buffer.active
|
|
166
174
|
const startLine = buffer.viewportY
|
|
167
175
|
const tailStartLine = Math.max(0, buffer.baseY + terminal.rows - SNAPSHOT_TAIL_LINE_COUNT)
|
|
@@ -201,6 +209,10 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
|
|
|
201
209
|
|
|
202
210
|
return {
|
|
203
211
|
baseY: buffer.baseY,
|
|
212
|
+
cursorBlink,
|
|
213
|
+
cursorCol: cursorColumn,
|
|
214
|
+
cursorRow: cursorLine - buffer.viewportY,
|
|
215
|
+
cursorStyle,
|
|
204
216
|
cursorVisible,
|
|
205
217
|
lines,
|
|
206
218
|
tailLines,
|
|
@@ -255,7 +267,11 @@ export function areTerminalSnapshotsEqual(
|
|
|
255
267
|
if (
|
|
256
268
|
left.viewportY !== right.viewportY ||
|
|
257
269
|
left.baseY !== right.baseY ||
|
|
258
|
-
left.cursorVisible !== right.cursorVisible
|
|
270
|
+
left.cursorVisible !== right.cursorVisible ||
|
|
271
|
+
left.cursorStyle !== right.cursorStyle ||
|
|
272
|
+
left.cursorBlink !== right.cursorBlink ||
|
|
273
|
+
left.cursorRow !== right.cursorRow ||
|
|
274
|
+
left.cursorCol !== right.cursorCol
|
|
259
275
|
) {
|
|
260
276
|
return false
|
|
261
277
|
}
|
|
@@ -7,6 +7,8 @@ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
|
|
|
7
7
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
8
8
|
import { filterThemeIds } from '../../ui/filter-themes'
|
|
9
9
|
import {
|
|
10
|
+
type BaseRefOption,
|
|
11
|
+
buildBaseRefOptions,
|
|
10
12
|
filterAssistants,
|
|
11
13
|
filterSessions,
|
|
12
14
|
filterSnippets,
|
|
@@ -44,6 +46,17 @@ function getCurrentWorktreeIndex(state: AppState): number {
|
|
|
44
46
|
)
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
function getNewTabBaseOptions(state: AppState, queryOverride?: string): BaseRefOption[] {
|
|
50
|
+
if (state.modal.type !== 'new-tab') return []
|
|
51
|
+
const worktrees =
|
|
52
|
+
state.sessions.find((entry) => entry.id === state.currentSessionId)?.worktrees ?? []
|
|
53
|
+
return buildBaseRefOptions(
|
|
54
|
+
worktrees,
|
|
55
|
+
state.modal.baseBranches,
|
|
56
|
+
queryOverride ?? state.modal.baseQuery
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
function getSelectedNewTabAssistant(state: AppState, assistantId?: string) {
|
|
48
61
|
if (assistantId != null && assistantId !== '') {
|
|
49
62
|
return getAllAssistantOptions(state.customCommands).find((entry) => entry.id === assistantId)
|
|
@@ -63,6 +76,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
63
76
|
focusMode: 'command-edit',
|
|
64
77
|
modal: {
|
|
65
78
|
activeField: 'assistant',
|
|
79
|
+
baseBranches: [],
|
|
80
|
+
baseQuery: '',
|
|
81
|
+
baseRef: '',
|
|
66
82
|
branchError: null,
|
|
67
83
|
branchName: '',
|
|
68
84
|
createWorktree: false,
|
|
@@ -75,19 +91,25 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
75
91
|
step: 'assistant',
|
|
76
92
|
targetWorktreeIndex,
|
|
77
93
|
type: 'new-tab',
|
|
78
|
-
|
|
79
|
-
worktreeDeleteMessage: null,
|
|
94
|
+
worktreeDeletePrompt: null,
|
|
80
95
|
worktreeName: '',
|
|
81
96
|
},
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
case 'enter-new-tab-worktree-create': {
|
|
85
100
|
if (state.modal.type !== 'new-tab') return state
|
|
101
|
+
// Default the base to the branch of the worktree we're forking from (the
|
|
102
|
+
// current target), preserving the previous always-fork-from-source
|
|
103
|
+
// behaviour until the user picks another base.
|
|
104
|
+
const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
|
|
105
|
+
const sourceWorktree = session?.worktrees?.[state.modal.targetWorktreeIndex]
|
|
86
106
|
return {
|
|
87
107
|
...state,
|
|
88
108
|
modal: {
|
|
89
109
|
...state.modal,
|
|
90
110
|
activeField: 'worktree-name',
|
|
111
|
+
baseQuery: '',
|
|
112
|
+
baseRef: sourceWorktree?.branch ?? '',
|
|
91
113
|
createWorktree: true,
|
|
92
114
|
cursorPos: state.modal.worktreeName.length,
|
|
93
115
|
selectedIndex: 0,
|
|
@@ -95,6 +117,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
95
117
|
},
|
|
96
118
|
}
|
|
97
119
|
}
|
|
120
|
+
case 'set-new-tab-base-branches': {
|
|
121
|
+
if (state.modal.type !== 'new-tab') return state
|
|
122
|
+
const modalWithBranches = { ...state.modal, baseBranches: action.branches }
|
|
123
|
+
const withBranches: AppState = { ...state, modal: modalWithBranches }
|
|
124
|
+
// Backfill a default base if none resolved yet (e.g. detached source).
|
|
125
|
+
if (state.modal.baseRef !== '') return withBranches
|
|
126
|
+
const firstOption = getNewTabBaseOptions(withBranches)[0]
|
|
127
|
+
return {
|
|
128
|
+
...withBranches,
|
|
129
|
+
modal: { ...modalWithBranches, baseRef: firstOption?.ref ?? '' },
|
|
130
|
+
}
|
|
131
|
+
}
|
|
98
132
|
case 'enter-new-tab-template-pick': {
|
|
99
133
|
if (state.modal.type !== 'new-tab') return state
|
|
100
134
|
return {
|
|
@@ -125,14 +159,13 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
125
159
|
},
|
|
126
160
|
}
|
|
127
161
|
}
|
|
128
|
-
case 'set-new-tab-worktree-delete-
|
|
162
|
+
case 'set-new-tab-worktree-delete-prompt': {
|
|
129
163
|
if (state.modal.type !== 'new-tab') return state
|
|
130
164
|
return {
|
|
131
165
|
...state,
|
|
132
166
|
modal: {
|
|
133
167
|
...state.modal,
|
|
134
|
-
|
|
135
|
-
worktreeDeleteMessage: action.message,
|
|
168
|
+
worktreeDeletePrompt: action.prompt,
|
|
136
169
|
},
|
|
137
170
|
}
|
|
138
171
|
}
|
|
@@ -192,8 +225,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
192
225
|
selectedIndex: worktreeCount === 0 ? 0 : targetWorktreeIndex,
|
|
193
226
|
step: 'worktree',
|
|
194
227
|
targetWorktreeIndex,
|
|
195
|
-
|
|
196
|
-
worktreeDeleteMessage: null,
|
|
228
|
+
worktreeDeletePrompt: null,
|
|
197
229
|
worktreeName: state.modal.worktreeName || `wt-${option.label}`,
|
|
198
230
|
},
|
|
199
231
|
}
|
|
@@ -228,8 +260,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
228
260
|
: Math.min(state.modal.selectedIndex, Math.max(0, worktreeCount - 1)),
|
|
229
261
|
step: option && createWorktree ? 'worktree' : state.modal.step,
|
|
230
262
|
targetWorktreeIndex,
|
|
231
|
-
|
|
232
|
-
worktreeDeleteMessage: null,
|
|
263
|
+
worktreeDeletePrompt: null,
|
|
233
264
|
worktreeName: defaultName,
|
|
234
265
|
},
|
|
235
266
|
}
|
|
@@ -273,6 +304,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
273
304
|
selectedIndex: 0,
|
|
274
305
|
sessionTargetId: null,
|
|
275
306
|
sourceWorktreeId: action.sourceWorktreeId,
|
|
307
|
+
stats: { kind: 'loading' },
|
|
276
308
|
type: 'worktree-move',
|
|
277
309
|
},
|
|
278
310
|
}
|
|
@@ -284,6 +316,51 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
284
316
|
modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
|
|
285
317
|
}
|
|
286
318
|
}
|
|
319
|
+
case 'set-worktree-move-stats': {
|
|
320
|
+
if (state.modal.type !== 'worktree-move') return state
|
|
321
|
+
return {
|
|
322
|
+
...state,
|
|
323
|
+
modal: { ...state.modal, stats: { dirtyFiles: action.dirtyFiles, kind: 'ready' } },
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
case 'open-worktree-move-confirm': {
|
|
327
|
+
return {
|
|
328
|
+
...state,
|
|
329
|
+
focusMode: 'modal',
|
|
330
|
+
modal: {
|
|
331
|
+
deleteSource: action.deleteSource,
|
|
332
|
+
editBuffer: null,
|
|
333
|
+
files: action.files,
|
|
334
|
+
selectedIndex: 0,
|
|
335
|
+
sessionId: action.sessionId,
|
|
336
|
+
sessionTargetId: null,
|
|
337
|
+
sourceLabel: action.sourceLabel,
|
|
338
|
+
sourceWorktreeId: action.sourceWorktreeId,
|
|
339
|
+
targetLabel: action.targetLabel,
|
|
340
|
+
targetWorktreeId: action.targetWorktreeId,
|
|
341
|
+
type: 'worktree-move-confirm',
|
|
342
|
+
variant: action.variant,
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
case 'open-worktree-delete-confirm': {
|
|
347
|
+
return {
|
|
348
|
+
...state,
|
|
349
|
+
focusMode: 'modal',
|
|
350
|
+
modal: {
|
|
351
|
+
closeTabs: action.closeTabs,
|
|
352
|
+
editBuffer: null,
|
|
353
|
+
force: action.force,
|
|
354
|
+
reason: action.reason,
|
|
355
|
+
selectedIndex: 0,
|
|
356
|
+
sessionId: action.sessionId,
|
|
357
|
+
sessionTargetId: null,
|
|
358
|
+
type: 'worktree-delete-confirm',
|
|
359
|
+
worktreeId: action.worktreeId,
|
|
360
|
+
worktreeLabel: action.worktreeLabel,
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
}
|
|
287
364
|
case 'open-help-modal': {
|
|
288
365
|
const keymap = getActiveKeymap()
|
|
289
366
|
const scope = action.scope ?? null
|
|
@@ -616,7 +693,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
616
693
|
return state
|
|
617
694
|
}
|
|
618
695
|
if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
|
|
619
|
-
return state
|
|
696
|
+
if (state.modal.activeField !== 'base') return state
|
|
697
|
+
const options = getNewTabBaseOptions(state)
|
|
698
|
+
if (options.length === 0) return state
|
|
699
|
+
const next = (state.modal.selectedIndex + action.delta + options.length) % options.length
|
|
700
|
+
return {
|
|
701
|
+
...state,
|
|
702
|
+
modal: {
|
|
703
|
+
...state.modal,
|
|
704
|
+
baseRef: options[next]?.ref ?? state.modal.baseRef,
|
|
705
|
+
selectedIndex: next,
|
|
706
|
+
},
|
|
707
|
+
}
|
|
620
708
|
}
|
|
621
709
|
let optionCount: number
|
|
622
710
|
if (state.modal.type === 'new-tab') {
|
|
@@ -661,8 +749,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
661
749
|
createWorktree:
|
|
662
750
|
(state.modal.selectedIndex + action.delta + optionCount) % optionCount ===
|
|
663
751
|
optionCount - 1,
|
|
664
|
-
|
|
665
|
-
worktreeDeleteMessage: null,
|
|
752
|
+
worktreeDeletePrompt: null,
|
|
666
753
|
}
|
|
667
754
|
: null),
|
|
668
755
|
},
|
|
@@ -683,7 +770,19 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
683
770
|
return state
|
|
684
771
|
}
|
|
685
772
|
if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
|
|
686
|
-
return state
|
|
773
|
+
if (state.modal.activeField !== 'base') return state
|
|
774
|
+
const options = getNewTabBaseOptions(state)
|
|
775
|
+
if (options.length === 0) return state
|
|
776
|
+
const clamped = Math.max(0, Math.min(options.length - 1, action.index))
|
|
777
|
+
if (clamped === state.modal.selectedIndex) return state
|
|
778
|
+
return {
|
|
779
|
+
...state,
|
|
780
|
+
modal: {
|
|
781
|
+
...state.modal,
|
|
782
|
+
baseRef: options[clamped]?.ref ?? state.modal.baseRef,
|
|
783
|
+
selectedIndex: clamped,
|
|
784
|
+
},
|
|
785
|
+
}
|
|
687
786
|
}
|
|
688
787
|
let optionCount: number
|
|
689
788
|
if (state.modal.type === 'help') {
|
|
@@ -724,8 +823,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
724
823
|
...state.modal,
|
|
725
824
|
createWorktree: clamped === optionCount - 1,
|
|
726
825
|
selectedIndex: clamped,
|
|
727
|
-
|
|
728
|
-
worktreeDeleteMessage: null,
|
|
826
|
+
worktreeDeletePrompt: null,
|
|
729
827
|
},
|
|
730
828
|
}
|
|
731
829
|
}
|
|
@@ -770,6 +868,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
770
868
|
buffer = state.modal.worktreeName
|
|
771
869
|
} else if (state.modal.activeField === 'branch-name') {
|
|
772
870
|
buffer = state.modal.branchName
|
|
871
|
+
} else if (state.modal.activeField === 'base') {
|
|
872
|
+
buffer = state.modal.baseQuery
|
|
773
873
|
}
|
|
774
874
|
}
|
|
775
875
|
if (
|
|
@@ -821,6 +921,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
821
921
|
},
|
|
822
922
|
}
|
|
823
923
|
}
|
|
924
|
+
if (
|
|
925
|
+
state.modal.type === 'new-tab' &&
|
|
926
|
+
state.modal.editingCommand === null &&
|
|
927
|
+
state.modal.activeField === 'base'
|
|
928
|
+
) {
|
|
929
|
+
// Re-filter and snap the selection/base to the top match as the query changes.
|
|
930
|
+
const topOption = getNewTabBaseOptions(state, nextBuffer)[0]
|
|
931
|
+
return {
|
|
932
|
+
...state,
|
|
933
|
+
modal: {
|
|
934
|
+
...state.modal,
|
|
935
|
+
baseQuery: nextBuffer,
|
|
936
|
+
baseRef: topOption?.ref ?? state.modal.baseRef,
|
|
937
|
+
cursorPos: nextCursor,
|
|
938
|
+
selectedIndex: 0,
|
|
939
|
+
},
|
|
940
|
+
}
|
|
941
|
+
}
|
|
824
942
|
const resetIndex =
|
|
825
943
|
!isNewTabEditing &&
|
|
826
944
|
(state.modal.type === 'session-picker' ||
|
|
@@ -901,8 +1019,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
901
1019
|
cursorPos: state.modal.editBuffer?.length ?? 0,
|
|
902
1020
|
selectedIndex: Math.max(0, assistantIndex),
|
|
903
1021
|
step: 'assistant',
|
|
904
|
-
|
|
905
|
-
worktreeDeleteMessage: null,
|
|
1022
|
+
worktreeDeletePrompt: null,
|
|
906
1023
|
},
|
|
907
1024
|
}
|
|
908
1025
|
}
|
|
@@ -991,8 +1108,38 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
991
1108
|
if (state.modal.type === 'new-tab') {
|
|
992
1109
|
if (state.modal.step === 'assistant') return state
|
|
993
1110
|
if (state.modal.step === 'worktree-create') {
|
|
994
|
-
const
|
|
995
|
-
|
|
1111
|
+
const cycle: Record<
|
|
1112
|
+
'worktree-name' | 'branch-name' | 'base',
|
|
1113
|
+
typeof state.modal.activeField
|
|
1114
|
+
> = {
|
|
1115
|
+
'base': 'worktree-name',
|
|
1116
|
+
'branch-name': 'base',
|
|
1117
|
+
'worktree-name': 'branch-name',
|
|
1118
|
+
}
|
|
1119
|
+
const nextField =
|
|
1120
|
+
state.modal.activeField === 'worktree-name' ||
|
|
1121
|
+
state.modal.activeField === 'branch-name' ||
|
|
1122
|
+
state.modal.activeField === 'base'
|
|
1123
|
+
? cycle[state.modal.activeField]
|
|
1124
|
+
: 'worktree-name'
|
|
1125
|
+
if (nextField === 'base') {
|
|
1126
|
+
// Highlight the row matching the resolved base ref when entering it.
|
|
1127
|
+
const currentBaseRef = state.modal.baseRef
|
|
1128
|
+
const options = getNewTabBaseOptions(state)
|
|
1129
|
+
const baseIndex = Math.max(
|
|
1130
|
+
0,
|
|
1131
|
+
options.findIndex((option) => option.ref === currentBaseRef)
|
|
1132
|
+
)
|
|
1133
|
+
return {
|
|
1134
|
+
...state,
|
|
1135
|
+
modal: {
|
|
1136
|
+
...state.modal,
|
|
1137
|
+
activeField: nextField,
|
|
1138
|
+
cursorPos: state.modal.baseQuery.length,
|
|
1139
|
+
selectedIndex: baseIndex,
|
|
1140
|
+
},
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
996
1143
|
const nextValue =
|
|
997
1144
|
nextField === 'branch-name' ? state.modal.branchName : state.modal.worktreeName
|
|
998
1145
|
return {
|