@brimveyn/aimux 1.12.6 → 1.13.2
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/README.md +3 -0
- package/package.json +4 -3
- package/src/app-runtime/auto-commit-driver.ts +8 -4
- package/src/app-runtime/auto-commit-ref.ts +1 -1
- package/src/app-runtime/backend-attach-runtime.ts +29 -13
- package/src/app-runtime/backend-runtime-events.ts +1 -1
- package/src/app-runtime/selection-scroll.ts +2 -2
- package/src/app-runtime/session-actions.ts +16 -4
- package/src/app-runtime/side-effects.ts +563 -85
- package/src/app-runtime/snippet-actions.ts +3 -3
- package/src/app-runtime/use-auto-commit-driver.ts +7 -4
- package/src/app-runtime/use-backend-runtime.ts +3 -3
- package/src/app-runtime/use-directory-search.ts +7 -5
- package/src/app-runtime/use-mouse-handlers.ts +11 -6
- package/src/app-runtime/use-pane-size-report.ts +1 -1
- package/src/app-runtime/use-renderer-bindings.ts +6 -5
- package/src/app.tsx +12 -7
- package/src/auto-commit/headless-commands.ts +4 -6
- package/src/daemon/daemon.ts +21 -11
- package/src/daemon/runtime-paths.ts +1 -1
- package/src/daemon/session-manager.ts +1 -1
- package/src/daemon/session-registry.ts +10 -3
- package/src/diff-parser/parse-patch-files.ts +18 -18
- package/src/git/command-queue.ts +19 -3
- package/src/git/divergence.ts +46 -0
- package/src/git/git-diff.ts +12 -5
- package/src/git/git-poller.ts +33 -11
- package/src/git/git-status.ts +14 -3
- package/src/git/move-worktree.ts +96 -0
- package/src/git/use-repo-discovery.ts +2 -1
- package/src/git/worktree-divergence-poller.ts +59 -0
- package/src/git/worktree.ts +99 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/describe-bindings.ts +27 -9
- package/src/input/keymap/key-chord.ts +4 -4
- package/src/input/keymap/key-format.ts +2 -2
- package/src/input/keymap/sequence-resolver.ts +1 -1
- package/src/input/keymap/trie.ts +1 -1
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +11 -1
- package/src/input/raw-input-handler.ts +1 -1
- package/src/input/terminal-text-extraction.ts +9 -5
- package/src/integrations/claude-syntax-overlay.ts +8 -8
- package/src/integrations/claude-theme-sync.ts +12 -12
- package/src/ipc/protocol.ts +3 -3
- package/src/platform/clipboard.ts +4 -2
- package/src/platform/worktree-deps.ts +22 -0
- package/src/platform/worktree-paths.ts +67 -0
- package/src/profile-paths.ts +3 -3
- package/src/pty/assistant-status-detection-loop.ts +2 -2
- package/src/pty/assistant-status-detector.ts +15 -6
- package/src/pty/command-registry.ts +2 -1
- package/src/pty/pty-manager.ts +1 -1
- package/src/services/ai-usage/adapters/claude.ts +5 -5
- package/src/services/ai-usage/adapters/codex.ts +8 -8
- package/src/services/ai-usage/cache.ts +2 -2
- package/src/services/ai-usage/pace.ts +3 -6
- package/src/services/ai-usage/provider.ts +1 -1
- package/src/session-backend/bootstrap.ts +2 -2
- package/src/session-backend/local-session-backend.ts +11 -11
- package/src/session-backend/remote-session-backend.ts +60 -45
- package/src/session-backend/types.ts +2 -2
- package/src/snippets/expand-variables.ts +7 -7
- package/src/snippets/run-shell-var.ts +1 -1
- package/src/snippets/trigger-detector.ts +1 -1
- package/src/state/ai-usage-store.ts +1 -1
- package/src/state/git-tree.ts +8 -4
- package/src/state/layout-tree.ts +8 -5
- package/src/state/reducers/git-mode-state.ts +44 -14
- package/src/state/reducers/git-panel-state.ts +38 -2
- package/src/state/reducers/modal-state.ts +355 -21
- package/src/state/reducers/session-state.ts +60 -1
- package/src/state/reducers/tab-state.ts +151 -85
- package/src/state/selectors.ts +6 -4
- package/src/state/session-catalog.ts +20 -5
- package/src/state/session-persistence.ts +8 -4
- package/src/state/session-worktrees.ts +227 -0
- package/src/state/store.ts +1 -0
- package/src/state/toast-store.ts +95 -0
- package/src/state/types.ts +92 -13
- package/src/state/validation.ts +25 -2
- package/src/terminal-manager/manager-client.ts +15 -13
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
- package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
- package/src/ui/components/git/git-panel.tsx +129 -83
- package/src/ui/components/git/git-view.tsx +96 -28
- package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
- package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
- package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
- package/src/ui/components/layout/session-bar.tsx +135 -117
- package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
- package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
- package/src/ui/components/layout/split-layout.tsx +27 -20
- package/src/ui/components/layout/terminal-pane.tsx +163 -120
- package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
- package/src/ui/components/modals/app/help-modal.tsx +29 -20
- package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
- package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
- package/src/ui/components/modals/shared/form.tsx +6 -6
- package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
- package/src/ui/components/modals/shared/picker.tsx +71 -32
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
- package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
- package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
- package/src/ui/components/overlays/toast/toast-position.ts +45 -0
- package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
- package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
- package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
- package/src/ui/components/primitives/input-field.tsx +1 -1
- package/src/ui/components/primitives/list-item.tsx +23 -7
- package/src/ui/filter-themes.ts +1 -1
- package/src/ui/root.tsx +121 -38
- package/src/ui/session-ordering.ts +2 -2
- package/src/ui/status-bar-model.ts +14 -7
- package/src/ui/terminal-graphics/capabilities.ts +1 -1
- package/src/ui/terminal-graphics/format-fallback.ts +7 -9
- package/src/ui/terminal-graphics/kitty.ts +2 -7
- package/src/update/version-check.ts +1 -1
package/src/git/git-diff.ts
CHANGED
|
@@ -5,7 +5,8 @@ import type { DiffData, DiffFileStatus, GitFileEntry } from '../state/types'
|
|
|
5
5
|
import { imageFormatLabel, imageMimeFromPath, isImagePath } from './image-detect'
|
|
6
6
|
|
|
7
7
|
function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?: string } {
|
|
8
|
-
if (entry.renamedFrom
|
|
8
|
+
if (entry.renamedFrom != null && entry.renamedFrom !== '')
|
|
9
|
+
return { oldPath: entry.renamedFrom, status: 'renamed' }
|
|
9
10
|
if (entry.section === 'untracked' || entry.status === '?') return { status: 'new' }
|
|
10
11
|
if (entry.status === 'D') return { status: 'deleted' }
|
|
11
12
|
if (entry.status === 'A' && (entry.section === 'staged' || entry.section === 'historical')) {
|
|
@@ -85,13 +86,19 @@ async function rawUnifiedDiff(
|
|
|
85
86
|
return result.text()
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
function resolveCompareRef(headOffset: number, compareRef: string | undefined): string {
|
|
90
|
+
if (compareRef != null && compareRef !== '') return compareRef
|
|
91
|
+
return headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
export async function fetchDiff(
|
|
89
95
|
cwd: string,
|
|
90
96
|
file: GitFileEntry,
|
|
91
|
-
headOffset: number = 0
|
|
97
|
+
headOffset: number = 0,
|
|
98
|
+
compareRef?: string
|
|
92
99
|
): Promise<DiffData> {
|
|
93
100
|
const { oldPath, status } = resolveStatus(file)
|
|
94
|
-
const ref = headOffset
|
|
101
|
+
const ref = resolveCompareRef(headOffset, compareRef)
|
|
95
102
|
|
|
96
103
|
if (isImagePath(file.path)) {
|
|
97
104
|
const headPath = oldPath ?? file.path
|
|
@@ -112,7 +119,7 @@ export async function fetchDiff(
|
|
|
112
119
|
}
|
|
113
120
|
if (imageBytesBefore) data.imageBytesBefore = imageBytesBefore
|
|
114
121
|
if (imageBytesAfter) data.imageBytesAfter = imageBytesAfter
|
|
115
|
-
if (oldPath) data.oldPath = oldPath
|
|
122
|
+
if (oldPath != null && oldPath !== '') data.oldPath = oldPath
|
|
116
123
|
return data
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -137,6 +144,6 @@ export async function fetchDiff(
|
|
|
137
144
|
rawDiff,
|
|
138
145
|
status,
|
|
139
146
|
}
|
|
140
|
-
if (oldPath) data.oldPath = oldPath
|
|
147
|
+
if (oldPath != null && oldPath !== '') data.oldPath = oldPath
|
|
141
148
|
return data
|
|
142
149
|
}
|
package/src/git/git-poller.ts
CHANGED
|
@@ -13,6 +13,9 @@ interface Options {
|
|
|
13
13
|
enabled: boolean
|
|
14
14
|
projectPath: string | undefined
|
|
15
15
|
headOffset: number
|
|
16
|
+
// When set, diff the working tree against this ref (worktree fork point for
|
|
17
|
+
// review-vs-base) instead of HEAD/HEAD~N.
|
|
18
|
+
compareRef?: string
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
function tagFiles(files: GitFileEntry[], repoPath: string): GitFileEntry[] {
|
|
@@ -22,13 +25,23 @@ function tagFiles(files: GitFileEntry[], repoPath: string): GitFileEntry[] {
|
|
|
22
25
|
async function collectAggregated(
|
|
23
26
|
repos: DiscoveredRepo[],
|
|
24
27
|
fallbackCwd: string,
|
|
25
|
-
headOffset: number
|
|
28
|
+
headOffset: number,
|
|
29
|
+
compareRef: string | undefined
|
|
26
30
|
): Promise<GitCollectResult> {
|
|
27
|
-
// Historical walking (HEAD~N)
|
|
28
|
-
// the root/fallback repo
|
|
29
|
-
if (
|
|
31
|
+
// Historical walking (HEAD~N) and review-vs-base are per-repo and don't
|
|
32
|
+
// compose; fall back to the root/fallback repo to preserve existing behaviour.
|
|
33
|
+
if ((compareRef != null && compareRef !== '') || headOffset > 0) {
|
|
34
|
+
const result = await collectGitStatus(fallbackCwd, { compareRef, headOffset })
|
|
35
|
+
if (result.kind !== 'ok') return result
|
|
36
|
+
return {
|
|
37
|
+
kind: 'ok',
|
|
38
|
+
payload: { ...result.payload, files: tagFiles(result.payload.files, fallbackCwd) },
|
|
39
|
+
}
|
|
40
|
+
}
|
|
30
41
|
|
|
31
|
-
const results = await Promise.all(
|
|
42
|
+
const results = await Promise.all(
|
|
43
|
+
repos.map(async (r) => collectGitStatus(r.path, { headOffset: 0 }))
|
|
44
|
+
)
|
|
32
45
|
const files: GitFileEntry[] = []
|
|
33
46
|
let branch: string | null = null
|
|
34
47
|
let ahead = 0
|
|
@@ -58,13 +71,18 @@ async function collectAggregated(
|
|
|
58
71
|
return { kind: 'ok', payload }
|
|
59
72
|
}
|
|
60
73
|
|
|
61
|
-
export function useGitPanelPolling({
|
|
74
|
+
export function useGitPanelPolling({
|
|
75
|
+
compareRef,
|
|
76
|
+
enabled,
|
|
77
|
+
headOffset,
|
|
78
|
+
projectPath,
|
|
79
|
+
}: Options): void {
|
|
62
80
|
// Read repos from the store imperatively — changes trigger a fresh effect run
|
|
63
81
|
// because the repos identity is stable across ticks until set-repos fires.
|
|
64
82
|
const repos = useAppStore((s) => s.multiRepo.repos)
|
|
65
83
|
|
|
66
84
|
useEffect(() => {
|
|
67
|
-
if (!enabled || !projectPath) return
|
|
85
|
+
if (!enabled || !(projectPath != null && projectPath !== '')) return
|
|
68
86
|
|
|
69
87
|
dispatchGlobal({ type: 'git-panel-reset' })
|
|
70
88
|
|
|
@@ -80,11 +98,15 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
|
|
|
80
98
|
const tick = async () => {
|
|
81
99
|
const result =
|
|
82
100
|
repos.length > 0
|
|
83
|
-
? await collectAggregated(repos, projectPath, headOffset)
|
|
84
|
-
: await collectGitStatus(projectPath, { headOffset })
|
|
101
|
+
? await collectAggregated(repos, projectPath, headOffset, compareRef)
|
|
102
|
+
: await collectGitStatus(projectPath, { compareRef, headOffset })
|
|
85
103
|
if (cancelled) return
|
|
86
104
|
if (result.kind === 'ok') {
|
|
87
|
-
|
|
105
|
+
const payload =
|
|
106
|
+
repos.length === 0
|
|
107
|
+
? { ...result.payload, files: tagFiles(result.payload.files, projectPath) }
|
|
108
|
+
: result.payload
|
|
109
|
+
dispatchGlobal({ payload, type: 'git-refresh-success' })
|
|
88
110
|
delay = BASE_INTERVAL_MS
|
|
89
111
|
} else if (result.kind === 'out-of-range') {
|
|
90
112
|
dispatchGlobal({ offset: result.maxOffset, type: 'git-mode-set-head-offset' })
|
|
@@ -106,5 +128,5 @@ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options
|
|
|
106
128
|
cancelled = true
|
|
107
129
|
if (timer) clearTimeout(timer)
|
|
108
130
|
}
|
|
109
|
-
}, [enabled, projectPath, headOffset, repos])
|
|
131
|
+
}, [enabled, projectPath, headOffset, compareRef, repos])
|
|
110
132
|
}
|
package/src/git/git-status.ts
CHANGED
|
@@ -88,7 +88,7 @@ function buildEntry(
|
|
|
88
88
|
removed = 0
|
|
89
89
|
}
|
|
90
90
|
const entry: GitFileEntry = { added, path, removed, section, status }
|
|
91
|
-
if (renamedFrom) entry.renamedFrom = renamedFrom
|
|
91
|
+
if (renamedFrom != null && renamedFrom !== '') entry.renamedFrom = renamedFrom
|
|
92
92
|
return entry
|
|
93
93
|
}
|
|
94
94
|
|
|
@@ -179,7 +179,7 @@ async function countUntrackedLines(cwd: string, path: string): Promise<number |
|
|
|
179
179
|
async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Promise<void> {
|
|
180
180
|
const untracked = files.filter((f) => f.section === 'untracked')
|
|
181
181
|
if (untracked.length === 0) return
|
|
182
|
-
const counts = await Promise.all(untracked.map((f) => countUntrackedLines(cwd, f.path)))
|
|
182
|
+
const counts = await Promise.all(untracked.map(async (f) => countUntrackedLines(cwd, f.path)))
|
|
183
183
|
for (let i = 0; i < untracked.length; i++) {
|
|
184
184
|
const file = untracked[i]
|
|
185
185
|
const count = counts[i]
|
|
@@ -192,6 +192,9 @@ async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Prom
|
|
|
192
192
|
|
|
193
193
|
interface CollectOptions {
|
|
194
194
|
headOffset?: number
|
|
195
|
+
// Explicit ref to diff the working tree against (e.g. a worktree's fork
|
|
196
|
+
// point). Takes precedence over headOffset when set.
|
|
197
|
+
compareRef?: string
|
|
195
198
|
}
|
|
196
199
|
|
|
197
200
|
function parseNameStatus(
|
|
@@ -208,7 +211,7 @@ function parseNameStatus(
|
|
|
208
211
|
if (letter === 'R' || letter === 'C') {
|
|
209
212
|
const from = parts[1]
|
|
210
213
|
const to = parts[2]
|
|
211
|
-
if (
|
|
214
|
+
if (from == null || from === '' || !(to != null && to !== '')) continue
|
|
212
215
|
rows.push({ path: to, renamedFrom: from, status })
|
|
213
216
|
} else {
|
|
214
217
|
const path = parts.slice(1).join('\t')
|
|
@@ -252,7 +255,13 @@ async function collectAgainstHistorical(
|
|
|
252
255
|
const maxOffset = Number.isFinite(count) && count > 0 ? count - 1 : 0
|
|
253
256
|
return { kind: 'out-of-range', maxOffset }
|
|
254
257
|
}
|
|
258
|
+
return collectAgainstRef(cwd, ref)
|
|
259
|
+
}
|
|
255
260
|
|
|
261
|
+
// Diffs the working tree against an arbitrary ref (a HEAD~N commit, or a
|
|
262
|
+
// worktree's fork point for review-vs-base), surfacing changed files as a
|
|
263
|
+
// single `historical` section plus any untracked files.
|
|
264
|
+
async function collectAgainstRef(cwd: string, ref: string): Promise<GitCollectResult> {
|
|
256
265
|
const [statusResult, nameStatus, numstat, untrackedStatus] = await Promise.all([
|
|
257
266
|
$`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
|
|
258
267
|
$`git -C ${cwd} -c core.quotePath=false diff ${ref} --name-status`.quiet().nothrow(),
|
|
@@ -286,7 +295,9 @@ export async function collectGitStatus(
|
|
|
286
295
|
options: CollectOptions = {}
|
|
287
296
|
): Promise<GitCollectResult> {
|
|
288
297
|
const headOffset = options.headOffset ?? 0
|
|
298
|
+
const compareRef = options.compareRef
|
|
289
299
|
try {
|
|
300
|
+
if (compareRef != null && compareRef !== '') return await collectAgainstRef(cwd, compareRef)
|
|
290
301
|
return headOffset > 0
|
|
291
302
|
? await collectAgainstHistorical(cwd, headOffset)
|
|
292
303
|
: await collectAgainstHead(cwd)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
|
|
3
|
+
export type MoveWorktreeResult =
|
|
4
|
+
| { kind: 'ok'; filesChanged: number }
|
|
5
|
+
| { kind: 'dirty-target' }
|
|
6
|
+
| { kind: 'conflict'; files: string[] }
|
|
7
|
+
| { kind: 'error'; message: string }
|
|
8
|
+
|
|
9
|
+
async function workingTreeDirty(repoPath: string): Promise<boolean> {
|
|
10
|
+
const result = await $`git -C ${repoPath} status --porcelain`.quiet().nothrow()
|
|
11
|
+
if (result.exitCode !== 0) return false
|
|
12
|
+
return result.text().trim() !== ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Files left with conflict markers by a failed squash (unmerged index entries).
|
|
16
|
+
async function conflictedFiles(repoPath: string): Promise<string[]> {
|
|
17
|
+
const result = await $`git -C ${repoPath} diff --name-only --diff-filter=U`.quiet().nothrow()
|
|
18
|
+
if (result.exitCode !== 0) return []
|
|
19
|
+
return result
|
|
20
|
+
.text()
|
|
21
|
+
.split('\n')
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => line !== '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function countStaged(repoPath: string): Promise<number> {
|
|
27
|
+
const result = await $`git -C ${repoPath} diff --cached --name-only`.quiet().nothrow()
|
|
28
|
+
if (result.exitCode !== 0) return 0
|
|
29
|
+
return result
|
|
30
|
+
.text()
|
|
31
|
+
.split('\n')
|
|
32
|
+
.filter((line) => line.trim() !== '').length
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Squashes everything a source worktree changed since its fork point into the
|
|
36
|
+
// target worktree's working tree (staged, uncommitted) — committed work plus
|
|
37
|
+
// any uncommitted/untracked changes. The source is left exactly as it was; the
|
|
38
|
+
// caller decides whether to delete it. The target must be clean so two change
|
|
39
|
+
// sets are never silently fused. On conflict nothing is kept: the target is
|
|
40
|
+
// reset and the source restored.
|
|
41
|
+
export async function moveWorktree(opts: {
|
|
42
|
+
sourcePath: string
|
|
43
|
+
sourceBranch: string
|
|
44
|
+
targetPath: string
|
|
45
|
+
}): Promise<MoveWorktreeResult> {
|
|
46
|
+
const { sourceBranch, sourcePath, targetPath } = opts
|
|
47
|
+
try {
|
|
48
|
+
if (await workingTreeDirty(targetPath)) return { kind: 'dirty-target' }
|
|
49
|
+
|
|
50
|
+
const headResult = await $`git -C ${sourcePath} rev-parse HEAD`.quiet().nothrow()
|
|
51
|
+
if (headResult.exitCode !== 0) {
|
|
52
|
+
return { kind: 'error', message: 'could not resolve source HEAD' }
|
|
53
|
+
}
|
|
54
|
+
const sourceHead = headResult.text().trim()
|
|
55
|
+
|
|
56
|
+
// Capture uncommitted + untracked work in a throwaway commit so the squash
|
|
57
|
+
// includes everything; undone again before we return.
|
|
58
|
+
const tempCommitted = await workingTreeDirty(sourcePath)
|
|
59
|
+
if (tempCommitted) {
|
|
60
|
+
await $`git -C ${sourcePath} add -A`.quiet().nothrow()
|
|
61
|
+
const commit = await $`git -C ${sourcePath} commit -m ${'aimux: move WIP'} --no-verify`
|
|
62
|
+
.quiet()
|
|
63
|
+
.nothrow()
|
|
64
|
+
if (commit.exitCode !== 0) {
|
|
65
|
+
const message = commit.stderr.toString().trim()
|
|
66
|
+
return {
|
|
67
|
+
kind: 'error',
|
|
68
|
+
message: message !== '' ? message : 'failed to stage source changes',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const restoreSource = async (): Promise<void> => {
|
|
74
|
+
if (tempCommitted) {
|
|
75
|
+
await $`git -C ${sourcePath} reset --mixed ${sourceHead}`.quiet().nothrow()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const merge = await $`git -C ${targetPath} merge --squash ${sourceBranch}`.quiet().nothrow()
|
|
80
|
+
const conflicts = await conflictedFiles(targetPath)
|
|
81
|
+
if (merge.exitCode !== 0 || conflicts.length > 0) {
|
|
82
|
+
await $`git -C ${targetPath} merge --abort`.quiet().nothrow()
|
|
83
|
+
await $`git -C ${targetPath} reset --hard HEAD`.quiet().nothrow()
|
|
84
|
+
await restoreSource()
|
|
85
|
+
if (conflicts.length > 0) return { files: conflicts, kind: 'conflict' }
|
|
86
|
+
const message = merge.stderr.toString().trim()
|
|
87
|
+
return { kind: 'error', message: message !== '' ? message : 'merge failed' }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const filesChanged = await countStaged(targetPath)
|
|
91
|
+
await restoreSource()
|
|
92
|
+
return { filesChanged, kind: 'ok' }
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return { kind: 'error', message: error instanceof Error ? error.message : String(error) }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -11,10 +11,11 @@ import { discoverRepos } from './repo-discovery'
|
|
|
11
11
|
export function useRepoDiscovery(projectPath: string | undefined): void {
|
|
12
12
|
useEffect(() => {
|
|
13
13
|
const cfg = getMultiRepoConfig()
|
|
14
|
-
if (!cfg.enabled || !projectPath) {
|
|
14
|
+
if (!cfg.enabled || !(projectPath != null && projectPath !== '')) {
|
|
15
15
|
dispatchGlobal({ type: 'multi-repo-clear' })
|
|
16
16
|
return
|
|
17
17
|
}
|
|
18
|
+
dispatchGlobal({ type: 'multi-repo-clear' })
|
|
18
19
|
let cancelled = false
|
|
19
20
|
void (async () => {
|
|
20
21
|
const repos = await discoverRepos(projectPath, cfg.maxDepth)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { BranchDivergence } from '../state/types'
|
|
4
|
+
|
|
5
|
+
import { useAppStore } from '../state/app-store'
|
|
6
|
+
import { dispatchGlobal } from '../state/dispatch-ref'
|
|
7
|
+
import { getBranchDivergence } from './divergence'
|
|
8
|
+
|
|
9
|
+
const INTERVAL_MS = 4000
|
|
10
|
+
|
|
11
|
+
// Polls per-worktree base divergence for the current session while enabled and
|
|
12
|
+
// dispatches it into worktreeDivergence. Only worktrees that record a baseRef
|
|
13
|
+
// (the aimux-created ones) are measured; the primary and externally-discovered
|
|
14
|
+
// worktrees have no recorded base and are left out.
|
|
15
|
+
export function useWorktreeDivergencePolling(enabled: boolean): void {
|
|
16
|
+
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
17
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!enabled) return
|
|
21
|
+
const session =
|
|
22
|
+
currentSessionId != null && currentSessionId !== ''
|
|
23
|
+
? sessions.find((s) => s.id === currentSessionId)
|
|
24
|
+
: undefined
|
|
25
|
+
const targets = (session?.worktrees ?? []).filter(
|
|
26
|
+
(w) => w.baseRef != null && w.baseRef !== '' && w.branch != null && w.branch !== ''
|
|
27
|
+
)
|
|
28
|
+
if (targets.length === 0) return
|
|
29
|
+
|
|
30
|
+
let cancelled = false
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
32
|
+
|
|
33
|
+
const tick = async () => {
|
|
34
|
+
const entries = await Promise.all(
|
|
35
|
+
targets.map(async (worktree) => {
|
|
36
|
+
const base = worktree.baseRef
|
|
37
|
+
const branch = worktree.branch
|
|
38
|
+
if (base == null || branch == null) return null
|
|
39
|
+
const divergence = await getBranchDivergence(worktree.repoRoot, base, branch)
|
|
40
|
+
return divergence != null ? ([worktree.id, divergence] as const) : null
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
if (cancelled) return
|
|
44
|
+
const next: Record<string, BranchDivergence> = {}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (entry != null) next[entry[0]] = entry[1]
|
|
47
|
+
}
|
|
48
|
+
dispatchGlobal({ divergence: next, type: 'set-worktree-divergence' })
|
|
49
|
+
timer = setTimeout(() => void tick(), INTERVAL_MS)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
void tick()
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
cancelled = true
|
|
56
|
+
if (timer != null) clearTimeout(timer)
|
|
57
|
+
}
|
|
58
|
+
}, [enabled, currentSessionId, sessions])
|
|
59
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
|
|
5
|
+
|
|
6
|
+
export interface GitWorktreeInfo {
|
|
7
|
+
path: string
|
|
8
|
+
head?: string
|
|
9
|
+
branch?: string
|
|
10
|
+
// True when git still tracks the worktree but its directory is gone
|
|
11
|
+
// (e.g. a temp dir cleared on reboot). Such entries are stale admin junk.
|
|
12
|
+
prunable?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Returns the main worktree's path, i.e. the original repository checkout.
|
|
16
|
+
// `git worktree list` always reports the main worktree first, so this stays
|
|
17
|
+
// correct even when called from inside a linked worktree — unlike
|
|
18
|
+
// `rev-parse --show-toplevel`, which returns the *current* worktree's path.
|
|
19
|
+
export async function getMainWorktreeRoot(cwd: string): Promise<string | undefined> {
|
|
20
|
+
const worktrees = await listGitWorktrees(cwd)
|
|
21
|
+
return worktrees[0]?.path
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getCurrentBranch(cwd: string): Promise<string | undefined> {
|
|
25
|
+
const result = await $`git -C ${cwd} branch --show-current`.quiet().nothrow()
|
|
26
|
+
if (result.exitCode !== 0) return undefined
|
|
27
|
+
return result.text().trim() || undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getHeadSha(cwd: string): Promise<string | undefined> {
|
|
31
|
+
const result = await $`git -C ${cwd} rev-parse HEAD`.quiet().nothrow()
|
|
32
|
+
if (result.exitCode !== 0) return undefined
|
|
33
|
+
return result.text().trim() || undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createGitWorktree({
|
|
37
|
+
baseRef,
|
|
38
|
+
branchName,
|
|
39
|
+
repoPath,
|
|
40
|
+
targetPath,
|
|
41
|
+
}: {
|
|
42
|
+
repoPath: string
|
|
43
|
+
targetPath: string
|
|
44
|
+
branchName: string
|
|
45
|
+
baseRef: string
|
|
46
|
+
}): Promise<void> {
|
|
47
|
+
const result = await $`git -C ${repoPath} worktree add -b ${branchName} ${targetPath} ${baseRef}`
|
|
48
|
+
.quiet()
|
|
49
|
+
.nothrow()
|
|
50
|
+
if (result.exitCode !== 0) {
|
|
51
|
+
throw new Error(result.stderr.toString().trim() || 'failed to create git worktree')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function removeGitWorktree({
|
|
56
|
+
force,
|
|
57
|
+
repoPath,
|
|
58
|
+
targetPath,
|
|
59
|
+
}: {
|
|
60
|
+
repoPath: string
|
|
61
|
+
targetPath: string
|
|
62
|
+
force: boolean
|
|
63
|
+
}): Promise<void> {
|
|
64
|
+
if (!isInsideAimuxWorktreeRoot(targetPath)) {
|
|
65
|
+
throw new Error(`refusing to delete worktree outside Aimux temp root: ${targetPath}`)
|
|
66
|
+
}
|
|
67
|
+
const result = force
|
|
68
|
+
? await $`git -C ${repoPath} worktree remove --force ${targetPath}`.quiet().nothrow()
|
|
69
|
+
: await $`git -C ${repoPath} worktree remove ${targetPath}`.quiet().nothrow()
|
|
70
|
+
if (result.exitCode === 0) return
|
|
71
|
+
// The worktree directory may already be gone, leaving only stale admin state
|
|
72
|
+
// in the main repo. Pruning clears it so deletion still reaches its goal.
|
|
73
|
+
if (!existsSync(targetPath)) {
|
|
74
|
+
await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
throw new Error(result.stderr.toString().trim() || 'failed to remove git worktree')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function listGitWorktrees(repoPath: string): Promise<GitWorktreeInfo[]> {
|
|
81
|
+
const result = await $`git -C ${repoPath} worktree list --porcelain`.quiet().nothrow()
|
|
82
|
+
if (result.exitCode !== 0) return []
|
|
83
|
+
const worktrees: GitWorktreeInfo[] = []
|
|
84
|
+
let current: GitWorktreeInfo | null = null
|
|
85
|
+
for (const line of result.text().split('\n')) {
|
|
86
|
+
if (line.startsWith('worktree ')) {
|
|
87
|
+
if (current) worktrees.push(current)
|
|
88
|
+
current = { path: line.slice('worktree '.length) }
|
|
89
|
+
} else if (current && line.startsWith('HEAD ')) {
|
|
90
|
+
current.head = line.slice('HEAD '.length)
|
|
91
|
+
} else if (current && line.startsWith('branch ')) {
|
|
92
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
|
|
93
|
+
} else if (current && line.startsWith('prunable')) {
|
|
94
|
+
current.prunable = true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (current) worktrees.push(current)
|
|
98
|
+
return worktrees
|
|
99
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -77,7 +77,7 @@ logDebug('index.userConfigLoaded', {
|
|
|
77
77
|
// plain code so we can re-tokenize on the snapshot. Set on process.env
|
|
78
78
|
// before backend bootstrap so the spawned daemon (and its child PTYs)
|
|
79
79
|
// inherit it.
|
|
80
|
-
if (resolvedConfig.theme?.beta?.experimentalSyntaxHighlight) {
|
|
80
|
+
if (resolvedConfig.theme?.beta?.experimentalSyntaxHighlight === true) {
|
|
81
81
|
process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT = 'false'
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -56,16 +56,22 @@ export function describeBindings(
|
|
|
56
56
|
for (const binding of mode.bindings) {
|
|
57
57
|
const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
|
|
58
58
|
|
|
59
|
-
if (options.mergeAlternativesByDescription) {
|
|
59
|
+
if (options.mergeAlternativesByDescription === true) {
|
|
60
60
|
const actionSignature = getActionSignature(binding.result)
|
|
61
|
-
const existingIndex =
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const existingIndex =
|
|
62
|
+
binding.description != null && binding.description !== ''
|
|
63
|
+
? resultIndexByDescription.get(binding.description)
|
|
64
|
+
: resultIndexByAction.get(actionSignature)
|
|
64
65
|
|
|
65
66
|
if (existingIndex === undefined) {
|
|
66
|
-
if (
|
|
67
|
+
if (
|
|
68
|
+
options.withDescriptionOnly === true &&
|
|
69
|
+
!(binding.description != null && binding.description !== '')
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
67
72
|
const nextIndex = result.length
|
|
68
|
-
if (binding.description
|
|
73
|
+
if (binding.description != null && binding.description !== '')
|
|
74
|
+
resultIndexByDescription.set(binding.description, nextIndex)
|
|
69
75
|
resultIndexByAction.set(actionSignature, nextIndex)
|
|
70
76
|
seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
|
|
71
77
|
result.push({
|
|
@@ -83,7 +89,11 @@ export function describeBindings(
|
|
|
83
89
|
seenKeysDisplay.add(keysDisplay)
|
|
84
90
|
const existing = result[existingIndex]
|
|
85
91
|
if (!existing) continue
|
|
86
|
-
if (
|
|
92
|
+
if (
|
|
93
|
+
(existing.description == null || existing.description === '') &&
|
|
94
|
+
binding.description != null &&
|
|
95
|
+
binding.description !== ''
|
|
96
|
+
) {
|
|
87
97
|
existing.description = binding.description
|
|
88
98
|
existing.group = binding.group
|
|
89
99
|
resultIndexByDescription.set(binding.description, existingIndex)
|
|
@@ -93,9 +103,17 @@ export function describeBindings(
|
|
|
93
103
|
continue
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
if (
|
|
106
|
+
if (
|
|
107
|
+
options.withDescriptionOnly === true &&
|
|
108
|
+
!(binding.description != null && binding.description !== '')
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
97
111
|
|
|
98
|
-
if (
|
|
112
|
+
if (
|
|
113
|
+
options.dedupeByDescription === true &&
|
|
114
|
+
binding.description != null &&
|
|
115
|
+
binding.description !== ''
|
|
116
|
+
) {
|
|
99
117
|
if (seenDescriptions.has(binding.description)) continue
|
|
100
118
|
seenDescriptions.add(binding.description)
|
|
101
119
|
}
|
|
@@ -77,7 +77,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
|
|
|
77
77
|
|
|
78
78
|
// Handle <leader>
|
|
79
79
|
if (inner.toLowerCase() === 'leader') {
|
|
80
|
-
if (!leader) {
|
|
80
|
+
if (!(leader != null && leader !== '')) {
|
|
81
81
|
throw new Error('parseKeyNotation: <leader> used but no leader key configured')
|
|
82
82
|
}
|
|
83
83
|
chords.push(leader)
|
|
@@ -102,7 +102,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
|
|
|
102
102
|
function parseAngleBracketToken(inner: string): KeyChord {
|
|
103
103
|
// Check if it's a special name (no modifiers)
|
|
104
104
|
const specialInner = SPECIAL_NAMES[inner]
|
|
105
|
-
if (specialInner) {
|
|
105
|
+
if (specialInner != null && specialInner !== '') {
|
|
106
106
|
return specialInner
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -120,11 +120,11 @@ function parseAngleBracketToken(inner: string): KeyChord {
|
|
|
120
120
|
else if (mod === 'S') shift = true
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
let keyName = parts
|
|
123
|
+
let keyName = parts.at(-1) ?? ''
|
|
124
124
|
|
|
125
125
|
// Check if the key part is a special name
|
|
126
126
|
const specialKey = SPECIAL_NAMES[keyName]
|
|
127
|
-
if (specialKey) {
|
|
127
|
+
if (specialKey != null && specialKey !== '') {
|
|
128
128
|
keyName = specialKey
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -14,14 +14,14 @@ const SPECIAL_DISPLAY: Record<string, string> = {
|
|
|
14
14
|
|
|
15
15
|
function formatModifierKey(baseName: string): string {
|
|
16
16
|
const special = SPECIAL_DISPLAY[baseName]
|
|
17
|
-
if (special) return special
|
|
17
|
+
if (special != null && special !== '') return special
|
|
18
18
|
if (baseName.length === 1) return baseName.toUpperCase()
|
|
19
19
|
return baseName.charAt(0).toUpperCase() + baseName.slice(1)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** Convert a single canonical KeyChord back to a human-readable label. */
|
|
23
23
|
export function formatChord(chord: KeyChord, leader?: KeyChord): string {
|
|
24
|
-
if (leader && chord === leader) return '<leader>'
|
|
24
|
+
if (leader != null && leader !== '' && chord === leader) return '<leader>'
|
|
25
25
|
|
|
26
26
|
let ctrl = false
|
|
27
27
|
let meta = false
|
|
@@ -135,7 +135,7 @@ export class SequenceResolver {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
private updateRepeatOnResolve(chord: KeyChord, binding: TrieBinding): void {
|
|
138
|
-
if (binding.repeatable) {
|
|
138
|
+
if (binding.repeatable === true) {
|
|
139
139
|
this.repeatTerminal = chord
|
|
140
140
|
this.repeatBinding = binding
|
|
141
141
|
} else {
|
package/src/input/keymap/trie.ts
CHANGED
|
@@ -52,7 +52,7 @@ export class KeyTrie {
|
|
|
52
52
|
remove(sequence: KeyChord[]): boolean {
|
|
53
53
|
if (sequence.length === 0) return false
|
|
54
54
|
|
|
55
|
-
const path:
|
|
55
|
+
const path: { node: TrieNode; chord: KeyChord }[] = []
|
|
56
56
|
let node = this.root
|
|
57
57
|
|
|
58
58
|
for (const chord of sequence) {
|
|
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
26
26
|
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
27
27
|
'ai-usage': 'modal.ai-usage',
|
|
28
28
|
'update-available': 'modal.update-available',
|
|
29
|
+
'worktree-move': 'modal.worktree-move',
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function deriveModeId(state: AppState): ModeId {
|
|
@@ -35,6 +36,12 @@ export function deriveModeId(state: AppState): ModeId {
|
|
|
35
36
|
return 'modal.help.filtering'
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// Overlay on top of git mode without flipping focusMode (like help) — route
|
|
40
|
+
// input to the picker while it's open even though focus stays 'git'.
|
|
41
|
+
if (state.modal.type === 'worktree-move') {
|
|
42
|
+
return 'modal.worktree-move'
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
|
|
39
46
|
if (directMode) {
|
|
40
47
|
return directMode
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModeId } from './types'
|
|
2
2
|
|
|
3
3
|
const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
4
|
-
'git-mode': ['navigation', 'modal.git-commit'],
|
|
4
|
+
'git-mode': ['navigation', 'modal.git-commit', 'modal.worktree-move'],
|
|
5
5
|
'modal.ai-usage': ['navigation', 'terminal-input'],
|
|
6
6
|
'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
|
|
7
7
|
'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
|
|
@@ -18,6 +18,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
18
18
|
'modal.split-picker': ['navigation', 'terminal-input'],
|
|
19
19
|
'modal.theme-picker.filtering': ['navigation'],
|
|
20
20
|
'modal.update-available': ['navigation'],
|
|
21
|
+
'modal.worktree-move': ['git-mode', 'navigation'],
|
|
21
22
|
'navigation': [
|
|
22
23
|
'terminal-input',
|
|
23
24
|
'modal.new-tab.command-edit',
|