@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.
@@ -18,15 +18,23 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
18
18
  const snapshot =
19
19
  action.workspaceSnapshot ??
20
20
  state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
21
- const restored = restoreWorkspaceState(state, snapshot, {
22
- forceDisconnected: action.forceDisconnected ?? true,
23
- })
24
21
  // The session's activeWorktreeId may have been patched right before
25
22
  // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
26
23
  // sets it to the worktree the user just clicked). The snapshot's
27
24
  // activeTabId still reflects the *last* worktree they were on, so
28
25
  // honor the patched worktree by filtering the restored tab list.
29
26
  const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
27
+ const loadedWorktrees = loadedSession?.worktrees ?? []
28
+ const restored = restoreWorkspaceState(state, snapshot, {
29
+ forceDisconnected: action.forceDisconnected ?? true,
30
+ // Drop tabs bound to worktrees this session no longer owns so a stale
31
+ // id can't be caught by a later worktree delete (closing "another
32
+ // worktree's" tabs) and so corrupted catalogs self-heal on load. Only
33
+ // prune once the session has worktrees — an empty list means it hasn't
34
+ // been initialized yet, and pruning then would wrongly drop bound tabs.
35
+ validWorktreeIds:
36
+ loadedWorktrees.length > 0 ? new Set(loadedWorktrees.map((w) => w.id)) : undefined,
37
+ })
30
38
  const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
31
39
  // Prefer the snapshot's tab; if it isn't visible under the active
32
40
  // worktree (e.g. a multi-worktree session restored onto a different
@@ -188,22 +196,6 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
188
196
  : withActiveWorktree(next, action.worktree.id)
189
197
  }),
190
198
  }
191
- case 'remove-worktree-record':
192
- return {
193
- ...state,
194
- sessions: state.sessions.map((session) => {
195
- if (session.id !== action.sessionId) return session
196
- const remaining = (session.worktrees ?? []).filter((w) => w.id !== action.worktreeId)
197
- if (remaining.length === 0) return session
198
- if (session.activeWorktreeId !== action.worktreeId) {
199
- return { ...session, updatedAt: new Date().toISOString(), worktrees: remaining }
200
- }
201
- return withActiveWorktree(
202
- { ...session, activeWorktreeId: remaining[0]?.id, worktrees: remaining },
203
- remaining[0]?.id ?? ''
204
- )
205
- }),
206
- }
207
199
  case 'set-active-worktree': {
208
200
  const sessions = state.sessions.map((session) =>
209
201
  session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
@@ -469,6 +469,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
469
469
  }
470
470
  return { ...state, tabs }
471
471
  }
472
+ case 'reorder-tabs': {
473
+ // Drag-and-drop reorder of the visible tab strip. `orderedTabIds` is the
474
+ // flattened new order of the currently-visible tabs (group members already
475
+ // expanded into contiguous runs). We only rewrite the slots those tabs
476
+ // occupy in `state.tabs`, leaving tabs from other worktrees anchored in
477
+ // place. Because group members arrive contiguous, splits stay intact.
478
+ const visibleSet = new Set(action.orderedTabIds)
479
+ const byId = new Map(state.tabs.map((tab) => [tab.id, tab]))
480
+ let cursor = 0
481
+ const tabs = state.tabs.map((tab) => {
482
+ if (!visibleSet.has(tab.id)) return tab
483
+ const nextId = action.orderedTabIds[cursor]
484
+ cursor++
485
+ return (nextId != null ? byId.get(nextId) : undefined) ?? tab
486
+ })
487
+ return { ...state, tabs }
488
+ }
472
489
  case 'reset-tab-session':
473
490
  return withActiveTabWorktree(
474
491
  {
@@ -1,5 +1,49 @@
1
1
  import type { AssistantOption } from '../pty/command-registry'
2
- import type { AssistantId, SessionRecord, SnippetRecord } from './types'
2
+ import type { AssistantId, SessionRecord, SnippetRecord, WorktreeRecord } from './types'
3
+
4
+ export interface BaseRefOption {
5
+ /** Git ref the new worktree is forked from. */
6
+ ref: string
7
+ label: string
8
+ kind: 'worktree' | 'branch'
9
+ /** Worktree name, for the 'worktree' kind. */
10
+ detail?: string
11
+ }
12
+
13
+ /**
14
+ * Ordered, filtered base-ref candidates for the worktree-create "Base" picker:
15
+ * branches checked out in the session's worktrees first (labelled with the
16
+ * worktree name), then the remaining local branches. A branch already surfaced
17
+ * via a worktree is not repeated. Throwaway `aimux/` branches are skipped unless
18
+ * a live worktree is on them — `git worktree remove` leaves the branch behind,
19
+ * so deleted temp worktrees would otherwise haunt the list as orphan branches.
20
+ */
21
+ export function buildBaseRefOptions(
22
+ worktrees: WorktreeRecord[],
23
+ localBranches: string[],
24
+ query: string
25
+ ): BaseRefOption[] {
26
+ const seen = new Set<string>()
27
+ const options: BaseRefOption[] = []
28
+ for (const worktree of worktrees) {
29
+ if (worktree.branch == null || worktree.branch === '' || seen.has(worktree.branch)) continue
30
+ seen.add(worktree.branch)
31
+ options.push({
32
+ detail: worktree.name,
33
+ kind: 'worktree',
34
+ label: worktree.branch,
35
+ ref: worktree.branch,
36
+ })
37
+ }
38
+ for (const branch of localBranches) {
39
+ if (seen.has(branch) || branch.startsWith('aimux/')) continue
40
+ seen.add(branch)
41
+ options.push({ kind: 'branch', label: branch, ref: branch })
42
+ }
43
+ const trimmed = query.trim().toLowerCase()
44
+ if (trimmed === '') return options
45
+ return options.filter((option) => option.label.toLowerCase().includes(trimmed))
46
+ }
3
47
 
4
48
  /**
5
49
  * 0 when the template picker should NOT show the "None" fallback (no assistant
@@ -72,6 +72,27 @@ export interface RestoreOptions {
72
72
  // will overwrite the status with daemon truth — leaving the flag on would
73
73
  // briefly flash the "Restored snapshot" hint on every j/k cycle.
74
74
  forceDisconnected?: boolean
75
+ // When provided, drop tabs pinned to a worktree id that the session no
76
+ // longer owns. Some delete paths (notably the sidebar's "Remove worktree")
77
+ // historically removed the worktree record without closing its tabs, leaving
78
+ // orphans bound to a vanished id. Those orphans are invisible (filtered out
79
+ // by the active-worktree filter) yet keep a worktree id that a *future*
80
+ // delete can collide with — exactly what closes "another worktree's" tabs.
81
+ // Pruning them on restore both repairs corrupted catalogs and prevents the
82
+ // collision. Tabs with no worktree id (legacy/unbound) are always kept.
83
+ validWorktreeIds?: ReadonlySet<string>
84
+ }
85
+
86
+ // Drop tabs bound to a worktree id the session no longer owns. Unbound tabs
87
+ // (no worktreeId) are kept — they surface under the primary worktree.
88
+ function pruneOrphanedTabs(
89
+ tabs: TabSession[],
90
+ validWorktreeIds: ReadonlySet<string> | undefined
91
+ ): TabSession[] {
92
+ if (!validWorktreeIds) return tabs
93
+ return tabs.filter(
94
+ (tab) => tab.worktreeId == null || tab.worktreeId === '' || validWorktreeIds.has(tab.worktreeId)
95
+ )
75
96
  }
76
97
 
77
98
  export function restoreTabsFromWorkspace(
@@ -84,7 +105,7 @@ export function restoreTabsFromWorkspace(
84
105
 
85
106
  const forceDisconnected = options.forceDisconnected ?? true
86
107
 
87
- return snapshot.tabs
108
+ const restored: TabSession[] = snapshot.tabs
88
109
  .filter(
89
110
  (tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
90
111
  tab.status !== 'exited'
@@ -103,6 +124,7 @@ export function restoreTabsFromWorkspace(
103
124
  viewport: tab.viewport,
104
125
  worktreeId: tab.worktreeId,
105
126
  }))
127
+ return pruneOrphanedTabs(restored, options.validWorktreeIds)
106
128
  }
107
129
 
108
130
  export function restoreLayoutTrees(
@@ -47,6 +47,8 @@ export type ModalType =
47
47
  | 'update-available'
48
48
  | 'ai-usage'
49
49
  | 'worktree-move'
50
+ | 'worktree-move-confirm'
51
+ | 'worktree-delete-confirm'
50
52
  | null
51
53
 
52
54
  export interface TerminalSpan {
@@ -71,12 +73,22 @@ export interface TerminalLine {
71
73
  spans: TerminalSpan[]
72
74
  }
73
75
 
76
+ /** DECSCUSR cursor shape; 'default' restores the host terminal's configured cursor. */
77
+ export type TerminalCursorStyle = 'block' | 'underline' | 'bar' | 'default'
78
+
74
79
  export interface TerminalSnapshot {
75
80
  lines: TerminalLine[]
76
81
  tailLines?: TerminalLine[]
77
82
  viewportY: number
78
83
  baseY: number
79
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
80
92
  }
81
93
 
82
94
  // The scroll position is owned end-to-end by the backend emulator; this type
@@ -323,16 +335,21 @@ export interface ModalClosed extends ModalBase {
323
335
  export interface ModalNewTab extends ModalBase {
324
336
  type: 'new-tab'
325
337
  editingCommand: AssistantId | null
326
- activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
338
+ activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
327
339
  branchError: string | null
328
340
  branchName: string
329
341
  createWorktree: boolean
330
342
  selectedAssistantId: AssistantId | null
331
343
  step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
332
344
  targetWorktreeIndex: number
333
- worktreeDeleteConfirmId: string | null
334
- worktreeDeleteMessage: string | null
345
+ worktreeDeletePrompt: { worktreeId: string; reason: string } | null
335
346
  worktreeName: string
347
+ /** Filter text typed into the "Base" picker on the worktree-create step. */
348
+ baseQuery: string
349
+ /** Resolved base ref the new worktree is forked from (branch of a worktree or a local branch). */
350
+ baseRef: string
351
+ /** Local branches available as base refs, loaded when the create step opens. */
352
+ baseBranches: string[]
336
353
  }
337
354
 
338
355
  export interface ModalSessionPicker extends ModalBase {
@@ -415,6 +432,42 @@ export interface ModalWorktreeMove extends ModalBase {
415
432
  /** The worktree being moved (may differ from the active one, e.g. a tab menu). */
416
433
  sourceWorktreeId: string
417
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
455
+ }
456
+
457
+ /**
458
+ * Standalone confirmation for a recoverable worktree delete failure triggered
459
+ * outside the new-tab picker (e.g. the sidebar's "Remove worktree"). Carries the
460
+ * params needed to re-run the delete with force once confirmed.
461
+ */
462
+ export interface ModalWorktreeDeleteConfirm extends ModalBase {
463
+ type: 'worktree-delete-confirm'
464
+ sessionId: string
465
+ worktreeId: string
466
+ worktreeLabel: string
467
+ reason: string
468
+ closeTabs: boolean
469
+ /** Whether confirming force-deletes — true only after a recoverable failure. */
470
+ force: boolean
418
471
  }
419
472
 
420
473
  export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
@@ -440,6 +493,8 @@ export type ModalState =
440
493
  | ModalUpdateAvailable
441
494
  | ModalAIUsage
442
495
  | ModalWorktreeMove
496
+ | ModalWorktreeMoveConfirm
497
+ | ModalWorktreeDeleteConfirm
443
498
 
444
499
  export interface LayoutState {
445
500
  terminalCols: number
@@ -515,10 +570,10 @@ export type ModalAction =
515
570
  | { type: 'open-new-tab-modal' }
516
571
  | { type: 'set-new-tab-branch-error'; message: string | null }
517
572
  | {
518
- type: 'set-new-tab-worktree-delete-state'
519
- confirmWorktreeId?: string | null
520
- message: string | null
573
+ type: 'set-new-tab-worktree-delete-prompt'
574
+ prompt: { worktreeId: string; reason: string } | null
521
575
  }
576
+ | { type: 'set-new-tab-base-branches'; branches: string[] }
522
577
  | { type: 'enter-new-tab-worktree-create' }
523
578
  | { type: 'enter-new-tab-template-pick' }
524
579
  | { type: 'enter-new-tab-template-shortcut' }
@@ -553,6 +608,27 @@ export type ModalAction =
553
608
  | { type: 'open-ai-usage-modal' }
554
609
  | { type: 'open-worktree-move-modal'; sourceWorktreeId: string }
555
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
+ }
623
+ | {
624
+ type: 'open-worktree-delete-confirm'
625
+ sessionId: string
626
+ worktreeId: string
627
+ worktreeLabel: string
628
+ reason: string
629
+ closeTabs: boolean
630
+ force: boolean
631
+ }
556
632
 
557
633
  // -- Session actions --
558
634
  export type SessionAction =
@@ -570,7 +646,6 @@ export type SessionAction =
570
646
  | { type: 'reorder-active-session'; delta: number }
571
647
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
572
648
  | { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
573
- | { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
574
649
  | { type: 'set-active-worktree'; sessionId: string; worktreeId: string }
575
650
  | {
576
651
  type: 'update-worktree-record'
@@ -595,6 +670,7 @@ export type TabAction =
595
670
  | { type: 'set-active-tab'; tabId: string }
596
671
  | { type: 'move-active-tab'; delta: number }
597
672
  | { type: 'reorder-active-tab'; delta: number }
673
+ | { type: 'reorder-tabs'; orderedTabIds: string[] }
598
674
  | { type: 'reset-tab-session'; tabId: string }
599
675
  | { type: 'rename-tab'; tabId: string; title: string }
600
676
  | { type: 'append-tab-buffer'; tabId: string; chunk: 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
  : []),
@@ -68,14 +68,24 @@ export const WorktreeRow = memo(function WorktreeRow({
68
68
  [
69
69
  'Remove worktree',
70
70
  () =>
71
+ // Always confirm first. Confirming routes through the full delete side
72
+ // effect (closes the worktree's tabs, disposes their PTYs, prunes the
73
+ // snapshot, removes the git worktree). closeTabs (not force) cleans up
74
+ // the tabs while keeping the non-force `git worktree remove`, so
75
+ // uncommitted work in a temp worktree is still protected — a dirty
76
+ // worktree re-prompts for an explicit force-delete.
71
77
  dispatchGlobal({
78
+ closeTabs: true,
79
+ force: false,
80
+ reason: 'Its assistant tabs will be closed and the worktree removed.',
72
81
  sessionId: session.id,
73
- type: 'remove-worktree-record',
82
+ type: 'open-worktree-delete-confirm',
74
83
  worktreeId: worktree.id,
84
+ worktreeLabel: worktree.branch ?? worktree.name,
75
85
  }),
76
86
  ],
77
87
  ]
78
- }, [session.id, worktree.id, worktree.source])
88
+ }, [session.id, worktree.branch, worktree.id, worktree.name, worktree.source])
79
89
 
80
90
  let bgColor: string | undefined
81
91
  if (isActiveItem) {
@@ -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>