@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 +2 -2
- package/src/app-runtime/side-effects.ts +51 -10
- package/src/git/move-worktree.ts +87 -17
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +5 -0
- 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 +28 -0
- package/src/state/types.ts +44 -0
- package/src/state/validation.ts +9 -1
- package/src/ui/components/layout/sidebar/tab-item.tsx +4 -2
- package/src/ui/components/layout/terminal-pane.tsx +112 -8
- 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 +11 -0
- package/src/ui/status-bar-model.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
|
|
1677
|
-
|
|
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
|
-
`
|
|
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
|
|
package/src/git/move-worktree.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
12
|
-
return result
|
|
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
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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'],
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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
|
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -175,7 +175,15 @@ function isTerminalSnapshot(value: unknown): value is TerminalSnapshot {
|
|
|
175
175
|
) &&
|
|
176
176
|
isFiniteNumber(value.viewportY) &&
|
|
177
177
|
isFiniteNumber(value.baseY) &&
|
|
178
|
-
typeof value.cursorVisible === 'boolean'
|
|
178
|
+
typeof value.cursorVisible === 'boolean' &&
|
|
179
|
+
(value.cursorStyle === undefined ||
|
|
180
|
+
value.cursorStyle === 'block' ||
|
|
181
|
+
value.cursorStyle === 'underline' ||
|
|
182
|
+
value.cursorStyle === 'bar' ||
|
|
183
|
+
value.cursorStyle === 'default') &&
|
|
184
|
+
(value.cursorBlink === undefined || typeof value.cursorBlink === 'boolean') &&
|
|
185
|
+
(value.cursorRow === undefined || isFiniteNumber(value.cursorRow)) &&
|
|
186
|
+
(value.cursorCol === undefined || isFiniteNumber(value.cursorCol))
|
|
179
187
|
)
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export type PtyEnv = Record<string, string>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Replicate Ghostty's automatic shell-integration injection for the nested
|
|
8
|
+
* shells aimux spawns. Ghostty only performs it for shells it launches
|
|
9
|
+
* directly (by pointing ZDOTDIR at its integration dir, whose .zshenv
|
|
10
|
+
* restores the user's ZDOTDIR and then loads the integration), so aimux
|
|
11
|
+
* panes would otherwise miss the integration's zle hooks — notably the
|
|
12
|
+
* DECSCUSR cursor-shape reporting (bar at prompt, block in vicmd) that the
|
|
13
|
+
* hardware-cursor pass-through relies on for native-parity rendering.
|
|
14
|
+
*/
|
|
15
|
+
export function applyGhosttyShellIntegration(
|
|
16
|
+
env: PtyEnv,
|
|
17
|
+
command: string,
|
|
18
|
+
integrationFileExists: (path: string) => boolean = existsSync
|
|
19
|
+
): PtyEnv {
|
|
20
|
+
const resourcesDir = env.GHOSTTY_RESOURCES_DIR
|
|
21
|
+
if (resourcesDir === undefined || resourcesDir === '') return env
|
|
22
|
+
// bash and fish use different bootstrap mechanisms; only zsh is supported.
|
|
23
|
+
if (basename(command) !== 'zsh') return env
|
|
24
|
+
|
|
25
|
+
const integrationDir = join(resourcesDir, 'shell-integration', 'zsh')
|
|
26
|
+
if (env.ZDOTDIR === integrationDir) return env
|
|
27
|
+
if (!integrationFileExists(join(integrationDir, '.zshenv'))) return env
|
|
28
|
+
|
|
29
|
+
const next: PtyEnv = { ...env, ZDOTDIR: integrationDir }
|
|
30
|
+
if (env.ZDOTDIR !== undefined && env.ZDOTDIR !== '') {
|
|
31
|
+
next.GHOSTTY_ZSH_ZDOTDIR = env.ZDOTDIR
|
|
32
|
+
}
|
|
33
|
+
return next
|
|
34
|
+
}
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { Terminal as XTerm } from '@xterm/headless'
|
|
|
2
2
|
import { type IPty, spawn } from 'bun-pty'
|
|
3
3
|
import { EventEmitter } from 'node:events'
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
ScrollIntent,
|
|
7
|
+
TerminalCursorStyle,
|
|
8
|
+
TerminalModeState,
|
|
9
|
+
TerminalSnapshot,
|
|
10
|
+
} from '../state/types'
|
|
6
11
|
|
|
7
12
|
import { logDebug } from '../debug/input-log'
|
|
13
|
+
import { applyGhosttyShellIntegration } from './ghostty-shell-integration'
|
|
8
14
|
import { areTerminalSnapshotsEqual, snapshotTerminal } from './terminal-snapshot'
|
|
9
15
|
|
|
10
16
|
interface PtyManagerEvents {
|
|
@@ -21,6 +27,10 @@ interface SessionHandle {
|
|
|
21
27
|
lastTerminalModes?: TerminalModeState
|
|
22
28
|
alternateScrollMode: boolean
|
|
23
29
|
cursorVisible: boolean
|
|
30
|
+
/** Last DECSCUSR shape; 'default' = the host terminal's configured cursor. */
|
|
31
|
+
cursorStyle: TerminalCursorStyle
|
|
32
|
+
/** Last DECSCUSR blink flag; undefined until an explicit DECSCUSR arrives. */
|
|
33
|
+
cursorBlink: boolean | undefined
|
|
24
34
|
pendingModeSequence: string
|
|
25
35
|
pendingWrites: number
|
|
26
36
|
pendingExitCode: number | null
|
|
@@ -75,6 +85,28 @@ function trackPrivateModes(
|
|
|
75
85
|
}
|
|
76
86
|
}
|
|
77
87
|
|
|
88
|
+
// DECSCUSR (CSI Ps SP q): 0 resets to the terminal's configured cursor,
|
|
89
|
+
// odd values blink, 1/2 = block, 3/4 = underline, 5/6 = bar.
|
|
90
|
+
const DECSCUSR_STYLES: readonly TerminalCursorStyle[] = [
|
|
91
|
+
'block',
|
|
92
|
+
'block',
|
|
93
|
+
'underline',
|
|
94
|
+
'underline',
|
|
95
|
+
'bar',
|
|
96
|
+
'bar',
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
function decscusrToCursorState(ps: number): {
|
|
100
|
+
cursorStyle: TerminalCursorStyle
|
|
101
|
+
cursorBlink: boolean | undefined
|
|
102
|
+
} {
|
|
103
|
+
const style = DECSCUSR_STYLES[ps - 1]
|
|
104
|
+
if (ps === 0 || style === undefined) {
|
|
105
|
+
return { cursorBlink: undefined, cursorStyle: 'default' }
|
|
106
|
+
}
|
|
107
|
+
return { cursorBlink: ps % 2 === 1, cursorStyle: style }
|
|
108
|
+
}
|
|
109
|
+
|
|
78
110
|
function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): TerminalModeState {
|
|
79
111
|
return {
|
|
80
112
|
alternateScrollMode,
|
|
@@ -192,7 +224,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
192
224
|
|
|
193
225
|
private emitRenderIfChanged(session: SessionHandle): void {
|
|
194
226
|
if (!this.broadcastEnabled) return
|
|
195
|
-
const nextSnapshot = snapshotTerminal(session.emulator,
|
|
227
|
+
const nextSnapshot = snapshotTerminal(session.emulator, {
|
|
228
|
+
cursorBlink: session.cursorBlink,
|
|
229
|
+
cursorStyle: session.cursorStyle,
|
|
230
|
+
cursorVisible: session.cursorVisible,
|
|
231
|
+
})
|
|
196
232
|
const nextTerminalModes = getTerminalModes(session.emulator, session.alternateScrollMode)
|
|
197
233
|
const snapshotChanged = !areTerminalSnapshotsEqual(session.lastSnapshot, nextSnapshot)
|
|
198
234
|
const modesChanged =
|
|
@@ -259,21 +295,28 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
259
295
|
scrollback: 1000,
|
|
260
296
|
})
|
|
261
297
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
cwd: options.cwd ?? process.cwd(),
|
|
265
|
-
env: {
|
|
298
|
+
const env = applyGhosttyShellIntegration(
|
|
299
|
+
{
|
|
266
300
|
...process.env,
|
|
267
301
|
COLORTERM: 'truecolor',
|
|
268
302
|
TERM: 'xterm-256color',
|
|
269
303
|
...options.env,
|
|
270
304
|
},
|
|
305
|
+
options.command
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
const pty = spawn(options.command, options.args ?? [], {
|
|
309
|
+
cols: options.cols,
|
|
310
|
+
cwd: options.cwd ?? process.cwd(),
|
|
311
|
+
env,
|
|
271
312
|
name: 'xterm-256color',
|
|
272
313
|
rows: options.rows,
|
|
273
314
|
})
|
|
274
315
|
|
|
275
316
|
const session: SessionHandle = {
|
|
276
317
|
alternateScrollMode: false,
|
|
318
|
+
cursorBlink: undefined,
|
|
319
|
+
cursorStyle: 'default',
|
|
277
320
|
cursorVisible: true,
|
|
278
321
|
emulator,
|
|
279
322
|
lastScrollIntent: undefined,
|
|
@@ -287,6 +330,17 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
287
330
|
tabId: options.tabId,
|
|
288
331
|
}
|
|
289
332
|
|
|
333
|
+
// xterm parses DECSCUSR itself but keeps the result in private state;
|
|
334
|
+
// mirror it here so snapshots can carry the shape to the renderer.
|
|
335
|
+
// Returning false lets xterm's own handler still run.
|
|
336
|
+
emulator.parser.registerCsiHandler({ final: 'q', intermediates: ' ' }, (params) => {
|
|
337
|
+
const raw = params[0]
|
|
338
|
+
const tracked = decscusrToCursorState(typeof raw === 'number' ? raw : 0)
|
|
339
|
+
session.cursorStyle = tracked.cursorStyle
|
|
340
|
+
session.cursorBlink = tracked.cursorBlink
|
|
341
|
+
return false
|
|
342
|
+
})
|
|
343
|
+
|
|
290
344
|
pty.onData((data) => {
|
|
291
345
|
logDebug('ptyManager.data', {
|
|
292
346
|
byteLength: Buffer.byteLength(data, 'utf8'),
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Terminal } from '@xterm/headless'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
TerminalCursorStyle,
|
|
5
|
+
TerminalLine,
|
|
6
|
+
TerminalSnapshot,
|
|
7
|
+
TerminalSpan,
|
|
8
|
+
} from '../state/types'
|
|
4
9
|
|
|
5
10
|
import { getCurrentTheme } from '../ui/theme'
|
|
6
11
|
|
|
@@ -95,14 +100,10 @@ function buildLine(
|
|
|
95
100
|
bg = resolvedFg
|
|
96
101
|
}
|
|
97
102
|
|
|
103
|
+
// The cursor cell is only flagged here; how it renders (soft inverted
|
|
104
|
+
// block vs. the host terminal's hardware cursor) is a presentation
|
|
105
|
+
// decision made in the renderer process (terminal-pane.tsx).
|
|
98
106
|
const isCursorCell = cursorVisible && cursorColumn === column
|
|
99
|
-
if (isCursorCell) {
|
|
100
|
-
const tokens = getCurrentTheme()
|
|
101
|
-
const resolvedFg: CellColor = fg ?? { hex: tokens.text, kind: 'rgb' }
|
|
102
|
-
const resolvedBg: CellColor = bg ?? { hex: tokens.background, kind: 'rgb' }
|
|
103
|
-
fg = resolvedBg
|
|
104
|
-
bg = resolvedFg
|
|
105
|
-
}
|
|
106
107
|
|
|
107
108
|
const span: TerminalSpan = {
|
|
108
109
|
bold: current.isBold() ? true : undefined,
|
|
@@ -143,11 +144,8 @@ function buildLine(
|
|
|
143
144
|
if (leading > 0) {
|
|
144
145
|
pushSpan(spans, { text: ' '.repeat(leading) })
|
|
145
146
|
}
|
|
146
|
-
const tokens = getCurrentTheme()
|
|
147
147
|
pushSpan(spans, {
|
|
148
|
-
bg: tokens.text,
|
|
149
148
|
cursor: true,
|
|
150
|
-
fg: tokens.background,
|
|
151
149
|
text: ' ',
|
|
152
150
|
})
|
|
153
151
|
if (trailing > 0) {
|
|
@@ -161,7 +159,17 @@ function buildLine(
|
|
|
161
159
|
return { spans }
|
|
162
160
|
}
|
|
163
161
|
|
|
164
|
-
export
|
|
162
|
+
export interface SnapshotCursorOptions {
|
|
163
|
+
cursorVisible: boolean
|
|
164
|
+
cursorStyle?: TerminalCursorStyle
|
|
165
|
+
cursorBlink?: boolean
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function snapshotTerminal(
|
|
169
|
+
terminal: Terminal,
|
|
170
|
+
cursorOptions: SnapshotCursorOptions = { cursorVisible: true }
|
|
171
|
+
): TerminalSnapshot {
|
|
172
|
+
const { cursorBlink, cursorStyle, cursorVisible } = cursorOptions
|
|
165
173
|
const buffer = terminal.buffer.active
|
|
166
174
|
const startLine = buffer.viewportY
|
|
167
175
|
const tailStartLine = Math.max(0, buffer.baseY + terminal.rows - SNAPSHOT_TAIL_LINE_COUNT)
|
|
@@ -201,6 +209,10 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
|
|
|
201
209
|
|
|
202
210
|
return {
|
|
203
211
|
baseY: buffer.baseY,
|
|
212
|
+
cursorBlink,
|
|
213
|
+
cursorCol: cursorColumn,
|
|
214
|
+
cursorRow: cursorLine - buffer.viewportY,
|
|
215
|
+
cursorStyle,
|
|
204
216
|
cursorVisible,
|
|
205
217
|
lines,
|
|
206
218
|
tailLines,
|
|
@@ -255,7 +267,11 @@ export function areTerminalSnapshotsEqual(
|
|
|
255
267
|
if (
|
|
256
268
|
left.viewportY !== right.viewportY ||
|
|
257
269
|
left.baseY !== right.baseY ||
|
|
258
|
-
left.cursorVisible !== right.cursorVisible
|
|
270
|
+
left.cursorVisible !== right.cursorVisible ||
|
|
271
|
+
left.cursorStyle !== right.cursorStyle ||
|
|
272
|
+
left.cursorBlink !== right.cursorBlink ||
|
|
273
|
+
left.cursorRow !== right.cursorRow ||
|
|
274
|
+
left.cursorCol !== right.cursorCol
|
|
259
275
|
) {
|
|
260
276
|
return false
|
|
261
277
|
}
|
|
@@ -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,
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
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
|
: []),
|
|
@@ -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>
|
|
@@ -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
|
}
|