@brimveyn/aimux 1.12.6 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) 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 +18 -2
  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 +538 -83
  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 +3 -3
  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 +1 -1
  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 +3 -3
  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 +2 -1
  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 +6 -4
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  91. package/src/ui/components/git/git-panel.tsx +17 -10
  92. package/src/ui/components/git/git-view.tsx +79 -19
  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 +5 -4
  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 +3 -3
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  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 +1 -1
  103. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  109. package/src/ui/components/modals/shared/form.tsx +4 -5
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +28 -5
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +11 -3
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  114. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  115. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  116. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  117. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  118. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  119. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  120. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  121. package/src/ui/components/primitives/input-field.tsx +1 -1
  122. package/src/ui/components/primitives/list-item.tsx +3 -3
  123. package/src/ui/filter-themes.ts +1 -1
  124. package/src/ui/root.tsx +57 -3
  125. package/src/ui/session-ordering.ts +2 -2
  126. package/src/ui/status-bar-model.ts +14 -7
  127. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  128. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  129. package/src/ui/terminal-graphics/kitty.ts +2 -7
  130. package/src/update/version-check.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events'
2
- import { connect, Socket } from 'node:net'
2
+ import { connect, type Socket } from 'node:net'
3
3
 
4
4
  import type {
5
5
  ScrollIntent,
@@ -23,7 +23,7 @@ import {
23
23
  parseManagerMessage,
24
24
  } from '../ipc/manager-protocol'
25
25
 
26
- type ManagerClientEvents = {
26
+ interface ManagerClientEvents {
27
27
  render: [
28
28
  sessionId: string,
29
29
  tabId: string,
@@ -84,7 +84,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
84
84
  return this.socket
85
85
  }
86
86
 
87
- private send(request: ManagerRequest): Promise<ManagerResponse> {
87
+ private async send(request: ManagerRequest): Promise<ManagerResponse> {
88
88
  const socket = this.getConnectedSocket()
89
89
  logDebug('managerClient.send', { id: request.id, type: request.type })
90
90
  return new Promise((resolve, reject) => {
@@ -286,7 +286,9 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
286
286
  return response.payload
287
287
  }
288
288
 
289
- createTab(options: Extract<ManagerRequest, { type: 'createTab' }>['payload']): Promise<void> {
289
+ async createTab(
290
+ options: Extract<ManagerRequest, { type: 'createTab' }>['payload']
291
+ ): Promise<void> {
290
292
  logDebug('managerClient.createTab', {
291
293
  command: options.command,
292
294
  sessionId: options.sessionId,
@@ -296,7 +298,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
296
298
  return this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' })
297
299
  }
298
300
 
299
- write(sessionId: string, tabId: string, data: string): Promise<void> {
301
+ async write(sessionId: string, tabId: string, data: string): Promise<void> {
300
302
  return this.sendExpectOk({
301
303
  id: crypto.randomUUID(),
302
304
  payload: { data, sessionId, tabId },
@@ -304,7 +306,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
304
306
  })
305
307
  }
306
308
 
307
- resize(
309
+ async resize(
308
310
  sessionId: string,
309
311
  cols: number,
310
312
  rows: number,
@@ -317,7 +319,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
317
319
  })
318
320
  }
319
321
 
320
- resizeTab(
322
+ async resizeTab(
321
323
  sessionId: string,
322
324
  tabId: string,
323
325
  cols: number,
@@ -331,7 +333,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
331
333
  })
332
334
  }
333
335
 
334
- scroll(sessionId: string, tabId: string, deltaLines: number): Promise<void> {
336
+ async scroll(sessionId: string, tabId: string, deltaLines: number): Promise<void> {
335
337
  return this.sendExpectOk({
336
338
  id: crypto.randomUUID(),
337
339
  payload: { deltaLines, sessionId, tabId },
@@ -339,7 +341,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
339
341
  })
340
342
  }
341
343
 
342
- scrollToBottom(sessionId: string, tabId: string): Promise<void> {
344
+ async scrollToBottom(sessionId: string, tabId: string): Promise<void> {
343
345
  return this.sendExpectOk({
344
346
  id: crypto.randomUUID(),
345
347
  payload: { sessionId, tabId },
@@ -347,7 +349,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
347
349
  })
348
350
  }
349
351
 
350
- reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): Promise<void> {
352
+ async reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): Promise<void> {
351
353
  return this.sendExpectOk({
352
354
  id: crypto.randomUUID(),
353
355
  payload: { intent, sessionId, tabId },
@@ -355,7 +357,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
355
357
  })
356
358
  }
357
359
 
358
- setActiveTab(sessionId: string, tabId: string | null): Promise<void> {
360
+ async setActiveTab(sessionId: string, tabId: string | null): Promise<void> {
359
361
  return this.sendExpectOk({
360
362
  id: crypto.randomUUID(),
361
363
  payload: { sessionId, tabId },
@@ -363,7 +365,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
363
365
  })
364
366
  }
365
367
 
366
- closeTab(sessionId: string, tabId: string): Promise<void> {
368
+ async closeTab(sessionId: string, tabId: string): Promise<void> {
367
369
  return this.sendExpectOk({
368
370
  id: crypto.randomUUID(),
369
371
  payload: { sessionId, tabId },
@@ -371,7 +373,7 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
371
373
  })
372
374
  }
373
375
 
374
- disposeSession(sessionId: string): Promise<void> {
376
+ async disposeSession(sessionId: string): Promise<void> {
375
377
  return this.sendExpectOk({
376
378
  id: crypto.randomUUID(),
377
379
  payload: { sessionId },
@@ -1,6 +1,7 @@
1
+ import type { FoldDispatch } from './pierre-diff'
2
+
1
3
  import { useTheme } from '../../../theme'
2
4
  import { FOLD_STEP, type FoldInfo } from './build-rows'
3
- import { type FoldDispatch } from './pierre-diff'
4
5
 
5
6
  interface Props {
6
7
  fold: FoldInfo
@@ -3,11 +3,11 @@ import type { ThemedToken } from 'shiki'
3
3
 
4
4
  import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
5
5
 
6
+ import type { FoldState } from '../../../../state/types'
6
7
  import type { ThemeId } from '../../../themes'
7
8
 
8
9
  import { useAppStore } from '../../../../state/app-store'
9
10
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
10
- import { type FoldState } from '../../../../state/types'
11
11
  import { useTheme } from '../../../theme'
12
12
  import { buildDiffSegments, firstChangeSegmentOffset, gutterWidth } from './build-rows'
13
13
  import { SplitView, type SplitViewHandle } from './split-view'
@@ -22,14 +22,14 @@ interface PrepareOptions {
22
22
  signal?: AbortSignal
23
23
  }
24
24
 
25
- function yieldToEventLoop(): Promise<void> {
25
+ async function yieldToEventLoop(): Promise<void> {
26
26
  return new Promise((resolve) => {
27
27
  setImmediate(resolve)
28
28
  })
29
29
  }
30
30
 
31
31
  function throwIfAborted(signal?: AbortSignal): void {
32
- if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
32
+ if (signal?.aborted === true) throw new DOMException('Aborted', 'AbortError')
33
33
  }
34
34
 
35
35
  // Lifts diff parsing off the synchronous render tree and returns a segmented
@@ -230,7 +230,9 @@ function HunkHeaderRow({ row }: { row: Extract<SplitRowOrHeader, { type: 'hunk-h
230
230
  return (
231
231
  <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
232
232
  <text fg={t.text}>{row.spec}</text>
233
- {row.context ? <text fg={t.textMuted}> {row.context}</text> : null}
233
+ {row.context != null && row.context !== '' ? (
234
+ <text fg={t.textMuted}> {row.context}</text>
235
+ ) : null}
234
236
  </box>
235
237
  )
236
238
  }
@@ -285,9 +287,9 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
285
287
  {tokens.map((tok, i) => {
286
288
  const s = tokenToSpan(tok)
287
289
  let attributes = 0
288
- if (s.bold) attributes |= TextAttributes.BOLD
289
- if (s.italic) attributes |= TextAttributes.ITALIC
290
- if (s.underline) attributes |= TextAttributes.UNDERLINE
290
+ if (s.bold === true) attributes |= TextAttributes.BOLD
291
+ if (s.italic === true) attributes |= TextAttributes.ITALIC
292
+ if (s.underline === true) attributes |= TextAttributes.UNDERLINE
291
293
  return (
292
294
  <span key={i} fg={s.fg ?? t.text} attributes={attributes}>
293
295
  {s.text}
@@ -186,7 +186,9 @@ function UnifiedRowRender({
186
186
  return (
187
187
  <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
188
188
  <text fg={t.text}>{row.spec}</text>
189
- {row.context ? <text fg={t.textMuted}> {row.context}</text> : null}
189
+ {row.context != null && row.context !== '' ? (
190
+ <text fg={t.textMuted}> {row.context}</text>
191
+ ) : null}
190
192
  </box>
191
193
  )
192
194
  }
@@ -230,9 +232,9 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
230
232
  {tokens.map((tok, i) => {
231
233
  const s = tokenToSpan(tok)
232
234
  let attributes = 0
233
- if (s.bold) attributes |= TextAttributes.BOLD
234
- if (s.italic) attributes |= TextAttributes.ITALIC
235
- if (s.underline) attributes |= TextAttributes.UNDERLINE
235
+ if (s.bold === true) attributes |= TextAttributes.BOLD
236
+ if (s.italic === true) attributes |= TextAttributes.ITALIC
237
+ if (s.underline === true) attributes |= TextAttributes.UNDERLINE
236
238
  return (
237
239
  <span key={i} fg={s.fg ?? t.text} attributes={attributes}>
238
240
  {s.text}
@@ -13,6 +13,7 @@ interface PrefetchOptions {
13
13
  projectPath: string | undefined
14
14
  themeId: string
15
15
  headOffset: number
16
+ compareRef?: string
16
17
  enabled: boolean
17
18
  }
18
19
 
@@ -94,15 +95,20 @@ export function useDiffPrefetch(
94
95
 
95
96
  const queueRef = useRef<PrefetchQueue | null>(null)
96
97
 
97
- const { enabled, headOffset, projectPath } = opts
98
+ const { compareRef, enabled, headOffset, projectPath } = opts
98
99
  const runTaskRef = useRef<(task: Task) => Promise<void>>(async () => {})
99
100
 
100
101
  // Keep a ref to the execute function so the queue keeps the latest closure
101
102
  // without needing to recreate the queue itself.
102
103
  runTaskRef.current = async (task: Task) => {
103
- if (!projectPath) return
104
+ if (!(projectPath != null && projectPath !== '')) return
104
105
  try {
105
- const diff = await fetchDiff(task.file.repoPath ?? projectPath, task.file, headOffset)
106
+ const diff = await fetchDiff(
107
+ task.file.repoPath ?? projectPath,
108
+ task.file,
109
+ headOffset,
110
+ compareRef
111
+ )
106
112
  if (task.controller.signal.aborted) return
107
113
  const hash = diffHash(diff.rawDiff)
108
114
  dispatchGlobal({ diff, hash, key: task.key, type: 'git-mode-set-diff' })
@@ -122,13 +128,19 @@ export function useDiffPrefetch(
122
128
  }
123
129
 
124
130
  if (!queueRef.current) {
125
- queueRef.current = new PrefetchQueue((task) => runTaskRef.current(task), MAX_CONCURRENCY)
131
+ queueRef.current = new PrefetchQueue(async (task) => runTaskRef.current(task), MAX_CONCURRENCY)
126
132
  }
127
133
 
128
134
  useEffect(() => {
129
135
  const queue = queueRef.current
130
136
  if (!queue) return
131
- if (!enabled || radius <= 0 || !projectPath || !selectedEntryKey) {
137
+ if (
138
+ !enabled ||
139
+ radius <= 0 ||
140
+ projectPath == null ||
141
+ projectPath === '' ||
142
+ !(selectedEntryKey != null && selectedEntryKey !== '')
143
+ ) {
132
144
  queue.cancelAll()
133
145
  return
134
146
  }
@@ -149,7 +161,7 @@ export function useDiffPrefetch(
149
161
  const key = row.key
150
162
  if (diffs[key]) continue
151
163
  if (parsed[key]) continue
152
- if (loading[key]) continue
164
+ if (loading[key] === true) continue
153
165
  tasks.push({
154
166
  controller: new AbortController(),
155
167
  distance: Math.abs(i - selectedIdx),
@@ -171,6 +183,7 @@ export function useDiffPrefetch(
171
183
  parsed,
172
184
  loading,
173
185
  headOffset,
186
+ compareRef,
174
187
  ])
175
188
 
176
189
  useEffect(() => {
@@ -163,11 +163,11 @@ export function useDiffPreparation(
163
163
  (segments: readonly DiffSegment[]) => {
164
164
  const file = parsed.file
165
165
  const filetype = parsed.filetype
166
- if (!file || !filetype) return
166
+ if (!file || !(filetype != null && filetype !== '')) return
167
167
  const owner = requestOwner
168
168
  const version = requestVersionRef.current
169
169
  const activeHash = cachedParsed?.hash ?? (localOwnerMatches ? localHash : null)
170
- if (!activeHash) return
170
+ if (!(activeHash != null && activeHash !== '')) return
171
171
  const next = segments.filter(
172
172
  (segment) =>
173
173
  (segment.kind === 'context' || segment.kind === 'change') &&
@@ -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>
@@ -211,7 +214,9 @@ function renderFileRow(
211
214
  // Repo disambiguation prefix: only in flat mode, only when the file came
212
215
  // from a sub-repo (root repo files get an empty prefix).
213
216
  const repoTag =
214
- fileListMode === 'flat' && file.repoPath ? (repoPrefixes[file.repoPath] ?? '') : ''
217
+ fileListMode === 'flat' && file.repoPath != null && file.repoPath !== ''
218
+ ? (repoPrefixes[file.repoPath] ?? '')
219
+ : ''
215
220
  return (
216
221
  <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
217
222
  <box width={2} flexShrink={0} justifyContent="center">
@@ -238,7 +243,7 @@ function renderTreeSection(
238
243
  section: GitFileSection,
239
244
  title: string,
240
245
  files: GitFileEntry[],
241
- rows: Array<GitTreeFolderRow | GitTreeFileRow>,
246
+ rows: (GitTreeFolderRow | GitTreeFileRow)[],
242
247
  addedW: number,
243
248
  removedW: number,
244
249
  fileListMode: GitFileListMode,
@@ -337,6 +342,7 @@ const DEFAULT_PATH_CONFIG: GitPanePathConfig = { enabled: true }
337
342
  const DEFAULT_DIFF_COUNT_CONFIG: GitPaneDiffCountConfig = { enabled: true }
338
343
 
339
344
  export const GitPanel = memo(function GitPanel({
345
+ baseLabel,
340
346
  collapsedFolders = {},
341
347
  compact = false,
342
348
  diffCountConfig = DEFAULT_DIFF_COUNT_CONFIG,
@@ -350,7 +356,8 @@ export const GitPanel = memo(function GitPanel({
350
356
  const t = useTheme()
351
357
  useTransparent()
352
358
  const repoPrefixes = useAppStore((s) => s.multiRepo.prefixes)
353
- const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
359
+ const isSingleSection = headOffset > 0 || (baseLabel != null && baseLabel !== '')
360
+ const sectionOrder = isSingleSection ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
354
361
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
355
362
  const tree = useMemo(
356
363
  () => buildGitTreeRows(gitPanel.files, collapsedFolders, fileListMode, compact),
@@ -361,7 +368,7 @@ export const GitPanel = memo(function GitPanel({
361
368
  [gitPanel.files]
362
369
  )
363
370
 
364
- const statusNode = renderStatus(gitPanel, !!projectPath)
371
+ const statusNode = renderStatus(gitPanel, !!(projectPath != null && projectPath !== ''))
365
372
 
366
373
  const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
367
374
  const toggleSection =
@@ -372,7 +379,7 @@ export const GitPanel = memo(function GitPanel({
372
379
 
373
380
  useEffect(() => {
374
381
  const scrollbox = scrollRef.current
375
- if (!scrollbox || !selectedEntryKey) return
382
+ if (!scrollbox || !(selectedEntryKey != null && selectedEntryKey !== '')) return
376
383
  const selectedIndex = tree.visibleRows.findIndex((row) => row.key === selectedEntryKey)
377
384
  if (selectedIndex < 0) return
378
385
  const viewportHeight = Math.max(1, scrollbox.viewport.height)
@@ -403,7 +410,7 @@ export const GitPanel = memo(function GitPanel({
403
410
  .some((k) => (tree.sections.find((e) => e.section === k)?.files.length ?? 0) > 0)
404
411
  return renderTreeSection(
405
412
  key,
406
- sectionTitle(key, headOffset),
413
+ sectionTitle(key, headOffset, baseLabel),
407
414
  section?.files ?? [],
408
415
  section?.rows ?? [],
409
416
  addedW,
@@ -1,16 +1,18 @@
1
1
  import { useTerminalDimensions } from '@opentui/react'
2
- import { memo, useEffect, useRef } from 'react'
2
+ import { memo, useEffect, useRef, useState } from 'react'
3
3
 
4
4
  import type { DiffData, GitDiffView } from '../../../state/types'
5
5
  import type { ThemeId } from '../../themes'
6
6
 
7
7
  import { diffHash } from '../../../git/diff-hash'
8
+ import { getMergeBase } from '../../../git/divergence'
8
9
  import { fetchDiff } from '../../../git/git-diff'
9
10
  import { useGitPanelPolling } from '../../../git/git-poller'
10
11
  import { useRepoDiscovery } from '../../../git/use-repo-discovery'
11
12
  import { useAppStore } from '../../../state/app-store'
12
13
  import { dispatchGlobal } from '../../../state/dispatch-ref'
13
14
  import { getSelectedGitFile, gitFileKey } from '../../../state/git-tree'
15
+ import { getActiveWorktree, getSessionProjectPath } from '../../../state/session-worktrees'
14
16
  import { setGitDiffScroller } from '../../git-view-controls'
15
17
  import { useTheme } from '../../theme'
16
18
  import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
@@ -64,7 +66,7 @@ const DiffStage = memo(function DiffStage({
64
66
  </box>
65
67
  )
66
68
  }
67
- if (diff.errorMessage) {
69
+ if (diff.errorMessage != null && diff.errorMessage !== '') {
68
70
  return (
69
71
  <box flexGrow={1} padding={1}>
70
72
  <text fg={t.error}>{diff.errorMessage}</text>
@@ -77,7 +79,7 @@ const DiffStage = memo(function DiffStage({
77
79
  }
78
80
 
79
81
  const placeholder = placeholderText(diff)
80
- if (placeholder) {
82
+ if (placeholder != null && placeholder !== '') {
81
83
  return (
82
84
  <box flexGrow={1} padding={1}>
83
85
  <text fg={t.textMuted}>{placeholder}</text>
@@ -87,7 +89,7 @@ const DiffStage = memo(function DiffStage({
87
89
 
88
90
  return (
89
91
  <box flexDirection="column" flexGrow={1} overflow="hidden">
90
- {diff.oldPath ? (
92
+ {diff.oldPath != null && diff.oldPath !== '' ? (
91
93
  <box paddingLeft={1} paddingRight={1}>
92
94
  <text fg={t.textMuted}>
93
95
  renamed: {diff.oldPath} → {diff.path}
@@ -123,13 +125,41 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
123
125
  const focusMode = useAppStore((s) => s.focusMode)
124
126
  const diffRef = useRef<PierreDiffHandle | null>(null)
125
127
 
126
- const currentSession = currentSessionId
127
- ? sessions.find((s) => s.id === currentSessionId)
128
- : undefined
129
- const projectPath = currentSession?.projectPath
128
+ const currentSession =
129
+ currentSessionId != null && currentSessionId !== ''
130
+ ? sessions.find((s) => s.id === currentSessionId)
131
+ : undefined
132
+ const projectPath = getSessionProjectPath(currentSession)
133
+
134
+ // Review-vs-base: when toggled on for a worktree that records a baseRef,
135
+ // resolve the fork point and diff the working tree against it.
136
+ const activeWorktree = getActiveWorktree(currentSession)
137
+ const reviewBaseRef =
138
+ gitMode.reviewBase && activeWorktree?.baseRef != null && activeWorktree.baseRef !== ''
139
+ ? activeWorktree.baseRef
140
+ : null
141
+ const [compareRef, setCompareRef] = useState<string | undefined>(undefined)
142
+ useEffect(() => {
143
+ if (reviewBaseRef == null || projectPath == null || projectPath === '') {
144
+ setCompareRef(undefined)
145
+ return
146
+ }
147
+ let cancelled = false
148
+ void getMergeBase(projectPath, reviewBaseRef).then((mergeBase) => {
149
+ if (!cancelled) setCompareRef(mergeBase)
150
+ })
151
+ return () => {
152
+ cancelled = true
153
+ }
154
+ }, [reviewBaseRef, projectPath])
130
155
 
131
156
  useRepoDiscovery(projectPath)
132
- useGitPanelPolling({ enabled: focusMode === 'git', headOffset: gitMode.headOffset, projectPath })
157
+ useGitPanelPolling({
158
+ compareRef,
159
+ enabled: focusMode === 'git',
160
+ headOffset: gitMode.headOffset,
161
+ projectPath,
162
+ })
133
163
 
134
164
  const fileBarWidth = Math.max(20, Math.floor(dimensions.width * gitPane.diffModeRatio))
135
165
 
@@ -140,8 +170,12 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
140
170
  selectedEntryKey: gitMode.selectedEntryKey,
141
171
  })
142
172
  const selectedDiffKey = selectedFile ? gitFileKey(selectedFile) : null
143
- const diff = selectedDiffKey ? gitMode.diffs[selectedDiffKey] : undefined
144
- const loading = selectedDiffKey ? !!gitMode.loading[selectedDiffKey] : false
173
+ const diff =
174
+ selectedDiffKey != null && selectedDiffKey !== '' ? gitMode.diffs[selectedDiffKey] : undefined
175
+ const loading =
176
+ selectedDiffKey != null && selectedDiffKey !== ''
177
+ ? !!(gitMode.loading[selectedDiffKey] === true)
178
+ : false
145
179
 
146
180
  useEffect(() => {
147
181
  setGitDiffScroller((delta: number) => {
@@ -160,6 +194,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
160
194
  }, [])
161
195
 
162
196
  useDiffPrefetch(gitMode.selectedEntryKey, gitPane.prefetchRadius, {
197
+ compareRef,
163
198
  enabled: focusMode === 'git',
164
199
  headOffset: gitMode.headOffset,
165
200
  projectPath,
@@ -168,11 +203,16 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
168
203
 
169
204
  useEffect(() => {
170
205
  if (focusMode !== 'git') return
171
- if (!selectedFile || !projectPath) return
172
- if (!selectedDiffKey) return
206
+ if (!selectedFile || !(projectPath != null && projectPath !== '')) return
207
+ if (!(selectedDiffKey != null && selectedDiffKey !== '')) return
173
208
  if (diff || loading) return
174
209
  dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
175
- void fetchDiff(selectedFile.repoPath ?? projectPath, selectedFile, gitMode.headOffset)
210
+ void fetchDiff(
211
+ selectedFile.repoPath ?? projectPath,
212
+ selectedFile,
213
+ gitMode.headOffset,
214
+ compareRef
215
+ )
176
216
  .then((d) =>
177
217
  dispatchGlobal({
178
218
  diff: d,
@@ -184,7 +224,16 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
184
224
  .catch(() =>
185
225
  dispatchGlobal({ key: selectedDiffKey, loading: false, type: 'git-mode-set-loading' })
186
226
  )
187
- }, [focusMode, projectPath, selectedFile, selectedDiffKey, diff, loading, gitMode.headOffset])
227
+ }, [
228
+ focusMode,
229
+ projectPath,
230
+ selectedFile,
231
+ selectedDiffKey,
232
+ diff,
233
+ loading,
234
+ gitMode.headOffset,
235
+ compareRef,
236
+ ])
188
237
 
189
238
  const pendingPath = gitMode.pendingDeletePath
190
239
  const pendingIsUntracked =
@@ -199,13 +248,13 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
199
248
  }
200
249
  const actionMessage = gitMode.actionMessage
201
250
  let footerNode: React.ReactNode = null
202
- if (pendingHint) {
251
+ if (pendingHint != null && pendingHint !== '') {
203
252
  footerNode = (
204
253
  <text fg={t.warning}>
205
254
  <strong>{pendingHint}</strong>
206
255
  </text>
207
256
  )
208
- } else if (actionMessage) {
257
+ } else if (actionMessage != null && actionMessage !== '') {
209
258
  footerNode = actionMessage.split('\n').map((line, idx) => (
210
259
  <text key={idx} fg={t.primary}>
211
260
  {line}
@@ -213,6 +262,8 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
213
262
  ))
214
263
  }
215
264
 
265
+ const baseLabel = compareRef != null && reviewBaseRef != null ? `vs ${reviewBaseRef}` : undefined
266
+
216
267
  return (
217
268
  <box flexDirection="column" flexGrow={1} overflow="hidden">
218
269
  <box flexDirection="row" flexGrow={1}>
@@ -228,7 +279,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
228
279
  <text fg={t.text}>
229
280
  <strong>aimux · git</strong>
230
281
  </text>
231
- {gitPanel.branch ? (
282
+ {gitPanel.branch != null && gitPanel.branch !== '' ? (
232
283
  <box flexDirection="row">
233
284
  <text fg={t.text}>{'\u{e702}'} </text>
234
285
  <text fg={t.text}>{gitPanel.branch}</text>
@@ -242,9 +293,18 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
242
293
  <text fg={t.textMuted}>[ newer · ] older</text>
243
294
  </box>
244
295
  ) : null}
296
+ {baseLabel != null ? (
297
+ <box flexDirection="row" gap={1}>
298
+ <text fg={t.primary}>
299
+ <strong>{baseLabel}</strong>
300
+ </text>
301
+ <text fg={t.textMuted}>b: back</text>
302
+ </box>
303
+ ) : null}
245
304
  <text fg={t.textMuted}>{'·'.repeat(Math.max(0, fileBarWidth - 2))}</text>
246
305
  </box>
247
306
  <GitPanel
307
+ baseLabel={baseLabel}
248
308
  collapsedFolders={gitMode.collapsedFolders}
249
309
  compact={gitPane.treeCompaction}
250
310
  fileListMode={gitPane.fileListMode}
@@ -292,7 +352,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
292
352
  />
293
353
  </box>
294
354
  </box>
295
- {footerNode ? (
355
+ {footerNode != null ? (
296
356
  <box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
297
357
  {footerNode}
298
358
  </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,4 +1,5 @@
1
- import { type BoxRenderable } from '@opentui/core'
1
+ import type { BoxRenderable } from '@opentui/core'
2
+
2
3
  import { memo, useEffect, useRef, useState } from 'react'
3
4
 
4
5
  import { convertToPng, isPng } from '../../../terminal-graphics/format-fallback'
@@ -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,7 +77,7 @@ export const TerminalImagePane = memo(function TerminalImagePane({
76
77
  }
77
78
  }, [bytes, mime])
78
79
 
79
- if (error) {
80
+ if (error != null && error !== '') {
80
81
  return (
81
82
  <box flexGrow={1} alignItems="center" justifyContent="center" padding={1}>
82
83
  <text fg={t.warning}>({error})</text>
@@ -1,11 +1,11 @@
1
1
  import type { GitPaneMode, GitPanePosition, GitPaneState } from '../../../../state/types'
2
2
  import type { ContextMenuItem } from '../../../context-menu/controller'
3
3
 
4
- const GIT_PANE_MENU_OPTIONS: Array<{
4
+ const GIT_PANE_MENU_OPTIONS: {
5
5
  label: string
6
6
  mode: GitPaneMode
7
7
  position: GitPanePosition
8
- }> = [
8
+ }[] = [
9
9
  { label: 'Move to top', mode: 'embedded', position: 'top' },
10
10
  { label: 'Move to bottom', mode: 'embedded', position: 'bottom' },
11
11
  { label: 'Move to left', mode: 'pane', position: 'left' },