@brimveyn/aimux 1.9.0 → 1.9.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.
Files changed (73) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +13 -3
  3. package/src/app.tsx +26 -17
  4. package/src/git/git-poller.ts +56 -3
  5. package/src/git/repo-discovery.ts +95 -0
  6. package/src/git/use-repo-discovery.ts +28 -0
  7. package/src/input/keymap/describe-bindings.ts +63 -2
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/state/reducers/git-mode-state.ts +58 -0
  10. package/src/state/reducers/git-panel-state.ts +3 -0
  11. package/src/state/reducers/multi-repo-state.ts +60 -0
  12. package/src/state/store.ts +6 -0
  13. package/src/state/types.ts +38 -6
  14. package/src/ui/breaking-update-screen.tsx +2 -2
  15. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  17. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  18. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  19. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  20. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  22. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
  23. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  24. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  25. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
  26. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
  27. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  28. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
  29. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  30. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  31. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  32. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  33. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  34. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  35. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  36. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  37. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  38. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  39. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  40. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  41. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  42. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  43. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  44. package/src/ui/components/modals/shared/form.tsx +164 -0
  45. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  46. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  47. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  48. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  49. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  50. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  51. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  52. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  53. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  54. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  55. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  56. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  57. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  58. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  59. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  60. package/src/ui/root.tsx +24 -22
  61. package/src/ui/theme-store.ts +30 -26
  62. package/src/ui/themes.ts +1 -0
  63. package/src/ui/components/create-session-modal.tsx +0 -96
  64. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  65. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  66. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  67. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  68. package/src/ui/components/session-name-modal.tsx +0 -11
  69. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  70. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  71. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  72. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  73. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -0,0 +1,240 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import type { DiffSegment } from './build-rows'
6
+
7
+ import { useAppStore } from '../../../../state/app-store'
8
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
9
+ import { getHighlightsTokens, getParsedPayload } from '../../../../state/types'
10
+ import { type HighlightPatch, tokenizeSegment } from './highlight'
11
+ import { prepareDiff, type PreparedParsedDiff } from './prepare-diff'
12
+
13
+ export interface DiffPreparation {
14
+ file: PreparedParsedDiff['file']
15
+ filetype: string | null
16
+ firstChangeOffset: number
17
+ highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
18
+ preparing: boolean
19
+ ready: boolean
20
+ requestSegmentHighlights: (segments: readonly DiffSegment[]) => void
21
+ segments: DiffSegment[]
22
+ }
23
+
24
+ const EMPTY_HIGHLIGHTS = { add: [] as ThemedToken[][], del: [] as ThemedToken[][] }
25
+ const EMPTY_PARSED: PreparedParsedDiff = {
26
+ file: null,
27
+ filetype: null,
28
+ firstChangeOffset: -1,
29
+ segments: [],
30
+ }
31
+ const MAX_HIGHLIGHT_SEGMENTS_PER_PASS = 8
32
+
33
+ function applyHighlightPatches(
34
+ source: { add: ThemedToken[][]; del: ThemedToken[][] },
35
+ add: readonly HighlightPatch[],
36
+ del: readonly HighlightPatch[]
37
+ ): { add: ThemedToken[][]; del: ThemedToken[][] } {
38
+ const nextAdd = [...source.add]
39
+ const nextDel = [...source.del]
40
+ for (const patch of add) {
41
+ for (let i = 0; i < patch.tokens.length; i++) nextAdd[patch.start + i] = patch.tokens[i] ?? []
42
+ }
43
+ for (const patch of del) {
44
+ for (let i = 0; i < patch.tokens.length; i++) nextDel[patch.start + i] = patch.tokens[i] ?? []
45
+ }
46
+ return { add: nextAdd, del: nextDel }
47
+ }
48
+
49
+ function segmentHasHighlights(
50
+ segment: DiffSegment,
51
+ highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
52
+ ): boolean {
53
+ if (segment.kind === 'context') {
54
+ for (let i = 0; i < segment.count; i++) {
55
+ if (!highlights.add[segment.addStart + i] || !highlights.del[segment.delStart + i])
56
+ return false
57
+ }
58
+ return true
59
+ }
60
+ if (segment.kind === 'change') {
61
+ for (let i = 0; i < segment.additions; i++) {
62
+ if (!highlights.add[segment.addStart + i]) return false
63
+ }
64
+ for (let i = 0; i < segment.deletions; i++) {
65
+ if (!highlights.del[segment.delStart + i]) return false
66
+ }
67
+ return true
68
+ }
69
+ return true
70
+ }
71
+
72
+ export function useDiffPreparation(
73
+ cacheKey: string,
74
+ diff: string,
75
+ path: string,
76
+ themeId: string
77
+ ): DiffPreparation {
78
+ const requestOwner = `${cacheKey}|${themeId}|${diff}`
79
+ const cachedParsed = useAppStore((s) => s.gitMode.parsedFiles[cacheKey])
80
+ const highlightKey = `${cacheKey}|${themeId}`
81
+ const cachedHighlights = useAppStore((s) => s.gitMode.highlights[highlightKey])
82
+
83
+ const [preparing, setPreparing] = useState(false)
84
+ const [localParsed, setLocalParsed] = useState<PreparedParsedDiff | null>(null)
85
+ const [localHighlights, setLocalHighlights] = useState(EMPTY_HIGHLIGHTS)
86
+ const [localHash, setLocalHash] = useState<string | null>(null)
87
+ const completedSegmentsRef = useRef(new Set<string>())
88
+ const currentOwnerRef = useRef(requestOwner)
89
+ const pendingSegmentsRef = useRef(new Set<string>())
90
+ const requestVersionRef = useRef(0)
91
+ const localOwnerMatches = currentOwnerRef.current === requestOwner
92
+
93
+ const parsed = useMemo(() => {
94
+ return (
95
+ getParsedPayload<PreparedParsedDiff>(cachedParsed) ??
96
+ (localOwnerMatches ? localParsed : null) ??
97
+ EMPTY_PARSED
98
+ )
99
+ }, [cachedParsed, localOwnerMatches, localParsed])
100
+
101
+ const highlights = useMemo(() => {
102
+ const cached = getHighlightsTokens(cachedHighlights)
103
+ if (cached && cachedHighlights?.hash === cachedParsed?.hash) return cached
104
+ return localOwnerMatches ? localHighlights : EMPTY_HIGHLIGHTS
105
+ }, [cachedHighlights, cachedParsed, localHighlights, localOwnerMatches])
106
+
107
+ const ready = !!parsed.file
108
+
109
+ useLayoutEffect(() => {
110
+ currentOwnerRef.current = requestOwner
111
+ requestVersionRef.current += 1
112
+ completedSegmentsRef.current.clear()
113
+ pendingSegmentsRef.current.clear()
114
+ setLocalParsed(null)
115
+ setLocalHighlights(EMPTY_HIGHLIGHTS)
116
+ setLocalHash(null)
117
+ setPreparing(false)
118
+ }, [cacheKey, diff, path, requestOwner, themeId])
119
+
120
+ useEffect(() => {
121
+ if (cachedParsed) return
122
+
123
+ const controller = new AbortController()
124
+ const owner = requestOwner
125
+ const version = requestVersionRef.current
126
+ setPreparing(true)
127
+ void prepareDiff(diff, path, { signal: controller.signal })
128
+ .then((result) => {
129
+ if (
130
+ controller.signal.aborted ||
131
+ requestVersionRef.current !== version ||
132
+ currentOwnerRef.current !== owner
133
+ ) {
134
+ return
135
+ }
136
+ setLocalParsed(result.parsed)
137
+ setLocalHighlights(result.highlights)
138
+ setLocalHash(result.hash)
139
+ dispatchGlobal({
140
+ file: result.parsed,
141
+ hash: result.hash,
142
+ key: cacheKey,
143
+ type: 'git-mode-set-parsed',
144
+ })
145
+ })
146
+ .catch(() => {})
147
+ .finally(() => {
148
+ if (
149
+ !controller.signal.aborted &&
150
+ requestVersionRef.current === version &&
151
+ currentOwnerRef.current === owner
152
+ ) {
153
+ setPreparing(false)
154
+ }
155
+ })
156
+
157
+ return () => {
158
+ controller.abort()
159
+ }
160
+ }, [cacheKey, cachedParsed, diff, path, requestOwner, themeId])
161
+
162
+ const requestSegmentHighlights = useCallback(
163
+ (segments: readonly DiffSegment[]) => {
164
+ const file = parsed.file
165
+ const filetype = parsed.filetype
166
+ if (!file || !filetype) return
167
+ const owner = requestOwner
168
+ const version = requestVersionRef.current
169
+ const activeHash = cachedParsed?.hash ?? (localOwnerMatches ? localHash : null)
170
+ if (!activeHash) return
171
+ const next = segments.filter(
172
+ (segment) =>
173
+ (segment.kind === 'context' || segment.kind === 'change') &&
174
+ !completedSegmentsRef.current.has(segment.id) &&
175
+ !pendingSegmentsRef.current.has(segment.id) &&
176
+ !segmentHasHighlights(segment, highlights)
177
+ )
178
+ const queue = next.slice(0, MAX_HIGHLIGHT_SEGMENTS_PER_PASS)
179
+ if (queue.length === 0) return
180
+ for (const segment of queue) pendingSegmentsRef.current.add(segment.id)
181
+ void (async () => {
182
+ const add: HighlightPatch[] = []
183
+ const del: HighlightPatch[] = []
184
+ try {
185
+ for (const segment of queue) {
186
+ if (requestVersionRef.current !== version || currentOwnerRef.current !== owner) return
187
+ const patch = await tokenizeSegment(file, segment, filetype)
188
+ add.push(...patch.add)
189
+ del.push(...patch.del)
190
+ }
191
+ } finally {
192
+ if (requestVersionRef.current === version && currentOwnerRef.current === owner) {
193
+ for (const segment of queue) {
194
+ pendingSegmentsRef.current.delete(segment.id)
195
+ completedSegmentsRef.current.add(segment.id)
196
+ }
197
+ }
198
+ }
199
+ if (
200
+ requestVersionRef.current !== version ||
201
+ currentOwnerRef.current !== owner ||
202
+ (add.length === 0 && del.length === 0)
203
+ ) {
204
+ return
205
+ }
206
+ setLocalHighlights((current) => applyHighlightPatches(current, add, del))
207
+ dispatchGlobal({
208
+ add,
209
+ del,
210
+ hash: activeHash,
211
+ key: cacheKey,
212
+ themeId,
213
+ type: 'git-mode-merge-highlights',
214
+ })
215
+ })()
216
+ },
217
+ [
218
+ cacheKey,
219
+ cachedParsed?.hash,
220
+ highlights,
221
+ localHash,
222
+ localOwnerMatches,
223
+ parsed.file,
224
+ parsed.filetype,
225
+ requestOwner,
226
+ themeId,
227
+ ]
228
+ )
229
+
230
+ return {
231
+ file: parsed.file,
232
+ filetype: parsed.filetype,
233
+ firstChangeOffset: parsed.firstChangeOffset,
234
+ highlights,
235
+ preparing,
236
+ ready,
237
+ requestSegmentHighlights,
238
+ segments: parsed.segments,
239
+ }
240
+ }
@@ -0,0 +1,95 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { useEffect, useMemo, useRef, useState } from 'react'
4
+
5
+ import type { DiffSegment } from './build-rows'
6
+
7
+ interface VirtualizedSegment<T extends DiffSegment> {
8
+ bottomSpacer: number
9
+ topSpacer: number
10
+ totalHeight: number
11
+ visible: T[]
12
+ }
13
+
14
+ interface Options<T extends DiffSegment> {
15
+ estimateHeight: (segment: T) => number
16
+ overscan: number
17
+ scrollRef: React.RefObject<ScrollBoxRenderable | null>
18
+ segments: readonly T[]
19
+ version: number
20
+ }
21
+
22
+ interface ViewportState {
23
+ height: number
24
+ scrollTop: number
25
+ }
26
+
27
+ function sameViewport(a: ViewportState, b: ViewportState): boolean {
28
+ return a.height === b.height && a.scrollTop === b.scrollTop
29
+ }
30
+
31
+ export function useSegmentVirtualization<T extends DiffSegment>({
32
+ estimateHeight,
33
+ overscan,
34
+ scrollRef,
35
+ segments,
36
+ version,
37
+ }: Options<T>): VirtualizedSegment<T> {
38
+ const [viewport, setViewport] = useState<ViewportState>({ height: 0, scrollTop: 0 })
39
+ const estimateHeightRef = useRef(estimateHeight)
40
+
41
+ useEffect(() => {
42
+ estimateHeightRef.current = estimateHeight
43
+ }, [estimateHeight])
44
+
45
+ useEffect(() => {
46
+ let frame = 0
47
+ const tick = (): void => {
48
+ const node = scrollRef.current
49
+ const next = {
50
+ height: Math.max(1, node?.viewport.height ?? 0),
51
+ scrollTop: Math.max(0, node?.scrollTop ?? 0),
52
+ }
53
+ setViewport((prev) => (sameViewport(prev, next) ? prev : next))
54
+ frame = requestAnimationFrame(tick)
55
+ }
56
+ frame = requestAnimationFrame(tick)
57
+ return () => cancelAnimationFrame(frame)
58
+ }, [scrollRef])
59
+
60
+ return useMemo(() => {
61
+ void version
62
+ if (segments.length === 0) {
63
+ return { bottomSpacer: 0, topSpacer: 0, totalHeight: 0, visible: [] }
64
+ }
65
+ const topTarget = Math.max(0, viewport.scrollTop - overscan)
66
+ const bottomTarget = viewport.scrollTop + viewport.height + overscan
67
+ let totalHeight = 0
68
+ let topSpacer = 0
69
+ let start = 0
70
+ while (start < segments.length) {
71
+ const height = estimateHeightRef.current(segments[start] as T)
72
+ if (totalHeight + height > topTarget) break
73
+ topSpacer += height
74
+ totalHeight += height
75
+ start += 1
76
+ }
77
+ let end = start
78
+ while (end < segments.length && totalHeight < bottomTarget) {
79
+ totalHeight += estimateHeightRef.current(segments[end] as T)
80
+ end += 1
81
+ }
82
+ let bottomSpacer = 0
83
+ for (let i = end; i < segments.length; i++) {
84
+ bottomSpacer += estimateHeightRef.current(segments[i] as T)
85
+ }
86
+ totalHeight = topSpacer + bottomSpacer
87
+ for (let i = start; i < end; i++) totalHeight += estimateHeightRef.current(segments[i] as T)
88
+ return {
89
+ bottomSpacer,
90
+ topSpacer,
91
+ totalHeight,
92
+ visible: segments.slice(start, end) as T[],
93
+ }
94
+ }, [overscan, segments, version, viewport])
95
+ }
@@ -9,11 +9,16 @@ import type {
9
9
  GitPaneDiffCountConfig,
10
10
  GitPanelState,
11
11
  GitPanePathConfig,
12
- } from '../../state/types'
12
+ } from '../../../state/types'
13
13
 
14
- import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
15
- import { buildGitTreeRows, type GitTreeFileRow, type GitTreeFolderRow } from '../../state/git-tree'
16
- import { getCurrentTokens, getTransparent, useTokens, useTransparent } from '../theme'
14
+ import { useAppStore } from '../../../state/app-store'
15
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
16
+ import {
17
+ buildGitTreeRows,
18
+ type GitTreeFileRow,
19
+ type GitTreeFolderRow,
20
+ } from '../../../state/git-tree'
21
+ import { getCurrentTokens, getTransparent, useTokens, useTransparent } from '../../theme'
17
22
 
18
23
  interface GitPanelProps {
19
24
  collapsedFolders?: Record<string, true>
@@ -185,14 +190,20 @@ function renderFileRow(
185
190
  isSelected: boolean,
186
191
  fileListMode: GitFileListMode,
187
192
  pathConfig: GitPanePathConfig,
188
- diffCountConfig: GitPaneDiffCountConfig
193
+ diffCountConfig: GitPaneDiffCountConfig,
194
+ repoPrefixes: Record<string, string>
189
195
  ): ReactNode {
196
+ const t = getCurrentTokens()
190
197
  const file = row.file
191
198
  const hasNumstat = file.added !== null || file.removed !== null
192
- const bg = isSelected && !getTransparent() ? getCurrentTokens().selected : undefined
199
+ const bg = isSelected && !getTransparent() ? t.selected : undefined
193
200
  const onSelect = (): void => {
194
201
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
195
202
  }
203
+ // Repo disambiguation prefix: only in flat mode, only when the file came
204
+ // from a sub-repo (root repo files get an empty prefix).
205
+ const repoTag =
206
+ fileListMode === 'flat' && file.repoPath ? (repoPrefixes[file.repoPath] ?? '') : ''
196
207
  return (
197
208
  <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
198
209
  <box width={2} flexShrink={0} justifyContent="center">
@@ -200,6 +211,13 @@ function renderFileRow(
200
211
  <strong>{displayStatus(file)}</strong>
201
212
  </text>
202
213
  </box>
214
+ {repoTag ? (
215
+ <box flexShrink={0}>
216
+ <text fg={t.palette.primary} bg={bg}>
217
+ <strong>{repoTag}</strong>
218
+ </text>
219
+ </box>
220
+ ) : null}
203
221
  <box flexGrow={1} overflow="hidden" paddingLeft={fileListMode === 'tree' ? row.depth * 2 : 0}>
204
222
  {renderFileLabel(file, pathConfig, fileListMode)}
205
223
  </box>
@@ -220,7 +238,8 @@ function renderTreeSection(
220
238
  showListModeToggle: boolean,
221
239
  pathConfig: GitPanePathConfig,
222
240
  diffCountConfig: GitPaneDiffCountConfig,
223
- marginTop: number
241
+ marginTop: number,
242
+ repoPrefixes: Record<string, string>
224
243
  ): ReactNode {
225
244
  if (files.length === 0) return null
226
245
  const t = getCurrentTokens()
@@ -255,7 +274,8 @@ function renderTreeSection(
255
274
  row.key === selectedEntryKey,
256
275
  fileListMode,
257
276
  pathConfig,
258
- diffCountConfig
277
+ diffCountConfig,
278
+ repoPrefixes
259
279
  )
260
280
  )}
261
281
  </box>
@@ -313,6 +333,7 @@ export const GitPanel = memo(function GitPanel({
313
333
  }: GitPanelProps) {
314
334
  const t = useTokens()
315
335
  useTransparent()
336
+ const repoPrefixes = useAppStore((s) => s.multiRepo.prefixes)
316
337
  const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
317
338
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
318
339
  const tree = useMemo(
@@ -344,7 +365,7 @@ export const GitPanel = memo(function GitPanel({
344
365
  }, [selectedEntryKey, tree.visibleRows])
345
366
 
346
367
  return (
347
- <box flexDirection="column" flexGrow={1} gap={0}>
368
+ <box flexDirection="column" flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden" gap={0}>
348
369
  {hasRemoteTracking ? (
349
370
  <text fg={t.muted}>
350
371
  ↑{gitPanel.ahead} ↓{gitPanel.behind}
@@ -376,7 +397,8 @@ export const GitPanel = memo(function GitPanel({
376
397
  key === toggleSection,
377
398
  pathConfig,
378
399
  diffCountConfig,
379
- priorHasFiles ? 1 : 0
400
+ priorHasFiles ? 1 : 0,
401
+ repoPrefixes
380
402
  )
381
403
  })}
382
404
  </scrollbox>
@@ -1,17 +1,18 @@
1
1
  import { useTerminalDimensions } from '@opentui/react'
2
2
  import { memo, useEffect, useRef } from 'react'
3
3
 
4
- import type { DiffData, GitDiffView } from '../../state/types'
5
- import type { ThemeId } from '../themes'
4
+ import type { DiffData, GitDiffView } from '../../../state/types'
5
+ import type { ThemeId } from '../../themes'
6
6
 
7
- import { diffHash } from '../../git/diff-hash'
8
- import { fetchDiff } from '../../git/git-diff'
9
- import { useGitPanelPolling } from '../../git/git-poller'
10
- import { useAppStore } from '../../state/app-store'
11
- import { dispatchGlobal } from '../../state/dispatch-ref'
12
- import { getSelectedGitFile, gitFileKey } from '../../state/git-tree'
13
- import { setGitDiffScroller } from '../git-view-controls'
14
- import { useBg, useTokens } from '../theme'
7
+ import { diffHash } from '../../../git/diff-hash'
8
+ import { fetchDiff } from '../../../git/git-diff'
9
+ import { useGitPanelPolling } from '../../../git/git-poller'
10
+ import { useRepoDiscovery } from '../../../git/use-repo-discovery'
11
+ import { useAppStore } from '../../../state/app-store'
12
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
13
+ import { getSelectedGitFile, gitFileKey } from '../../../state/git-tree'
14
+ import { setGitDiffScroller } from '../../git-view-controls'
15
+ import { useBg, useTokens } from '../../theme'
15
16
  import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
16
17
  import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
17
18
  import { GitPanel } from './git-panel'
@@ -107,6 +108,7 @@ interface GitViewProps {
107
108
  export const GitView = memo(function GitView({ themeId }: GitViewProps) {
108
109
  const t = useTokens()
109
110
  const sidebarBg = useBg('elevated')
111
+ const actionBg = useBg('selected')
110
112
  const dimensions = useTerminalDimensions()
111
113
  const gitPane = useAppStore((s) => s.gitPane)
112
114
  const gitPanel = useAppStore((s) => s.gitPanel)
@@ -121,6 +123,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
121
123
  : undefined
122
124
  const projectPath = currentSession?.projectPath
123
125
 
126
+ useRepoDiscovery(projectPath)
124
127
  useGitPanelPolling({ enabled: focusMode === 'git', headOffset: gitMode.headOffset, projectPath })
125
128
 
126
129
  const fileBarWidth = Math.max(20, Math.floor(dimensions.width * gitPane.diffModeRatio))
@@ -164,7 +167,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
164
167
  if (!selectedDiffKey) return
165
168
  if (diff || loading) return
166
169
  dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
167
- void fetchDiff(projectPath, selectedFile, gitMode.headOffset)
170
+ void fetchDiff(selectedFile.repoPath ?? projectPath, selectedFile, gitMode.headOffset)
168
171
  .then((d) =>
169
172
  dispatchGlobal({
170
173
  diff: d,
@@ -206,7 +209,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
206
209
  }
207
210
 
208
211
  return (
209
- <box flexDirection="column" flexGrow={1}>
212
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
210
213
  <box flexDirection="row" flexGrow={1}>
211
214
  <box
212
215
  width={fileBarWidth}
@@ -214,25 +217,28 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
214
217
  backgroundColor={sidebarBg}
215
218
  padding={0}
216
219
  gap={0}
220
+ overflow="hidden"
217
221
  >
218
- <text fg={t.palette.primary}>
219
- <strong>aimux · git</strong>
220
- </text>
221
- {gitPanel.branch ? (
222
- <box flexDirection="row">
223
- <text fg={t.palette.primary}>{'\u{e702}'} </text>
224
- <text fg={t.muted}>{gitPanel.branch}</text>
225
- </box>
226
- ) : null}
227
- {gitMode.headOffset > 0 ? (
228
- <box flexDirection="row" gap={1}>
229
- <text fg={t.palette.warning}>
230
- <strong>HEAD~{gitMode.headOffset}</strong>
231
- </text>
232
- <text fg={t.muted}>[ newer · ] older</text>
233
- </box>
234
- ) : null}
235
- <text fg={t.hover}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
222
+ <box flexDirection="column" flexShrink={0}>
223
+ <text fg={t.palette.primary}>
224
+ <strong>aimux · git</strong>
225
+ </text>
226
+ {gitPanel.branch ? (
227
+ <box flexDirection="row">
228
+ <text fg={t.palette.primary}>{'\u{e702}'} </text>
229
+ <text fg={t.muted}>{gitPanel.branch}</text>
230
+ </box>
231
+ ) : null}
232
+ {gitMode.headOffset > 0 ? (
233
+ <box flexDirection="row" gap={1}>
234
+ <text fg={t.palette.warning}>
235
+ <strong>HEAD~{gitMode.headOffset}</strong>
236
+ </text>
237
+ <text fg={t.muted}>[ newer · ] older</text>
238
+ </box>
239
+ ) : null}
240
+ <text fg={t.hover}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
241
+ </box>
236
242
  <GitPanel
237
243
  collapsedFolders={gitMode.collapsedFolders}
238
244
  compact={gitPane.treeCompaction}
@@ -243,14 +249,43 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
243
249
  selectedEntryKey={gitMode.selectedEntryKey}
244
250
  />
245
251
  </box>
246
- <DiffStage
247
- diff={diff}
248
- diffKey={selectedDiffKey}
249
- diffRef={diffRef}
250
- loading={loading}
251
- themeId={themeId}
252
- view={gitMode.diffView}
253
- />
252
+ <box flexDirection="column" flexGrow={1} overflow="hidden">
253
+ <box
254
+ flexDirection="row"
255
+ alignItems="center"
256
+ paddingLeft={1}
257
+ paddingRight={1}
258
+ backgroundColor={sidebarBg}
259
+ >
260
+ <box flexGrow={1}>
261
+ <text fg={selectedFile ? t.palette.ink : t.muted}>
262
+ {selectedFile ? selectedFile.path : 'Diff'}
263
+ </text>
264
+ </box>
265
+ <box
266
+ paddingLeft={1}
267
+ paddingRight={1}
268
+ backgroundColor={actionBg}
269
+ onMouseDown={(event) => {
270
+ event.preventDefault()
271
+ event.stopPropagation()
272
+ dispatchGlobal({ type: 'exit-git-mode' })
273
+ }}
274
+ >
275
+ <text fg={t.palette.ink}>
276
+ <strong>Exit diff</strong>
277
+ </text>
278
+ </box>
279
+ </box>
280
+ <DiffStage
281
+ diff={diff}
282
+ diffKey={selectedDiffKey}
283
+ diffRef={diffRef}
284
+ loading={loading}
285
+ themeId={themeId}
286
+ view={gitMode.diffView}
287
+ />
288
+ </box>
254
289
  </box>
255
290
  {footerNode ? (
256
291
  <box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
@@ -1,5 +1,5 @@
1
- import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../state/types'
2
- import type { ContextMenuItem } from '../context-menu/controller'
1
+ import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../../../state/types'
2
+ import type { ContextMenuItem } from '../../../context-menu/controller'
3
3
 
4
4
  const GIT_PANE_MENU_OPTIONS: Array<{
5
5
  label: string
@@ -1,10 +1,11 @@
1
1
  import { memo, useRef } from 'react'
2
2
 
3
- import type { GitPanelState } from '../../state/types'
3
+ import type { GitPanelState } from '../../../../state/types'
4
4
 
5
- import { useGitPanelPolling } from '../../git/git-poller'
6
- import { useAppStore } from '../../state/app-store'
7
- import { GitPanel } from './git-panel'
5
+ import { useGitPanelPolling } from '../../../../git/git-poller'
6
+ import { useRepoDiscovery } from '../../../../git/use-repo-discovery'
7
+ import { useAppStore } from '../../../../state/app-store'
8
+ import { GitPanel } from '../git-panel'
8
9
 
9
10
  interface GitPaneWidgetProps {
10
11
  pollingEnabled: boolean
@@ -24,6 +25,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: Git
24
25
  : undefined
25
26
  const projectPath = currentSession?.projectPath
26
27
 
28
+ useRepoDiscovery(projectPath)
27
29
  useGitPanelPolling({ enabled: pollingEnabled, headOffset: 0, projectPath })
28
30
 
29
31
  const lastGoodRef = useRef<GitPanelState | null>(null)