@brimveyn/aimux 1.14.16 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.16",
3
+ "version": "1.15.0",
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.10",
63
+ "@brimveyn/aimux-config": "0.6.11",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -19,7 +19,7 @@ import type { ThemeId } from '../ui/themes'
19
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
- import { moveWorktree } from '../git/move-worktree'
22
+ import { countDirtyFiles, moveWorktree } from '../git/move-worktree'
23
23
  import {
24
24
  createGitWorktree,
25
25
  deleteGitBranch,
@@ -784,7 +784,9 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
784
784
  effect.sessionId,
785
785
  effect.sourceWorktreeId,
786
786
  effect.targetWorktreeId,
787
- effect.deleteSource === true
787
+ effect.deleteSource === true,
788
+ effect.stashTarget === true,
789
+ effect.keepConflicts === true
788
790
  )
789
791
  )
790
792
  } catch (error) {
@@ -793,6 +795,22 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
793
795
  })()
794
796
  return
795
797
  }
798
+ case 'load-worktree-move-stats': {
799
+ void (async () => {
800
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
801
+ const worktrees = session?.worktrees ?? []
802
+ if (worktrees.length === 0) return
803
+ const counts = await Promise.all(
804
+ worktrees.map(async (worktree) => [worktree.id, await countDirtyFiles(worktree.path)])
805
+ )
806
+ if (ctx.getState().modal.type !== 'worktree-move') return
807
+ ctx.dispatch({
808
+ dirtyFiles: Object.fromEntries(counts),
809
+ type: 'set-worktree-move-stats',
810
+ })
811
+ })()
812
+ return
813
+ }
796
814
  case 'open-rename-selected-session': {
797
815
  openSelectedSessionRename(ctx)
798
816
  return
@@ -1648,7 +1666,9 @@ async function runMoveWorktree(
1648
1666
  sessionId: string,
1649
1667
  sourceWorktreeId: string,
1650
1668
  targetWorktreeId: string,
1651
- deleteSource: boolean
1669
+ deleteSource: boolean,
1670
+ stashTarget: boolean,
1671
+ keepConflicts: boolean
1652
1672
  ): Promise<void> {
1653
1673
  const session = ctx.state.sessions.find((entry) => entry.id === sessionId)
1654
1674
  const source = session?.worktrees?.find((entry) => entry.id === sourceWorktreeId)
@@ -1666,20 +1686,38 @@ async function runMoveWorktree(
1666
1686
  const sourceLabel = source.branch ?? source.name
1667
1687
  const targetLabel = target.branch ?? target.name
1668
1688
  const result = await moveWorktree({
1689
+ keepConflicts,
1669
1690
  sourceBranch: source.branch,
1670
1691
  sourcePath: source.path,
1692
+ stashTarget,
1671
1693
  targetPath: target.path,
1672
1694
  })
1673
1695
 
1674
- // Toasts surface the outcome everywhere the picker can be opened outside git
1675
- // mode (from a tab menu), where the git-pane message would never be seen.
1676
- if (result.kind === 'dirty-target') {
1677
- toast.warning(`Target ${targetLabel} has uncommitted changes commit or stash it first`)
1696
+ // Recoverable failures open a confirm dialog carrying retry params; both
1697
+ // worktrees are already back in their original state, so confirming simply
1698
+ // re-dispatches move-worktree with the matching flag.
1699
+ if (result.kind === 'needs-stash' || result.kind === 'conflict') {
1700
+ ctx.dispatch({
1701
+ deleteSource,
1702
+ files: result.files,
1703
+ sessionId,
1704
+ sourceLabel,
1705
+ sourceWorktreeId,
1706
+ targetLabel,
1707
+ targetWorktreeId,
1708
+ type: 'open-worktree-move-confirm',
1709
+ variant: result.kind === 'needs-stash' ? 'stash-target' : 'keep-conflicts',
1710
+ })
1678
1711
  return
1679
1712
  }
1680
- if (result.kind === 'conflict') {
1713
+ if (result.kind === 'conflict-kept') {
1714
+ // Never delete the source here — its work only landed half-resolved. The
1715
+ // auto-commit driver is safe against this state: git refuses to commit
1716
+ // with unmerged index entries, so it fails loudly instead of committing
1717
+ // conflict markers.
1718
+ handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1681
1719
  toast.warning(
1682
- `Move hit conflicts in ${result.files.length} file(s) — left ${sourceLabel} untouched`
1720
+ `Left conflict markers in ${targetLabel} (${result.files.length} file(s))resolve & commit there; ${sourceLabel} kept`
1683
1721
  )
1684
1722
  return
1685
1723
  }
@@ -1702,8 +1740,11 @@ async function runMoveWorktree(
1702
1740
  } else {
1703
1741
  handleSwitchWorktree({ ...ctx, state: ctx.getState() }, sessionId, targetWorktreeId)
1704
1742
  }
1743
+ const stashNote = result.stashedTarget
1744
+ ? ` · target's previous changes stashed (recover with git stash pop)`
1745
+ : ''
1705
1746
  toast.success(
1706
- `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit`
1747
+ `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit${stashNote}`
1707
1748
  )
1708
1749
  }
1709
1750
 
@@ -1,15 +1,36 @@
1
1
  import { $ } from 'bun'
2
2
 
3
+ export interface MoveWorktreeOptions {
4
+ sourcePath: string
5
+ sourceBranch: string
6
+ targetPath: string
7
+ /** Stash the target's uncommitted (incl. untracked) changes before merging. The stash is kept. */
8
+ stashTarget?: boolean
9
+ /** On conflict, leave the conflicted squash state in the target for manual resolution. */
10
+ keepConflicts?: boolean
11
+ }
12
+
3
13
  export type MoveWorktreeResult =
4
- | { kind: 'ok'; filesChanged: number }
5
- | { kind: 'dirty-target' }
14
+ | { kind: 'ok'; filesChanged: number; stashedTarget: boolean }
15
+ /** Target dirty/untracked files would be overwritten by the move; nothing was touched. */
16
+ | { kind: 'needs-stash'; files: string[] }
17
+ /** Conflict; target and source fully restored. */
6
18
  | { kind: 'conflict'; files: string[] }
19
+ /** Conflict markers left in the target on purpose; source restored. */
20
+ | { kind: 'conflict-kept'; files: string[] }
7
21
  | { kind: 'error'; message: string }
8
22
 
9
- async function workingTreeDirty(repoPath: string): Promise<boolean> {
23
+ export async function countDirtyFiles(repoPath: string): Promise<number> {
10
24
  const result = await $`git -C ${repoPath} status --porcelain`.quiet().nothrow()
11
- if (result.exitCode !== 0) return false
12
- return result.text().trim() !== ''
25
+ if (result.exitCode !== 0) return 0
26
+ return result
27
+ .text()
28
+ .split('\n')
29
+ .filter((line) => line.trim() !== '').length
30
+ }
31
+
32
+ async function workingTreeDirty(repoPath: string): Promise<boolean> {
33
+ return (await countDirtyFiles(repoPath)) > 0
13
34
  }
14
35
 
15
36
  // Files left with conflict markers by a failed squash (unmerged index entries).
@@ -32,27 +53,63 @@ async function countStaged(repoPath: string): Promise<number> {
32
53
  .filter((line) => line.trim() !== '').length
33
54
  }
34
55
 
56
+ // Git refuses a merge up front (working tree untouched) when local changes
57
+ // overlap the incoming ones, listing the files tab-indented under either
58
+ // "Your local changes to the following files would be overwritten by merge:"
59
+ // or "The following untracked working tree files would be overwritten by merge:".
60
+ function parseOverwrittenFiles(output: string): string[] {
61
+ const files: string[] = []
62
+ let collecting = false
63
+ for (const line of output.split('\n')) {
64
+ if (line.includes('would be overwritten by merge')) {
65
+ collecting = true
66
+ continue
67
+ }
68
+ if (!collecting) continue
69
+ if (line.startsWith('\t')) {
70
+ const file = line.trim()
71
+ if (file !== '') files.push(file)
72
+ } else {
73
+ collecting = false
74
+ }
75
+ }
76
+ return files
77
+ }
78
+
35
79
  // Squashes everything a source worktree changed since its fork point into the
36
80
  // target worktree's working tree (staged, uncommitted) — committed work plus
37
81
  // any uncommitted/untracked changes. The source is left exactly as it was; the
38
- // caller decides whether to delete it. The target must be clean so two change
39
- // sets are never silently fused. On conflict nothing is kept: the target is
40
- // reset and the source restored.
41
- export async function moveWorktree(opts: {
42
- sourcePath: string
43
- sourceBranch: string
44
- targetPath: string
45
- }): Promise<MoveWorktreeResult> {
82
+ // caller decides whether to delete it. The target may be dirty as long as its
83
+ // local changes don't overlap the incoming ones: on overlap the move stops
84
+ // before touching anything (needs-stash) unless stashTarget is set. On
85
+ // conflict nothing is kept — target reset, source restored — unless
86
+ // keepConflicts is set, in which case the conflicted squash state is left in
87
+ // the target for manual resolution.
88
+ export async function moveWorktree(opts: MoveWorktreeOptions): Promise<MoveWorktreeResult> {
46
89
  const { sourceBranch, sourcePath, targetPath } = opts
47
90
  try {
48
- if (await workingTreeDirty(targetPath)) return { kind: 'dirty-target' }
49
-
50
91
  const headResult = await $`git -C ${sourcePath} rev-parse HEAD`.quiet().nothrow()
51
92
  if (headResult.exitCode !== 0) {
52
93
  return { kind: 'error', message: 'could not resolve source HEAD' }
53
94
  }
54
95
  const sourceHead = headResult.text().trim()
55
96
 
97
+ let stashedTarget = false
98
+ if (opts.stashTarget === true && (await workingTreeDirty(targetPath))) {
99
+ const stashMessage = `aimux: backup before move from ${sourceBranch}`
100
+ const stash = await $`git -C ${targetPath} stash push --include-untracked -m ${stashMessage}`
101
+ .quiet()
102
+ .nothrow()
103
+ if (stash.exitCode !== 0) {
104
+ const message = stash.stderr.toString().trim()
105
+ return {
106
+ kind: 'error',
107
+ message: message !== '' ? message : 'failed to stash target changes',
108
+ }
109
+ }
110
+ stashedTarget = true
111
+ }
112
+
56
113
  // Capture uncommitted + untracked work in a throwaway commit so the squash
57
114
  // includes everything; undone again before we return.
58
115
  const tempCommitted = await workingTreeDirty(sourcePath)
@@ -79,8 +136,21 @@ export async function moveWorktree(opts: {
79
136
  const merge = await $`git -C ${targetPath} merge --squash ${sourceBranch}`.quiet().nothrow()
80
137
  const conflicts = await conflictedFiles(targetPath)
81
138
  if (merge.exitCode !== 0 || conflicts.length > 0) {
139
+ if (conflicts.length > 0 && opts.keepConflicts === true) {
140
+ await restoreSource()
141
+ return { files: conflicts, kind: 'conflict-kept' }
142
+ }
143
+ if (conflicts.length === 0) {
144
+ const overwritten = parseOverwrittenFiles(merge.stderr.toString() + merge.stdout.toString())
145
+ if (overwritten.length > 0) {
146
+ // Git refused up front — the target working tree was never touched.
147
+ await restoreSource()
148
+ return { files: overwritten, kind: 'needs-stash' }
149
+ }
150
+ }
82
151
  await $`git -C ${targetPath} merge --abort`.quiet().nothrow()
83
- await $`git -C ${targetPath} reset --hard HEAD`.quiet().nothrow()
152
+ // --merge (not --hard): unrelated dirty files in the target must survive.
153
+ await $`git -C ${targetPath} reset --merge HEAD`.quiet().nothrow()
84
154
  await restoreSource()
85
155
  if (conflicts.length > 0) return { files: conflicts, kind: 'conflict' }
86
156
  const message = merge.stderr.toString().trim()
@@ -89,7 +159,7 @@ export async function moveWorktree(opts: {
89
159
 
90
160
  const filesChanged = await countStaged(targetPath)
91
161
  await restoreSource()
92
- return { filesChanged, kind: 'ok' }
162
+ return { filesChanged, kind: 'ok', stashedTarget }
93
163
  } catch (error) {
94
164
  return { kind: 'error', message: error instanceof Error ? error.message : String(error) }
95
165
  }
@@ -28,6 +28,7 @@ const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
28
28
  'update-available': 'modal.update-available',
29
29
  'worktree-delete-confirm': 'modal.worktree-delete-confirm',
30
30
  'worktree-move': 'modal.worktree-move',
31
+ 'worktree-move-confirm': 'modal.worktree-move-confirm',
31
32
  }
32
33
 
33
34
  export function deriveModeId(state: AppState): ModeId {
@@ -25,6 +25,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
25
25
  'modal.update-available': ['navigation'],
26
26
  'modal.worktree-delete-confirm': ['navigation'],
27
27
  'modal.worktree-move': ['git-mode', 'navigation'],
28
+ 'modal.worktree-move-confirm': ['navigation'],
28
29
  'navigation': [
29
30
  'terminal-input',
30
31
  'modal.new-tab.command-edit',
@@ -37,6 +38,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
37
38
  'modal.update-available',
38
39
  'modal.ai-usage',
39
40
  'modal.worktree-delete-confirm',
41
+ 'modal.worktree-move-confirm',
40
42
  'git-mode',
41
43
  ],
42
44
  'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
@@ -25,6 +25,7 @@ export type ModeId =
25
25
  | 'modal.git-commit.generating'
26
26
  | 'modal.update-available'
27
27
  | 'modal.worktree-move'
28
+ | 'modal.worktree-move-confirm'
28
29
  | 'modal.ai-usage'
29
30
 
30
31
  export type SideEffect =
@@ -92,7 +93,11 @@ export type SideEffect =
92
93
  sourceWorktreeId: string
93
94
  targetWorktreeId: string
94
95
  deleteSource?: boolean
96
+ // Retry flags set by the worktree-move-confirm dialog.
97
+ stashTarget?: boolean
98
+ keepConflicts?: boolean
95
99
  }
100
+ | { type: 'load-worktree-move-stats' }
96
101
  | { type: 'toggle-transparent' }
97
102
  | { type: 'toggle-mode' }
98
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
  }
@@ -304,6 +304,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
304
304
  selectedIndex: 0,
305
305
  sessionTargetId: null,
306
306
  sourceWorktreeId: action.sourceWorktreeId,
307
+ stats: { kind: 'loading' },
307
308
  type: 'worktree-move',
308
309
  },
309
310
  }
@@ -315,6 +316,33 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
315
316
  modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
316
317
  }
317
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
+ }
318
346
  case 'open-worktree-delete-confirm': {
319
347
  return {
320
348
  ...state,
@@ -47,6 +47,7 @@ export type ModalType =
47
47
  | 'update-available'
48
48
  | 'ai-usage'
49
49
  | 'worktree-move'
50
+ | 'worktree-move-confirm'
50
51
  | 'worktree-delete-confirm'
51
52
  | null
52
53
 
@@ -72,12 +73,22 @@ export interface TerminalLine {
72
73
  spans: TerminalSpan[]
73
74
  }
74
75
 
76
+ /** DECSCUSR cursor shape; 'default' restores the host terminal's configured cursor. */
77
+ export type TerminalCursorStyle = 'block' | 'underline' | 'bar' | 'default'
78
+
75
79
  export interface TerminalSnapshot {
76
80
  lines: TerminalLine[]
77
81
  tailLines?: TerminalLine[]
78
82
  viewportY: number
79
83
  baseY: number
80
84
  cursorVisible: boolean
85
+ cursorStyle?: TerminalCursorStyle
86
+ /** Blink flag from DECSCUSR; undefined means "host terminal's default". */
87
+ cursorBlink?: boolean
88
+ /** Cursor row relative to the rendered viewport; outside [0, rows) when
89
+ * the user scrolled the viewport away from the active screen. */
90
+ cursorRow?: number
91
+ cursorCol?: number
81
92
  }
82
93
 
83
94
  // The scroll position is owned end-to-end by the backend emulator; this type
@@ -421,6 +432,26 @@ export interface ModalWorktreeMove extends ModalBase {
421
432
  /** The worktree being moved (may differ from the active one, e.g. a tab menu). */
422
433
  sourceWorktreeId: string
423
434
  deleteSource: boolean
435
+ /** Per-worktree dirty file counts, loaded async when the modal opens. */
436
+ stats: { kind: 'loading' } | { kind: 'ready'; dirtyFiles: Record<string, number> }
437
+ }
438
+
439
+ /**
440
+ * Confirmation for a recoverable move failure: the target's dirty files
441
+ * overlap the incoming changes (stash-target) or the squash conflicts
442
+ * (keep-conflicts). Both worktrees are already restored; confirming re-runs
443
+ * move-worktree with the matching flag.
444
+ */
445
+ export interface ModalWorktreeMoveConfirm extends ModalBase {
446
+ type: 'worktree-move-confirm'
447
+ variant: 'stash-target' | 'keep-conflicts'
448
+ files: string[]
449
+ sessionId: string
450
+ sourceWorktreeId: string
451
+ targetWorktreeId: string
452
+ deleteSource: boolean
453
+ sourceLabel: string
454
+ targetLabel: string
424
455
  }
425
456
 
426
457
  /**
@@ -462,6 +493,7 @@ export type ModalState =
462
493
  | ModalUpdateAvailable
463
494
  | ModalAIUsage
464
495
  | ModalWorktreeMove
496
+ | ModalWorktreeMoveConfirm
465
497
  | ModalWorktreeDeleteConfirm
466
498
 
467
499
  export interface LayoutState {
@@ -576,6 +608,18 @@ export type ModalAction =
576
608
  | { type: 'open-ai-usage-modal' }
577
609
  | { type: 'open-worktree-move-modal'; sourceWorktreeId: string }
578
610
  | { type: 'toggle-worktree-move-delete' }
611
+ | { type: 'set-worktree-move-stats'; dirtyFiles: Record<string, number> }
612
+ | {
613
+ type: 'open-worktree-move-confirm'
614
+ variant: 'stash-target' | 'keep-conflicts'
615
+ files: string[]
616
+ sessionId: string
617
+ sourceWorktreeId: string
618
+ targetWorktreeId: string
619
+ deleteSource: boolean
620
+ sourceLabel: string
621
+ targetLabel: string
622
+ }
579
623
  | {
580
624
  type: 'open-worktree-delete-confirm'
581
625
  sessionId: string
@@ -45,6 +45,10 @@ function isTerminalLine(value: unknown): value is TerminalLine {
45
45
  })
46
46
  }
47
47
 
48
+ function isTerminalCursorStyle(value: unknown): boolean {
49
+ return value === 'block' || value === 'underline' || value === 'bar' || value === 'default'
50
+ }
51
+
48
52
  function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
49
53
  return (
50
54
  isObjectRecord(value) &&
@@ -52,7 +56,11 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
52
56
  value.lines.every(isTerminalLine) &&
53
57
  isFiniteNumber(value.viewportY) &&
54
58
  isFiniteNumber(value.baseY) &&
55
- isBoolean(value.cursorVisible)
59
+ isBoolean(value.cursorVisible) &&
60
+ (value.cursorStyle === undefined || isTerminalCursorStyle(value.cursorStyle)) &&
61
+ (value.cursorBlink === undefined || isBoolean(value.cursorBlink)) &&
62
+ (value.cursorRow === undefined || isFiniteNumber(value.cursorRow)) &&
63
+ (value.cursorCol === undefined || isFiniteNumber(value.cursorCol))
56
64
  )
57
65
  }
58
66
 
@@ -170,11 +170,13 @@ export function TabItem({
170
170
  ? [
171
171
  [
172
172
  'Move worktree',
173
- () =>
173
+ () => {
174
174
  dispatchGlobal({
175
175
  sourceWorktreeId: moveWorktreeId,
176
176
  type: 'open-worktree-move-modal',
177
- }),
177
+ })
178
+ runSideEffectGlobal({ type: 'load-worktree-move-stats' })
179
+ },
178
180
  ] as [string, () => void],
179
181
  ]
180
182
  : []),
@@ -1,10 +1,17 @@
1
- import type { MouseEvent as OtuiMouseEvent, TextRenderable } from '@opentui/core'
1
+ import type { CursorStyle, MouseEvent as OtuiMouseEvent, TextRenderable } from '@opentui/core'
2
2
 
3
- import { memo, type ReactNode, useCallback, useMemo } from 'react'
3
+ import { useRenderer } from '@opentui/react'
4
+ import { memo, type ReactNode, useCallback, useEffect, useMemo } from 'react'
4
5
 
5
6
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
6
7
  import type { JunctionEdgeInfo, JunctionEdges } from '../../../state/layout-tree'
7
- import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
8
+ import type {
9
+ FocusMode,
10
+ TabSession,
11
+ TerminalCursorStyle,
12
+ TerminalSnapshot,
13
+ TerminalSpan,
14
+ } from '../../../state/types'
8
15
 
9
16
  import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
10
17
  import { logInputDebug } from '../../../debug/input-log'
@@ -69,7 +76,7 @@ function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMo
69
76
  return focusMode === 'terminal-input' ? t.accent : t.primary
70
77
  }
71
78
 
72
- function renderSpan(span: TerminalSpan, key: string): ReactNode {
79
+ function renderSpan(span: TerminalSpan, key: string, softCursor: boolean): ReactNode {
73
80
  let node: ReactNode = span.text
74
81
 
75
82
  if (span.underline === true) {
@@ -86,11 +93,20 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
86
93
 
87
94
  // Palette indices are resolved here (not in the daemon) so they pick up
88
95
  // the host terminal's actual ANSI palette queried at startup.
89
- const fg =
96
+ let fg =
90
97
  span.fgPalette !== undefined
91
98
  ? resolvePaletteIndex(span.fgPalette)
92
99
  : (span.fg ?? getCurrentTheme().text)
93
- const bg = span.bgPalette !== undefined ? resolvePaletteIndex(span.bgPalette) : span.bg
100
+ let bg = span.bgPalette !== undefined ? resolvePaletteIndex(span.bgPalette) : span.bg
101
+
102
+ // Soft cursor: inverted block drawn in the cell grid. Used only when the
103
+ // host terminal's hardware cursor is not parked on this pane (inactive
104
+ // pane, navigation mode) — otherwise both would show at once.
105
+ if (span.cursor === true && softCursor) {
106
+ const resolvedBg = bg ?? getCurrentTheme().background
107
+ bg = fg
108
+ fg = resolvedBg
109
+ }
94
110
 
95
111
  return (
96
112
  <span key={key} fg={fg} bg={bg}>
@@ -102,6 +118,80 @@ function renderSpan(span: TerminalSpan, key: string): ReactNode {
102
118
  interface TerminalViewportProps {
103
119
  viewport: TerminalSnapshot | undefined
104
120
  buffer: string
121
+ softCursor: boolean
122
+ }
123
+
124
+ const OPENTUI_CURSOR_STYLES: Record<TerminalCursorStyle, CursorStyle> = {
125
+ bar: 'line',
126
+ block: 'block',
127
+ default: 'default',
128
+ underline: 'underline',
129
+ }
130
+
131
+ /**
132
+ * Parks the host terminal's hardware cursor on this pane's cursor cell so the
133
+ * shape requested by the running program via DECSCUSR (nvim's insert bar, the
134
+ * shell's configured cursor, blinking) shows through, instead of the soft
135
+ * inverted block. Returns whether the hardware cursor is currently shown.
136
+ */
137
+ function useHardwareCursor(
138
+ viewport: TerminalSnapshot | undefined,
139
+ contentOrigin: TerminalContentOrigin,
140
+ active: boolean
141
+ ): boolean {
142
+ const renderer = useRenderer()
143
+ const rows = viewport?.lines.length ?? 0
144
+ const cursorRow = viewport?.cursorRow
145
+ const cursorCol = viewport?.cursorCol
146
+ // cursorRow leaves [0, rows) when the user scrolls the viewport away from
147
+ // the active screen — the hardware cursor must vanish with the cell. The
148
+ // contentOrigin bound matters separately: right after a resize the snapshot
149
+ // can be larger than the pane box, and a cursor parked past the border
150
+ // would render as a stray glyph over neighbouring UI.
151
+ const show =
152
+ active &&
153
+ viewport !== undefined &&
154
+ viewport.cursorVisible &&
155
+ cursorRow !== undefined &&
156
+ cursorRow >= 0 &&
157
+ cursorRow < rows &&
158
+ cursorRow < contentOrigin.rows &&
159
+ cursorCol !== undefined &&
160
+ cursorCol < contentOrigin.cols
161
+
162
+ useEffect(() => {
163
+ if (!show || viewport === undefined || cursorRow === undefined || cursorCol === undefined) {
164
+ return
165
+ }
166
+ // Native cursor coordinates are 1-indexed (same convention as opentui's
167
+ // editor renderable). `viewport` is a dependency on purpose: every new
168
+ // snapshot re-asserts the position, so a modal input blurring (which
169
+ // hides the cursor) can never leave it lost for long.
170
+ renderer.setCursorPosition(
171
+ contentOrigin.x + cursorCol + 1,
172
+ contentOrigin.y + cursorRow + 1,
173
+ true
174
+ )
175
+ renderer.setCursorStyle({
176
+ blinking: viewport.cursorBlink,
177
+ style: OPENTUI_CURSOR_STYLES[viewport.cursorStyle ?? 'default'],
178
+ })
179
+ // Cursor state only reaches the terminal with the next rendered frame
180
+ // (same reason opentui's editor calls requestRender() in focus/blur).
181
+ // Without it, a hide issued while the UI is static — e.g. switching to
182
+ // a workspace with no live PTY — never flushes and the host cursor
183
+ // stays stranded at its old position.
184
+ renderer.requestRender()
185
+ // React runs all cleanups in a commit before all effects, so when focus
186
+ // moves between panes the releasing pane always hides before the gaining
187
+ // pane shows — no stomp regardless of tree order.
188
+ return () => {
189
+ renderer.setCursorPosition(0, 0, false)
190
+ renderer.requestRender()
191
+ }
192
+ }, [contentOrigin, cursorCol, cursorRow, renderer, show, viewport])
193
+
194
+ return show
105
195
  }
106
196
 
107
197
  const NOOP = (): void => {}
@@ -127,6 +217,7 @@ const pinTerminalScroll = (node: TextRenderable | null): void => {
127
217
 
128
218
  const TerminalViewport = memo(function TerminalViewport({
129
219
  buffer,
220
+ softCursor,
130
221
  viewport,
131
222
  }: TerminalViewportProps) {
132
223
  const t = useTheme()
@@ -138,7 +229,7 @@ const TerminalViewport = memo(function TerminalViewport({
138
229
  // Terminal rows are a fixed positional grid; the row index is the identity.
139
230
  // eslint-disable-next-line react/no-array-index-key
140
231
  <span key={`line-${lineIndex}`}>
141
- {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`))}
232
+ {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`, softCursor))}
142
233
  {lineIndex < lines.length - 1 ? '\n' : ''}
143
234
  </span>
144
235
  ))}
@@ -178,6 +269,15 @@ export function TerminalPane({
178
269
  const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
179
270
  const editorBg = t.background
180
271
  const paneIsActive = isActive ?? true
272
+ // Restored/disconnected tabs carry a frozen snapshot whose persisted
273
+ // cursorVisible/cursorRow/cursorCol never update again — parking the
274
+ // hardware cursor there would leave a stray blinking cursor at a stale
275
+ // position. Only live PTYs get the hardware cursor.
276
+ const showHardwareCursor = useHardwareCursor(
277
+ tab?.viewport,
278
+ contentOrigin,
279
+ paneIsActive && focusMode === 'terminal-input' && tab?.status === 'running'
280
+ )
181
281
  // These are only used when this pane is rendered without a tab (the
182
282
  // top-level pane on a worktree with zero tabs). Selectors return plain
183
283
  // strings so re-renders are cheap and bounded to actual name changes.
@@ -483,7 +583,11 @@ export function TerminalPane({
483
583
  onMouseDrag={forwardMouseEvent}
484
584
  onMouseScroll={forwardScrollEvent}
485
585
  >
486
- <TerminalViewport viewport={tab.viewport} buffer={tab.buffer} />
586
+ <TerminalViewport
587
+ viewport={tab.viewport}
588
+ buffer={tab.buffer}
589
+ softCursor={!showHardwareCursor}
590
+ />
487
591
  </box>
488
592
  )}
489
593
  </ContextMenuBox>
@@ -0,0 +1,65 @@
1
+ import { useTheme } from '../../../theme'
2
+ import { uiTokens } from '../../../ui-tokens'
3
+ import { Form } from '../shared/form'
4
+
5
+ interface WorktreeMoveConfirmModalProps {
6
+ variant: 'stash-target' | 'keep-conflicts'
7
+ files: string[]
8
+ sourceLabel: string
9
+ targetLabel: string
10
+ }
11
+
12
+ const MAX_LISTED_FILES = 8
13
+
14
+ /**
15
+ * Confirmation dialog after a recoverable move failure. Both worktrees are
16
+ * already back in their original state; confirming re-runs the move with the
17
+ * flag matching the variant (stash the target's changes / keep the conflict
18
+ * markers in the target).
19
+ */
20
+ export function WorktreeMoveConfirmModal({
21
+ files,
22
+ sourceLabel,
23
+ targetLabel,
24
+ variant,
25
+ }: WorktreeMoveConfirmModalProps) {
26
+ const t = useTheme()
27
+ const listed = files.slice(0, MAX_LISTED_FILES)
28
+ const remaining = files.length - listed.length
29
+ const isStash = variant === 'stash-target'
30
+ return (
31
+ <Form
32
+ title={isStash ? 'Target has conflicting changes' : 'Move hit conflicts'}
33
+ keybindsModeId="modal.worktree-move-confirm"
34
+ width={uiTokens.modalWidth.md}
35
+ footer={
36
+ <text fg={t.textMuted}>
37
+ {isStash
38
+ ? 'Enter / y to stash & move · Esc / n to cancel'
39
+ : 'Enter / y to keep markers · Esc / n to cancel'}
40
+ </text>
41
+ }
42
+ >
43
+ <box flexDirection="column" gap={1}>
44
+ <text fg={t.text}>
45
+ {isStash
46
+ ? `${targetLabel} has uncommitted changes that the move would overwrite:`
47
+ : `Merging ${sourceLabel} into ${targetLabel} conflicts in ${files.length} file(s):`}
48
+ </text>
49
+ <box flexDirection="column">
50
+ {listed.map((file) => (
51
+ <text key={file} fg={t.warning} wrapMode="none">
52
+ {file}
53
+ </text>
54
+ ))}
55
+ {remaining > 0 ? <text fg={t.textMuted}>+{remaining} more</text> : null}
56
+ </box>
57
+ <text fg={t.text}>
58
+ {isStash
59
+ ? 'Stash them and continue? The stash is kept — recover with git stash pop.'
60
+ : `Keep conflict markers in ${targetLabel} for manual resolution? ${sourceLabel} stays untouched either way.`}
61
+ </text>
62
+ </box>
63
+ </Form>
64
+ )
65
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo } from 'react'
2
2
 
3
- import type { WorktreeRecord } from '../../../../state/types'
3
+ import type { ModalWorktreeMove, WorktreeRecord } from '../../../../state/types'
4
4
 
5
5
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
6
6
  import { formatDivergence } from '../../../../state/session-worktrees'
@@ -14,6 +14,7 @@ interface WorktreeMoveModalProps {
14
14
  divergence: Record<string, { ahead: number; behind: number }>
15
15
  selectedIndex: number
16
16
  sourceWorktreeId: string
17
+ stats: ModalWorktreeMove['stats']
17
18
  worktrees: WorktreeRecord[]
18
19
  }
19
20
 
@@ -29,6 +30,7 @@ export function WorktreeMoveModal({
29
30
  divergence,
30
31
  selectedIndex,
31
32
  sourceWorktreeId,
33
+ stats,
32
34
  worktrees,
33
35
  }: WorktreeMoveModalProps) {
34
36
  const t = useTheme()
@@ -42,6 +44,18 @@ export function WorktreeMoveModal({
42
44
  )
43
45
  const sourceLabel =
44
46
  source?.branch != null && source.branch !== '' ? source.branch : (source?.name ?? 'worktree')
47
+ // What the move would carry: commits ahead of the fork point (when known) plus
48
+ // uncommitted files, loaded async after the modal opens.
49
+ const sourcePreview = useMemo(() => {
50
+ const parts: string[] = []
51
+ const ahead = divergence[sourceWorktreeId]?.ahead ?? 0
52
+ if (ahead > 0) parts.push(`${ahead} commit(s)`)
53
+ if (stats.kind === 'ready') {
54
+ const dirty = stats.dirtyFiles[sourceWorktreeId] ?? 0
55
+ if (dirty > 0) parts.push(`${dirty} uncommitted file(s)`)
56
+ }
57
+ return parts.join(' · ')
58
+ }, [divergence, sourceWorktreeId, stats])
45
59
  const handleSelectIndex = useCallback(
46
60
  (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
47
61
  []
@@ -71,6 +85,7 @@ export function WorktreeMoveModal({
71
85
  }
72
86
  >
73
87
  <box flexDirection="column" marginTop={1}>
88
+ {sourcePreview !== '' ? <text fg={t.textMuted}>will move: {sourcePreview}</text> : null}
74
89
  {targets.length === 0 ? (
75
90
  <text fg={t.textMuted}>No other worktree to move into.</text>
76
91
  ) : (
@@ -79,6 +94,7 @@ export function WorktreeMoveModal({
79
94
  const label =
80
95
  worktree.branch != null && worktree.branch !== '' ? worktree.branch : worktree.name
81
96
  const ahead = formatDivergence(divergence[worktree.id])
97
+ const dirty = stats.kind === 'ready' && (stats.dirtyFiles[worktree.id] ?? 0) > 0
82
98
  return (
83
99
  <ListItem
84
100
  key={worktree.id}
@@ -92,6 +108,7 @@ export function WorktreeMoveModal({
92
108
  {label}
93
109
  {worktree.source === 'primary' ? ' (primary)' : ''}
94
110
  {ahead !== '' ? ` ${ahead}` : ''}
111
+ {dirty ? <span fg={t.warning}> ●</span> : null}
95
112
  </text>
96
113
  }
97
114
  />
package/src/ui/root.tsx CHANGED
@@ -39,6 +39,7 @@ import { SnippetEditorModal } from './components/modals/snippets/snippet-editor-
39
39
  import { SnippetPickerModal } from './components/modals/snippets/snippet-picker-modal'
40
40
  import { NewTabModal } from './components/modals/tabs/new-tab-modal'
41
41
  import { ThemePickerModal } from './components/modals/themes/theme-picker-modal'
42
+ import { WorktreeMoveConfirmModal } from './components/modals/worktree/worktree-move-confirm-modal'
42
43
  import { WorktreeMoveModal } from './components/modals/worktree/worktree-move-modal'
43
44
  import { ContextMenuBox } from './components/overlays/context-menu/context-menu-box'
44
45
  import { ContextMenuOverlay } from './components/overlays/context-menu/context-menu-overlay'
@@ -214,10 +215,20 @@ function renderModal(
214
215
  divergence={options.worktreeDivergence}
215
216
  selectedIndex={modal.selectedIndex}
216
217
  sourceWorktreeId={modal.sourceWorktreeId}
218
+ stats={modal.stats}
217
219
  worktrees={session?.worktrees ?? EMPTY_WORKTREES}
218
220
  />
219
221
  )
220
222
  }
223
+ case 'worktree-move-confirm':
224
+ return (
225
+ <WorktreeMoveConfirmModal
226
+ variant={modal.variant}
227
+ files={modal.files}
228
+ sourceLabel={modal.sourceLabel}
229
+ targetLabel={modal.targetLabel}
230
+ />
231
+ )
221
232
  case 'help':
222
233
  return (
223
234
  <HelpModal
@@ -133,6 +133,8 @@ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null
133
133
  return 'modal.update-available'
134
134
  case 'worktree-move':
135
135
  return 'modal.worktree-move'
136
+ case 'worktree-move-confirm':
137
+ return 'modal.worktree-move-confirm'
136
138
  default:
137
139
  return null
138
140
  }