@brimveyn/aimux 1.14.15 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +122 -43
- package/src/app.tsx +26 -1
- package/src/config.ts +4 -0
- package/src/git/move-worktree.ts +87 -17
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +5 -0
- package/src/input/modes/transitions.ts +10 -1
- package/src/input/modes/types.ts +20 -1
- package/src/ipc/manager-protocol.ts +9 -1
- package/src/ipc/protocol.ts +9 -1
- package/src/pty/ghostty-shell-integration.ts +34 -0
- package/src/pty/pty-manager.ts +60 -6
- package/src/pty/terminal-snapshot.ts +29 -13
- package/src/state/reducers/modal-state.ts +166 -19
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +45 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/types.ts +83 -7
- package/src/state/validation.ts +9 -1
- package/src/ui/components/layout/sidebar/tab-item.tsx +4 -2
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/terminal-pane.tsx +112 -8
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +70 -23
- package/src/ui/components/modals/worktree/worktree-move-confirm-modal.tsx +65 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +18 -1
- package/src/ui/root.tsx +25 -2
- package/src/ui/status-bar-model.ts +2 -0
|
@@ -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
|
{
|
package/src/state/selectors.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
519
|
-
|
|
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 }
|
package/src/state/validation.ts
CHANGED
|
@@ -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: '
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
96
|
+
let fg =
|
|
90
97
|
span.fgPalette !== undefined
|
|
91
98
|
? resolvePaletteIndex(span.fgPalette)
|
|
92
99
|
: (span.fg ?? getCurrentTheme().text)
|
|
93
|
-
|
|
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
|
|
586
|
+
<TerminalViewport
|
|
587
|
+
viewport={tab.viewport}
|
|
588
|
+
buffer={tab.buffer}
|
|
589
|
+
softCursor={!showHardwareCursor}
|
|
590
|
+
/>
|
|
487
591
|
</box>
|
|
488
592
|
)}
|
|
489
593
|
</ContextMenuBox>
|