@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.
Files changed (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +29 -13
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +563 -85
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +21 -11
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +19 -3
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +60 -45
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
  91. package/src/ui/components/git/git-panel.tsx +129 -83
  92. package/src/ui/components/git/git-view.tsx +96 -28
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +135 -117
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +27 -20
  103. package/src/ui/components/layout/terminal-pane.tsx +163 -120
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +29 -20
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
  109. package/src/ui/components/modals/shared/form.tsx +6 -6
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +71 -32
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
  114. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  115. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
  116. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
  117. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  118. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +23 -7
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +121 -38
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { ScrollBoxRenderable } from '@opentui/core'
2
2
 
3
- import { memo, type ReactNode, useEffect, useMemo, useRef } from 'react'
3
+ import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
4
4
 
5
5
  import type {
6
6
  GitFileEntry,
@@ -18,7 +18,7 @@ import {
18
18
  type GitTreeFileRow,
19
19
  type GitTreeFolderRow,
20
20
  } from '../../../state/git-tree'
21
- import { getCurrentTheme, getTransparent, useTheme, useTransparent } from '../../theme'
21
+ import { getCurrentTheme, useTheme, useTransparent } from '../../theme'
22
22
 
23
23
  interface GitPanelProps {
24
24
  collapsedFolders?: Record<string, true>
@@ -29,13 +29,16 @@ interface GitPanelProps {
29
29
  pathConfig?: GitPanePathConfig
30
30
  diffCountConfig?: GitPaneDiffCountConfig
31
31
  headOffset?: number
32
+ // When set, the single changed-files section is a worktree fork-point review
33
+ // ("vs <base>") rather than a HEAD~N history walk.
34
+ baseLabel?: string
32
35
  compact?: boolean
33
36
  }
34
37
 
35
- function sectionTitle(section: GitFileSection, headOffset: number): string {
38
+ function sectionTitle(section: GitFileSection, headOffset: number, baseLabel?: string): string {
36
39
  switch (section) {
37
40
  case 'historical':
38
- return `HEAD~${headOffset}`
41
+ return baseLabel != null && baseLabel !== '' ? baseLabel : `HEAD~${headOffset}`
39
42
  case 'staged':
40
43
  return 'Staged Changes'
41
44
  case 'unstaged':
@@ -105,8 +108,8 @@ function renderFileLabel(
105
108
  const displayPath = transform ? transform(file.path) : file.path
106
109
  const { basename, prefix } = splitPath(displayPath)
107
110
  const dir = stripTrailingSlash(prefix)
108
- const showDir = fileListMode === 'flat' && pathConfig.enabled && dir
109
- if (!file.renamedFrom) {
111
+ const showDir = fileListMode === 'flat' && pathConfig.enabled && dir !== ''
112
+ if (!(file.renamedFrom != null && file.renamedFrom !== '')) {
110
113
  return (
111
114
  <text selectable={false} wrapMode="none">
112
115
  <span fg={t.text}>{basename}</span>
@@ -164,23 +167,30 @@ function renderDiffCount(
164
167
  )
165
168
  }
166
169
 
167
- function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode {
168
- const t = getCurrentTheme()
169
- const bg = isSelected && !getTransparent() ? t.backgroundElement : undefined
170
- const onSelect = (): void => {
170
+ const FolderRow = memo(function FolderRow({
171
+ isSelected,
172
+ row,
173
+ }: {
174
+ row: GitTreeFolderRow
175
+ isSelected: boolean
176
+ }) {
177
+ const t = useTheme()
178
+ const transparent = useTransparent()
179
+ const bg = isSelected && !transparent ? t.backgroundElement : undefined
180
+ const onSelect = useCallback(() => {
171
181
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
172
- }
173
- const onToggle = (): void => {
182
+ }, [row.key])
183
+ const onToggle = useCallback(() => {
174
184
  dispatchGlobal({ key: row.key, type: 'git-mode-toggle-folder' })
175
- }
185
+ }, [row.key])
176
186
  return (
177
- <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
187
+ <box flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
178
188
  <box flexGrow={1} overflow="hidden" paddingLeft={row.depth * 2}>
179
189
  <box flexDirection="row" gap={1} onMouseDown={onToggle}>
180
190
  <text selectable={false} fg={t.textMuted} bg={bg}>
181
191
  {row.isCollapsed ? '▸' : '▾'}
182
192
  </text>
183
- <text selectable={false} fg={getCurrentTheme().textMuted} bg={bg}>
193
+ <text selectable={false} fg={t.textMuted} bg={bg}>
184
194
  {row.isCollapsed ? '\uf07b' : '\uf07c'}
185
195
  </text>
186
196
  <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
@@ -190,30 +200,43 @@ function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode
190
200
  </box>
191
201
  </box>
192
202
  )
193
- }
203
+ })
194
204
 
195
- function renderFileRow(
196
- row: GitTreeFileRow,
197
- addedW: number,
198
- removedW: number,
199
- isSelected: boolean,
200
- fileListMode: GitFileListMode,
201
- pathConfig: GitPanePathConfig,
202
- diffCountConfig: GitPaneDiffCountConfig,
205
+ const FileRow = memo(function FileRow({
206
+ addedW,
207
+ diffCountConfig,
208
+ fileListMode,
209
+ isSelected,
210
+ pathConfig,
211
+ removedW,
212
+ repoPrefixes,
213
+ row,
214
+ }: {
215
+ row: GitTreeFileRow
216
+ addedW: number
217
+ removedW: number
218
+ isSelected: boolean
219
+ fileListMode: GitFileListMode
220
+ pathConfig: GitPanePathConfig
221
+ diffCountConfig: GitPaneDiffCountConfig
203
222
  repoPrefixes: Record<string, string>
204
- ): ReactNode {
223
+ }) {
224
+ const t = useTheme()
225
+ const transparent = useTransparent()
205
226
  const file = row.file
206
227
  const hasNumstat = file.added !== null || file.removed !== null
207
- const bg = isSelected && !getTransparent() ? getCurrentTheme().backgroundElement : undefined
208
- const onSelect = (): void => {
228
+ const bg = isSelected && !transparent ? t.backgroundElement : undefined
229
+ const onSelect = useCallback(() => {
209
230
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
210
- }
231
+ }, [row.key])
211
232
  // Repo disambiguation prefix: only in flat mode, only when the file came
212
233
  // from a sub-repo (root repo files get an empty prefix).
213
234
  const repoTag =
214
- fileListMode === 'flat' && file.repoPath ? (repoPrefixes[file.repoPath] ?? '') : ''
235
+ fileListMode === 'flat' && file.repoPath != null && file.repoPath !== ''
236
+ ? (repoPrefixes[file.repoPath] ?? '')
237
+ : ''
215
238
  return (
216
- <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
239
+ <box flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
217
240
  <box width={2} flexShrink={0} justifyContent="center">
218
241
  <text selectable={false} fg={statusColor(file.status)} bg={bg}>
219
242
  <strong>{displayStatus(file)}</strong>
@@ -221,7 +244,7 @@ function renderFileRow(
221
244
  </box>
222
245
  {repoTag ? (
223
246
  <box flexShrink={0}>
224
- <text selectable={false} fg={getCurrentTheme().primary} bg={bg}>
247
+ <text selectable={false} fg={t.primary} bg={bg}>
225
248
  <strong>{repoTag}</strong>
226
249
  </text>
227
250
  </box>
@@ -232,32 +255,44 @@ function renderFileRow(
232
255
  {renderDiffCount(file, addedW, removedW, bg, diffCountConfig, hasNumstat)}
233
256
  </box>
234
257
  )
235
- }
258
+ })
236
259
 
237
- function renderTreeSection(
238
- section: GitFileSection,
239
- title: string,
240
- files: GitFileEntry[],
241
- rows: Array<GitTreeFolderRow | GitTreeFileRow>,
242
- addedW: number,
243
- removedW: number,
244
- fileListMode: GitFileListMode,
245
- selectedEntryKey: string | null | undefined,
246
- showListModeToggle: boolean,
247
- pathConfig: GitPanePathConfig,
248
- diffCountConfig: GitPaneDiffCountConfig,
249
- marginTop: number,
260
+ const TreeSection = memo(function TreeSection({
261
+ addedW,
262
+ diffCountConfig,
263
+ fileListMode,
264
+ files,
265
+ marginTop,
266
+ pathConfig,
267
+ removedW,
268
+ repoPrefixes,
269
+ rows,
270
+ selectedEntryKey,
271
+ showListModeToggle,
272
+ title,
273
+ }: {
274
+ title: string
275
+ files: GitFileEntry[]
276
+ rows: (GitTreeFolderRow | GitTreeFileRow)[]
277
+ addedW: number
278
+ removedW: number
279
+ fileListMode: GitFileListMode
280
+ selectedEntryKey: string | null | undefined
281
+ showListModeToggle: boolean
282
+ pathConfig: GitPanePathConfig
283
+ diffCountConfig: GitPaneDiffCountConfig
284
+ marginTop: number
250
285
  repoPrefixes: Record<string, string>
251
- ): ReactNode {
252
- if (files.length === 0) return null
253
- const t2 = getCurrentTheme()
286
+ }) {
287
+ const t2 = useTheme()
254
288
  const nextFileListMode = fileListMode === 'tree' ? 'flat' : 'tree'
255
- const toggleListMode = (): void => {
289
+ const toggleListMode = useCallback(() => {
256
290
  dispatchGlobal({ type: 'git-mode-toggle-file-list-mode' })
257
291
  runSideEffectGlobal({ mode: nextFileListMode, type: 'persist-git-file-list-mode' })
258
- }
292
+ }, [nextFileListMode])
293
+ if (files.length === 0) return null
259
294
  return (
260
- <box key={section} flexDirection="column" marginTop={marginTop}>
295
+ <box flexDirection="column" marginTop={marginTop}>
261
296
  <box flexDirection="row" justifyContent="space-between">
262
297
  <text selectable={false} fg={t2.text}>
263
298
  <strong>
@@ -279,22 +314,25 @@ function renderTreeSection(
279
314
  ) : null}
280
315
  </box>
281
316
  {rows.map((row) =>
282
- row.kind === 'folder'
283
- ? renderFolderRow(row, row.key === selectedEntryKey)
284
- : renderFileRow(
285
- row,
286
- addedW,
287
- removedW,
288
- row.key === selectedEntryKey,
289
- fileListMode,
290
- pathConfig,
291
- diffCountConfig,
292
- repoPrefixes
293
- )
317
+ row.kind === 'folder' ? (
318
+ <FolderRow key={row.key} row={row} isSelected={row.key === selectedEntryKey} />
319
+ ) : (
320
+ <FileRow
321
+ key={row.key}
322
+ row={row}
323
+ addedW={addedW}
324
+ removedW={removedW}
325
+ isSelected={row.key === selectedEntryKey}
326
+ fileListMode={fileListMode}
327
+ pathConfig={pathConfig}
328
+ diffCountConfig={diffCountConfig}
329
+ repoPrefixes={repoPrefixes}
330
+ />
331
+ )
294
332
  )}
295
333
  </box>
296
334
  )
297
- }
335
+ })
298
336
 
299
337
  interface StatusPlaceholder {
300
338
  label: string
@@ -335,8 +373,13 @@ function computeStatusPlaceholder(
335
373
 
336
374
  const DEFAULT_PATH_CONFIG: GitPanePathConfig = { enabled: true }
337
375
  const DEFAULT_DIFF_COUNT_CONFIG: GitPaneDiffCountConfig = { enabled: true }
376
+ const HIDDEN_SCROLLBAR_OPTIONS = { visible: false }
377
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
378
+ const EMPTY_FILES: GitFileEntry[] = []
379
+ const EMPTY_ROWS: (GitTreeFolderRow | GitTreeFileRow)[] = []
338
380
 
339
381
  export const GitPanel = memo(function GitPanel({
382
+ baseLabel,
340
383
  collapsedFolders = {},
341
384
  compact = false,
342
385
  diffCountConfig = DEFAULT_DIFF_COUNT_CONFIG,
@@ -350,7 +393,8 @@ export const GitPanel = memo(function GitPanel({
350
393
  const t = useTheme()
351
394
  useTransparent()
352
395
  const repoPrefixes = useAppStore((s) => s.multiRepo.prefixes)
353
- const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
396
+ const isSingleSection = headOffset > 0 || (baseLabel != null && baseLabel !== '')
397
+ const sectionOrder = isSingleSection ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
354
398
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
355
399
  const tree = useMemo(
356
400
  () => buildGitTreeRows(gitPanel.files, collapsedFolders, fileListMode, compact),
@@ -361,7 +405,7 @@ export const GitPanel = memo(function GitPanel({
361
405
  [gitPanel.files]
362
406
  )
363
407
 
364
- const statusNode = renderStatus(gitPanel, !!projectPath)
408
+ const statusNode = renderStatus(gitPanel, !!(projectPath != null && projectPath !== ''))
365
409
 
366
410
  const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
367
411
  const toggleSection =
@@ -372,7 +416,7 @@ export const GitPanel = memo(function GitPanel({
372
416
 
373
417
  useEffect(() => {
374
418
  const scrollbox = scrollRef.current
375
- if (!scrollbox || !selectedEntryKey) return
419
+ if (!scrollbox || !(selectedEntryKey != null && selectedEntryKey !== '')) return
376
420
  const selectedIndex = tree.visibleRows.findIndex((row) => row.key === selectedEntryKey)
377
421
  if (selectedIndex < 0) return
378
422
  const viewportHeight = Math.max(1, scrollbox.viewport.height)
@@ -392,29 +436,31 @@ export const GitPanel = memo(function GitPanel({
392
436
  flexGrow={1}
393
437
  ref={scrollRef}
394
438
  scrollY
395
- scrollbarOptions={{ visible: false }}
439
+ scrollbarOptions={HIDDEN_SCROLLBAR_OPTIONS}
396
440
  viewportCulling
397
- contentOptions={{ flexDirection: 'column', gap: 0 }}
441
+ contentOptions={COLUMN_CONTENT_OPTIONS}
398
442
  >
399
443
  {sectionOrder.map((key, idx) => {
400
444
  const section = tree.sections.find((entry) => entry.section === key)
401
445
  const priorHasFiles = sectionOrder
402
446
  .slice(0, idx)
403
447
  .some((k) => (tree.sections.find((e) => e.section === k)?.files.length ?? 0) > 0)
404
- return renderTreeSection(
405
- key,
406
- sectionTitle(key, headOffset),
407
- section?.files ?? [],
408
- section?.rows ?? [],
409
- addedW,
410
- removedW,
411
- fileListMode,
412
- selectedEntryKey,
413
- key === toggleSection,
414
- pathConfig,
415
- diffCountConfig,
416
- priorHasFiles ? 1 : 0,
417
- repoPrefixes
448
+ return (
449
+ <TreeSection
450
+ key={key}
451
+ title={sectionTitle(key, headOffset, baseLabel)}
452
+ files={section?.files ?? EMPTY_FILES}
453
+ rows={section?.rows ?? EMPTY_ROWS}
454
+ addedW={addedW}
455
+ removedW={removedW}
456
+ fileListMode={fileListMode}
457
+ selectedEntryKey={selectedEntryKey}
458
+ showListModeToggle={key === toggleSection}
459
+ pathConfig={pathConfig}
460
+ diffCountConfig={diffCountConfig}
461
+ marginTop={priorHasFiles ? 1 : 0}
462
+ repoPrefixes={repoPrefixes}
463
+ />
418
464
  )
419
465
  })}
420
466
  </scrollbox>
@@ -1,16 +1,20 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
1
3
  import { useTerminalDimensions } from '@opentui/react'
2
- import { memo, useEffect, useRef } from 'react'
4
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
3
5
 
4
6
  import type { DiffData, GitDiffView } from '../../../state/types'
5
7
  import type { ThemeId } from '../../themes'
6
8
 
7
9
  import { diffHash } from '../../../git/diff-hash'
10
+ import { getMergeBase } from '../../../git/divergence'
8
11
  import { fetchDiff } from '../../../git/git-diff'
9
12
  import { useGitPanelPolling } from '../../../git/git-poller'
10
13
  import { useRepoDiscovery } from '../../../git/use-repo-discovery'
11
14
  import { useAppStore } from '../../../state/app-store'
12
15
  import { dispatchGlobal } from '../../../state/dispatch-ref'
13
16
  import { getSelectedGitFile, gitFileKey } from '../../../state/git-tree'
17
+ import { getActiveWorktree, getSessionProjectPath } from '../../../state/session-worktrees'
14
18
  import { setGitDiffScroller } from '../../git-view-controls'
15
19
  import { useTheme } from '../../theme'
16
20
  import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
@@ -64,7 +68,7 @@ const DiffStage = memo(function DiffStage({
64
68
  </box>
65
69
  )
66
70
  }
67
- if (diff.errorMessage) {
71
+ if (diff.errorMessage != null && diff.errorMessage !== '') {
68
72
  return (
69
73
  <box flexGrow={1} padding={1}>
70
74
  <text fg={t.error}>{diff.errorMessage}</text>
@@ -77,7 +81,7 @@ const DiffStage = memo(function DiffStage({
77
81
  }
78
82
 
79
83
  const placeholder = placeholderText(diff)
80
- if (placeholder) {
84
+ if (placeholder != null && placeholder !== '') {
81
85
  return (
82
86
  <box flexGrow={1} padding={1}>
83
87
  <text fg={t.textMuted}>{placeholder}</text>
@@ -87,7 +91,7 @@ const DiffStage = memo(function DiffStage({
87
91
 
88
92
  return (
89
93
  <box flexDirection="column" flexGrow={1} overflow="hidden">
90
- {diff.oldPath ? (
94
+ {diff.oldPath != null && diff.oldPath !== '' ? (
91
95
  <box paddingLeft={1} paddingRight={1}>
92
96
  <text fg={t.textMuted}>
93
97
  renamed: {diff.oldPath} → {diff.path}
@@ -123,13 +127,42 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
123
127
  const focusMode = useAppStore((s) => s.focusMode)
124
128
  const diffRef = useRef<PierreDiffHandle | null>(null)
125
129
 
126
- const currentSession = currentSessionId
127
- ? sessions.find((s) => s.id === currentSessionId)
128
- : undefined
129
- const projectPath = currentSession?.projectPath
130
+ const currentSession =
131
+ currentSessionId != null && currentSessionId !== ''
132
+ ? sessions.find((s) => s.id === currentSessionId)
133
+ : undefined
134
+ const projectPath = getSessionProjectPath(currentSession)
135
+
136
+ // Review-vs-base: when toggled on for a worktree that records a baseRef,
137
+ // resolve the fork point and diff the working tree against it.
138
+ const activeWorktree = getActiveWorktree(currentSession)
139
+ const reviewBaseRef =
140
+ gitMode.reviewBase && activeWorktree?.baseRef != null && activeWorktree.baseRef !== ''
141
+ ? activeWorktree.baseRef
142
+ : null
143
+ const [compareRef, setCompareRef] = useState<string | undefined>(undefined)
144
+ useEffect(() => {
145
+ if (reviewBaseRef == null || projectPath == null || projectPath === '') {
146
+ setCompareRef(undefined)
147
+ return
148
+ }
149
+ let cancelled = false
150
+ void (async () => {
151
+ const mergeBase = await getMergeBase(projectPath, reviewBaseRef)
152
+ if (!cancelled) setCompareRef(mergeBase)
153
+ })()
154
+ return () => {
155
+ cancelled = true
156
+ }
157
+ }, [reviewBaseRef, projectPath])
130
158
 
131
159
  useRepoDiscovery(projectPath)
132
- useGitPanelPolling({ enabled: focusMode === 'git', headOffset: gitMode.headOffset, projectPath })
160
+ useGitPanelPolling({
161
+ compareRef,
162
+ enabled: focusMode === 'git',
163
+ headOffset: gitMode.headOffset,
164
+ projectPath,
165
+ })
133
166
 
134
167
  const fileBarWidth = Math.max(20, Math.floor(dimensions.width * gitPane.diffModeRatio))
135
168
 
@@ -140,8 +173,12 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
140
173
  selectedEntryKey: gitMode.selectedEntryKey,
141
174
  })
142
175
  const selectedDiffKey = selectedFile ? gitFileKey(selectedFile) : null
143
- const diff = selectedDiffKey ? gitMode.diffs[selectedDiffKey] : undefined
144
- const loading = selectedDiffKey ? !!gitMode.loading[selectedDiffKey] : false
176
+ const diff =
177
+ selectedDiffKey != null && selectedDiffKey !== '' ? gitMode.diffs[selectedDiffKey] : undefined
178
+ const loading =
179
+ selectedDiffKey != null && selectedDiffKey !== ''
180
+ ? !!(gitMode.loading[selectedDiffKey] === true)
181
+ : false
145
182
 
146
183
  useEffect(() => {
147
184
  setGitDiffScroller((delta: number) => {
@@ -160,6 +197,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
160
197
  }, [])
161
198
 
162
199
  useDiffPrefetch(gitMode.selectedEntryKey, gitPane.prefetchRadius, {
200
+ compareRef,
163
201
  enabled: focusMode === 'git',
164
202
  headOffset: gitMode.headOffset,
165
203
  projectPath,
@@ -168,23 +206,38 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
168
206
 
169
207
  useEffect(() => {
170
208
  if (focusMode !== 'git') return
171
- if (!selectedFile || !projectPath) return
172
- if (!selectedDiffKey) return
209
+ if (!selectedFile || !(projectPath != null && projectPath !== '')) return
210
+ if (!(selectedDiffKey != null && selectedDiffKey !== '')) return
173
211
  if (diff || loading) return
174
212
  dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
175
- void fetchDiff(selectedFile.repoPath ?? projectPath, selectedFile, gitMode.headOffset)
176
- .then((d) =>
213
+ void (async () => {
214
+ try {
215
+ const d = await fetchDiff(
216
+ selectedFile.repoPath ?? projectPath,
217
+ selectedFile,
218
+ gitMode.headOffset,
219
+ compareRef
220
+ )
177
221
  dispatchGlobal({
178
222
  diff: d,
179
223
  hash: diffHash(d.rawDiff),
180
224
  key: selectedDiffKey,
181
225
  type: 'git-mode-set-diff',
182
226
  })
183
- )
184
- .catch(() =>
227
+ } catch {
185
228
  dispatchGlobal({ key: selectedDiffKey, loading: false, type: 'git-mode-set-loading' })
186
- )
187
- }, [focusMode, projectPath, selectedFile, selectedDiffKey, diff, loading, gitMode.headOffset])
229
+ }
230
+ })()
231
+ }, [
232
+ focusMode,
233
+ projectPath,
234
+ selectedFile,
235
+ selectedDiffKey,
236
+ diff,
237
+ loading,
238
+ gitMode.headOffset,
239
+ compareRef,
240
+ ])
188
241
 
189
242
  const pendingPath = gitMode.pendingDeletePath
190
243
  const pendingIsUntracked =
@@ -199,20 +252,30 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
199
252
  }
200
253
  const actionMessage = gitMode.actionMessage
201
254
  let footerNode: React.ReactNode = null
202
- if (pendingHint) {
255
+ if (pendingHint != null && pendingHint !== '') {
203
256
  footerNode = (
204
257
  <text fg={t.warning}>
205
258
  <strong>{pendingHint}</strong>
206
259
  </text>
207
260
  )
208
- } else if (actionMessage) {
261
+ } else if (actionMessage != null && actionMessage !== '') {
209
262
  footerNode = actionMessage.split('\n').map((line, idx) => (
263
+ // Message lines are positional and may repeat (e.g. blank lines); index is the identity.
264
+ // eslint-disable-next-line react/no-array-index-key
210
265
  <text key={idx} fg={t.primary}>
211
266
  {line}
212
267
  </text>
213
268
  ))
214
269
  }
215
270
 
271
+ const baseLabel = compareRef != null && reviewBaseRef != null ? `vs ${reviewBaseRef}` : undefined
272
+
273
+ const handleExitDiff = useCallback((event: OtuiMouseEvent) => {
274
+ event.preventDefault()
275
+ event.stopPropagation()
276
+ dispatchGlobal({ type: 'exit-git-mode' })
277
+ }, [])
278
+
216
279
  return (
217
280
  <box flexDirection="column" flexGrow={1} overflow="hidden">
218
281
  <box flexDirection="row" flexGrow={1}>
@@ -228,7 +291,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
228
291
  <text fg={t.text}>
229
292
  <strong>aimux · git</strong>
230
293
  </text>
231
- {gitPanel.branch ? (
294
+ {gitPanel.branch != null && gitPanel.branch !== '' ? (
232
295
  <box flexDirection="row">
233
296
  <text fg={t.text}>{'\u{e702}'} </text>
234
297
  <text fg={t.text}>{gitPanel.branch}</text>
@@ -242,9 +305,18 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
242
305
  <text fg={t.textMuted}>[ newer · ] older</text>
243
306
  </box>
244
307
  ) : null}
308
+ {baseLabel != null ? (
309
+ <box flexDirection="row" gap={1}>
310
+ <text fg={t.primary}>
311
+ <strong>{baseLabel}</strong>
312
+ </text>
313
+ <text fg={t.textMuted}>b: back</text>
314
+ </box>
315
+ ) : null}
245
316
  <text fg={t.textMuted}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
246
317
  </box>
247
318
  <GitPanel
319
+ baseLabel={baseLabel}
248
320
  collapsedFolders={gitMode.collapsedFolders}
249
321
  compact={gitPane.treeCompaction}
250
322
  fileListMode={gitPane.fileListMode}
@@ -271,11 +343,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
271
343
  paddingLeft={1}
272
344
  paddingRight={1}
273
345
  backgroundColor={actionBg}
274
- onMouseDown={(event) => {
275
- event.preventDefault()
276
- event.stopPropagation()
277
- dispatchGlobal({ type: 'exit-git-mode' })
278
- }}
346
+ onMouseDown={handleExitDiff}
279
347
  >
280
348
  <text fg={t.text}>
281
349
  <strong>Exit diff</strong>
@@ -292,7 +360,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
292
360
  />
293
361
  </box>
294
362
  </box>
295
- {footerNode ? (
363
+ {footerNode != null ? (
296
364
  <box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
297
365
  {footerNode}
298
366
  </box>
@@ -76,14 +76,14 @@ export const ImageDiffView = memo(function ImageDiffView({ diff }: ImageDiffView
76
76
  const showBoth = before && after
77
77
  return (
78
78
  <box flexDirection="column" flexGrow={1} overflow="hidden" backgroundColor={t.background}>
79
- {diff.oldPath ? (
79
+ {diff.oldPath != null && diff.oldPath !== '' ? (
80
80
  <box paddingLeft={1} paddingRight={1}>
81
81
  <text fg={t.textMuted}>
82
82
  renamed: {diff.oldPath} → {diff.path}
83
83
  </text>
84
84
  </box>
85
85
  ) : null}
86
- {banner ? (
86
+ {banner != null && banner !== '' ? (
87
87
  <box paddingLeft={1} paddingRight={1}>
88
88
  <text fg={protocol === 'kitty' ? t.textMuted : t.warning}>{banner}</text>
89
89
  </box>
@@ -1,5 +1,6 @@
1
- import { type BoxRenderable } from '@opentui/core'
2
- import { memo, useEffect, useRef, useState } from 'react'
1
+ import type { BoxRenderable } from '@opentui/core'
2
+
3
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
3
4
 
4
5
  import { convertToPng, isPng } from '../../../terminal-graphics/format-fallback'
5
6
  import {
@@ -47,7 +48,7 @@ export const TerminalImagePane = memo(function TerminalImagePane({
47
48
  let cancelled = false
48
49
  stateRef.current = { imageId: null, lastKey: null, uploaded: false }
49
50
  setError(null)
50
- ;(async () => {
51
+ void (async () => {
51
52
  let pngBytes: Uint8Array | null = null
52
53
  if (mime === 'image/png' || isPng(bytes)) {
53
54
  pngBytes = bytes
@@ -60,7 +61,7 @@ export const TerminalImagePane = memo(function TerminalImagePane({
60
61
  }
61
62
  pngBytes = result.png
62
63
  }
63
- if (cancelled || !pngBytes) return
64
+ if (cancelled) return
64
65
  const id = nextImageId()
65
66
  writeRaw(uploadPngEscape(pngBytes, id))
66
67
  stateRef.current.imageId = id
@@ -76,15 +77,7 @@ export const TerminalImagePane = memo(function TerminalImagePane({
76
77
  }
77
78
  }, [bytes, mime])
78
79
 
79
- if (error) {
80
- return (
81
- <box flexGrow={1} alignItems="center" justifyContent="center" padding={1}>
82
- <text fg={t.warning}>({error})</text>
83
- </box>
84
- )
85
- }
86
-
87
- function renderAfter(this: BoxRenderable): void {
80
+ const renderAfter = useCallback(function (this: BoxRenderable): void {
88
81
  const state = stateRef.current
89
82
  if (!state.uploaded || state.imageId === null) return
90
83
  const key = `${this.screenX},${this.screenY},${this.width},${this.height}`
@@ -93,6 +86,14 @@ export const TerminalImagePane = memo(function TerminalImagePane({
93
86
  const seq = buildPlacement(state.imageId, this.screenX, this.screenY)
94
87
  // Queue write to land AFTER opentui's native cell flush in this frame.
95
88
  process.nextTick(() => writeRaw(seq))
89
+ }, [])
90
+
91
+ if (error != null && error !== '') {
92
+ return (
93
+ <box flexGrow={1} alignItems="center" justifyContent="center" padding={1}>
94
+ <text fg={t.warning}>({error})</text>
95
+ </box>
96
+ )
96
97
  }
97
98
 
98
99
  return <box flexGrow={1} renderAfter={renderAfter} />