@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.
@@ -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
- | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
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
 
@@ -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
+ }
@@ -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 { ScrollIntent, TerminalModeState, TerminalSnapshot } from '../state/types'
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, session.cursorVisible)
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 pty = spawn(options.command, options.args ?? [], {
263
- cols: options.cols,
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 { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
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 function snapshotTerminal(terminal: Terminal, cursorVisible = true): TerminalSnapshot {
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
- worktreeDeleteConfirmId: null,
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-state': {
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
- worktreeDeleteConfirmId: action.confirmWorktreeId ?? null,
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
- worktreeDeleteConfirmId: null,
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
- worktreeDeleteConfirmId: null,
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
- worktreeDeleteConfirmId: null,
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
- worktreeDeleteConfirmId: null,
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
- worktreeDeleteConfirmId: null,
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 nextField: typeof state.modal.activeField =
995
- state.modal.activeField === 'worktree-name' ? 'branch-name' : 'worktree-name'
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 {