@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,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,13 +1,16 @@
1
+ import { memo, useCallback } from 'react'
2
+
3
+ import type { FoldDispatch } from './pierre-diff'
4
+
1
5
  import { useTheme } from '../../../theme'
2
6
  import { FOLD_STEP, type FoldInfo } from './build-rows'
3
- import { type FoldDispatch } from './pierre-diff'
4
7
 
5
8
  interface Props {
6
9
  fold: FoldInfo
7
10
  dispatch: FoldDispatch
8
11
  }
9
12
 
10
- function Button({ label, onPress }: { label: string; onPress: () => void }) {
13
+ const Button = memo(function Button({ label, onPress }: { label: string; onPress: () => void }) {
11
14
  const t = useTheme()
12
15
  const bg = t.diffContextBg
13
16
  return (
@@ -15,7 +18,7 @@ function Button({ label, onPress }: { label: string; onPress: () => void }) {
15
18
  <text fg={t.primary}>{label}</text>
16
19
  </box>
17
20
  )
18
- }
21
+ })
19
22
 
20
23
  function Spacer() {
21
24
  return <text> </text>
@@ -30,42 +33,47 @@ export function FoldStrip({ dispatch, fold }: Props) {
30
33
  const shrinkUp = Math.min(FOLD_STEP, topExpanded)
31
34
  const shrinkDown = Math.min(FOLD_STEP, bottomExpanded)
32
35
 
36
+ const handleExpandTop = useCallback(
37
+ () => dispatch.adjust(foldId, 'top', stepUp),
38
+ [dispatch, foldId, stepUp]
39
+ )
40
+ const handleExpandBottom = useCallback(
41
+ () => dispatch.adjust(foldId, 'bottom', stepDown),
42
+ [dispatch, foldId, stepDown]
43
+ )
44
+ const handleExpandAll = useCallback(
45
+ () => dispatch.set(foldId, total, 0),
46
+ [dispatch, foldId, total]
47
+ )
48
+ const handleShrinkTop = useCallback(
49
+ () => dispatch.adjust(foldId, 'top', -shrinkUp),
50
+ [dispatch, foldId, shrinkUp]
51
+ )
52
+ const handleShrinkBottom = useCallback(
53
+ () => dispatch.adjust(foldId, 'bottom', -shrinkDown),
54
+ [dispatch, foldId, shrinkDown]
55
+ )
56
+
33
57
  const controls: React.ReactNode[] = []
34
58
  if (hidden > 0) {
35
59
  controls.push(
36
- <Button
37
- key="up"
38
- label={`↑${stepUp}`}
39
- onPress={() => dispatch.adjust(foldId, 'top', stepUp)}
40
- />,
60
+ <Button key="up" label={`↑${stepUp}`} onPress={handleExpandTop} />,
41
61
  <Spacer key="sp1" />,
42
- <Button
43
- key="down"
44
- label={`↓${stepDown}`}
45
- onPress={() => dispatch.adjust(foldId, 'bottom', stepDown)}
46
- />,
62
+ <Button key="down" label={`↓${stepDown}`} onPress={handleExpandBottom} />,
47
63
  <Spacer key="sp2" />,
48
- <Button key="all" label="⇅ all" onPress={() => dispatch.set(foldId, total, 0)} />
64
+ <Button key="all" label="⇅ all" onPress={handleExpandAll} />
49
65
  )
50
66
  }
51
67
  if (shrinkUp > 0) {
52
68
  controls.push(
53
69
  <Spacer key="sp3" />,
54
- <Button
55
- key="shrinkUp"
56
- label={`−↑${shrinkUp}`}
57
- onPress={() => dispatch.adjust(foldId, 'top', -shrinkUp)}
58
- />
70
+ <Button key="shrinkUp" label={`−↑${shrinkUp}`} onPress={handleShrinkTop} />
59
71
  )
60
72
  }
61
73
  if (shrinkDown > 0) {
62
74
  controls.push(
63
75
  <Spacer key="sp4" />,
64
- <Button
65
- key="shrinkDown"
66
- label={`−↓${shrinkDown}`}
67
- onPress={() => dispatch.adjust(foldId, 'bottom', -shrinkDown)}
68
- />
76
+ <Button key="shrinkDown" label={`−↓${shrinkDown}`} onPress={handleShrinkBottom} />
69
77
  )
70
78
  }
71
79
 
@@ -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
@@ -34,6 +34,30 @@ import { tokenToSpan } from './highlight'
34
34
  import { useSegmentVirtualization } from './use-segment-virtualization'
35
35
 
36
36
  const OVERSCAN = 24
37
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
38
+ const HIDDEN_SCROLLBAR_OPTIONS = { visible: false }
39
+
40
+ // Stable per-row key derived from the line identities in the row, so rows keep a
41
+ // consistent identity across fold expand/collapse rather than relying on position.
42
+ function splitCellKey(cell: SplitCell): string {
43
+ switch (cell.type) {
44
+ case 'context':
45
+ return `c${cell.lineNumber}`
46
+ case 'addition':
47
+ return `a${cell.lineNumber}`
48
+ case 'deletion':
49
+ return `d${cell.lineNumber}`
50
+ case 'fold':
51
+ return `f${cell.fold.foldId}`
52
+ case 'filler':
53
+ return 'x'
54
+ }
55
+ }
56
+
57
+ function splitRowKey(row: SplitRowOrHeader): string {
58
+ if (row.type === 'hunk-header') return `hh:${row.spec}`
59
+ return `${splitCellKey(row.left)}|${splitCellKey(row.right)}`
60
+ }
37
61
 
38
62
  export interface SplitViewHandle {
39
63
  leftScroll: ScrollBoxRenderable | null
@@ -154,15 +178,15 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
154
178
  flexGrow={1}
155
179
  scrollY
156
180
  viewportCulling
157
- contentOptions={{ flexDirection: 'column', gap: 0 }}
158
- verticalScrollbarOptions={{ visible: false }}
181
+ contentOptions={COLUMN_CONTENT_OPTIONS}
182
+ verticalScrollbarOptions={HIDDEN_SCROLLBAR_OPTIONS}
159
183
  onMouseScroll={handleScroll}
160
184
  >
161
185
  {visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
162
186
  {renderedSegments.map((rendered) =>
163
- rendered.rows.map((row, i) => (
187
+ rendered.rows.map((row) => (
164
188
  <SideRow
165
- key={`${rendered.segment.id}:left:${i}`}
189
+ key={`${rendered.segment.id}:left:${splitRowKey(row)}`}
166
190
  cell={row.type === 'row' ? row.left : null}
167
191
  foldDispatch={foldDispatch}
168
192
  gw={gw}
@@ -180,14 +204,14 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
180
204
  flexGrow={1}
181
205
  scrollY
182
206
  viewportCulling
183
- contentOptions={{ flexDirection: 'column', gap: 0 }}
207
+ contentOptions={COLUMN_CONTENT_OPTIONS}
184
208
  onMouseScroll={handleScroll}
185
209
  >
186
210
  {visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
187
211
  {renderedSegments.map((rendered) =>
188
- rendered.rows.map((row, i) => (
212
+ rendered.rows.map((row) => (
189
213
  <SideRow
190
- key={`${rendered.segment.id}:right:${i}`}
214
+ key={`${rendered.segment.id}:right:${splitRowKey(row)}`}
191
215
  cell={row.type === 'row' ? row.right : null}
192
216
  foldDispatch={foldDispatch}
193
217
  gw={gw}
@@ -230,7 +254,9 @@ function HunkHeaderRow({ row }: { row: Extract<SplitRowOrHeader, { type: 'hunk-h
230
254
  return (
231
255
  <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
232
256
  <text fg={t.text}>{row.spec}</text>
233
- {row.context ? <text fg={t.textMuted}> {row.context}</text> : null}
257
+ {row.context != null && row.context !== '' ? (
258
+ <text fg={t.textMuted}> {row.context}</text>
259
+ ) : null}
234
260
  </box>
235
261
  )
236
262
  }
@@ -285,10 +311,12 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
285
311
  {tokens.map((tok, i) => {
286
312
  const s = tokenToSpan(tok)
287
313
  let attributes = 0
288
- if (s.bold) attributes |= TextAttributes.BOLD
289
- if (s.italic) attributes |= TextAttributes.ITALIC
290
- if (s.underline) attributes |= TextAttributes.UNDERLINE
314
+ if (s.bold === true) attributes |= TextAttributes.BOLD
315
+ if (s.italic === true) attributes |= TextAttributes.ITALIC
316
+ if (s.underline === true) attributes |= TextAttributes.UNDERLINE
291
317
  return (
318
+ // Syntax tokens are positional within a single line and never reorder.
319
+ // eslint-disable-next-line react/no-array-index-key
292
320
  <span key={i} fg={s.fg ?? t.text} attributes={attributes}>
293
321
  {s.text}
294
322
  </span>
@@ -33,6 +33,24 @@ import { tokenToSpan } from './highlight'
33
33
  import { useSegmentVirtualization } from './use-segment-virtualization'
34
34
 
35
35
  const OVERSCAN = 24
36
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
37
+
38
+ // Stable per-row key derived from the line identities in the row, so rows keep a
39
+ // consistent identity across fold expand/collapse rather than relying on position.
40
+ function unifiedRowKey(row: UnifiedRowOrHeader): string {
41
+ switch (row.type) {
42
+ case 'hunk-header':
43
+ return `hh:${row.spec}`
44
+ case 'fold':
45
+ return `f:${row.fold.foldId}`
46
+ case 'context':
47
+ return `c:${row.delLineNumber}:${row.addLineNumber}`
48
+ case 'addition':
49
+ return `a:${row.lineNumber}`
50
+ case 'deletion':
51
+ return `d:${row.lineNumber}`
52
+ }
53
+ }
36
54
 
37
55
  export interface StackedViewHandle {
38
56
  scroll: ScrollBoxRenderable | null
@@ -148,14 +166,14 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
148
166
  flexGrow={1}
149
167
  scrollY
150
168
  viewportCulling
151
- contentOptions={{ flexDirection: 'column', gap: 0 }}
169
+ contentOptions={COLUMN_CONTENT_OPTIONS}
152
170
  onMouseScroll={handleScroll}
153
171
  >
154
172
  {visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
155
173
  {renderedSegments.map((rendered) =>
156
- rendered.rows.map((row, i) => (
174
+ rendered.rows.map((row) => (
157
175
  <UnifiedRowRender
158
- key={`${rendered.segment.id}:${i}`}
176
+ key={`${rendered.segment.id}:${unifiedRowKey(row)}`}
159
177
  foldDispatch={foldDispatch}
160
178
  gw={gw}
161
179
  highlights={highlights}
@@ -186,7 +204,9 @@ function UnifiedRowRender({
186
204
  return (
187
205
  <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
188
206
  <text fg={t.text}>{row.spec}</text>
189
- {row.context ? <text fg={t.textMuted}> {row.context}</text> : null}
207
+ {row.context != null && row.context !== '' ? (
208
+ <text fg={t.textMuted}> {row.context}</text>
209
+ ) : null}
190
210
  </box>
191
211
  )
192
212
  }
@@ -230,10 +250,12 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
230
250
  {tokens.map((tok, i) => {
231
251
  const s = tokenToSpan(tok)
232
252
  let attributes = 0
233
- if (s.bold) attributes |= TextAttributes.BOLD
234
- if (s.italic) attributes |= TextAttributes.ITALIC
235
- if (s.underline) attributes |= TextAttributes.UNDERLINE
253
+ if (s.bold === true) attributes |= TextAttributes.BOLD
254
+ if (s.italic === true) attributes |= TextAttributes.ITALIC
255
+ if (s.underline === true) attributes |= TextAttributes.UNDERLINE
236
256
  return (
257
+ // Syntax tokens are positional within a single line and never reorder.
258
+ // eslint-disable-next-line react/no-array-index-key
237
259
  <span key={i} fg={s.fg ?? t.text} attributes={attributes}>
238
260
  {s.text}
239
261
  </span>
@@ -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
 
@@ -66,11 +67,15 @@ class PrefetchQueue {
66
67
  if (!task) break
67
68
  this.inflight.set(task.key, task.controller)
68
69
  this.running += 1
69
- void this.execute(task).finally(() => {
70
- this.inflight.delete(task.key)
71
- this.running -= 1
72
- this.pump()
73
- })
70
+ void (async () => {
71
+ try {
72
+ await this.execute(task)
73
+ } finally {
74
+ this.inflight.delete(task.key)
75
+ this.running -= 1
76
+ this.pump()
77
+ }
78
+ })()
74
79
  }
75
80
  }
76
81
  }
@@ -94,15 +99,20 @@ export function useDiffPrefetch(
94
99
 
95
100
  const queueRef = useRef<PrefetchQueue | null>(null)
96
101
 
97
- const { enabled, headOffset, projectPath } = opts
102
+ const { compareRef, enabled, headOffset, projectPath } = opts
98
103
  const runTaskRef = useRef<(task: Task) => Promise<void>>(async () => {})
99
104
 
100
105
  // Keep a ref to the execute function so the queue keeps the latest closure
101
106
  // without needing to recreate the queue itself.
102
107
  runTaskRef.current = async (task: Task) => {
103
- if (!projectPath) return
108
+ if (!(projectPath != null && projectPath !== '')) return
104
109
  try {
105
- const diff = await fetchDiff(task.file.repoPath ?? projectPath, task.file, headOffset)
110
+ const diff = await fetchDiff(
111
+ task.file.repoPath ?? projectPath,
112
+ task.file,
113
+ headOffset,
114
+ compareRef
115
+ )
106
116
  if (task.controller.signal.aborted) return
107
117
  const hash = diffHash(diff.rawDiff)
108
118
  dispatchGlobal({ diff, hash, key: task.key, type: 'git-mode-set-diff' })
@@ -122,13 +132,19 @@ export function useDiffPrefetch(
122
132
  }
123
133
 
124
134
  if (!queueRef.current) {
125
- queueRef.current = new PrefetchQueue((task) => runTaskRef.current(task), MAX_CONCURRENCY)
135
+ queueRef.current = new PrefetchQueue(async (task) => runTaskRef.current(task), MAX_CONCURRENCY)
126
136
  }
127
137
 
128
138
  useEffect(() => {
129
139
  const queue = queueRef.current
130
140
  if (!queue) return
131
- if (!enabled || radius <= 0 || !projectPath || !selectedEntryKey) {
141
+ if (
142
+ !enabled ||
143
+ radius <= 0 ||
144
+ projectPath == null ||
145
+ projectPath === '' ||
146
+ !(selectedEntryKey != null && selectedEntryKey !== '')
147
+ ) {
132
148
  queue.cancelAll()
133
149
  return
134
150
  }
@@ -149,7 +165,7 @@ export function useDiffPrefetch(
149
165
  const key = row.key
150
166
  if (diffs[key]) continue
151
167
  if (parsed[key]) continue
152
- if (loading[key]) continue
168
+ if (loading[key] === true) continue
153
169
  tasks.push({
154
170
  controller: new AbortController(),
155
171
  distance: Math.abs(i - selectedIdx),
@@ -171,6 +187,7 @@ export function useDiffPrefetch(
171
187
  parsed,
172
188
  loading,
173
189
  headOffset,
190
+ compareRef,
174
191
  ])
175
192
 
176
193
  useEffect(() => {
@@ -124,8 +124,9 @@ export function useDiffPreparation(
124
124
  const owner = requestOwner
125
125
  const version = requestVersionRef.current
126
126
  setPreparing(true)
127
- void prepareDiff(diff, path, { signal: controller.signal })
128
- .then((result) => {
127
+ void (async () => {
128
+ try {
129
+ const result = await prepareDiff(diff, path, { signal: controller.signal })
129
130
  if (
130
131
  controller.signal.aborted ||
131
132
  requestVersionRef.current !== version ||
@@ -142,9 +143,9 @@ export function useDiffPreparation(
142
143
  key: cacheKey,
143
144
  type: 'git-mode-set-parsed',
144
145
  })
145
- })
146
- .catch(() => {})
147
- .finally(() => {
146
+ } catch {
147
+ // Aborted or failed preparation; nothing to surface here.
148
+ } finally {
148
149
  if (
149
150
  !controller.signal.aborted &&
150
151
  requestVersionRef.current === version &&
@@ -152,7 +153,8 @@ export function useDiffPreparation(
152
153
  ) {
153
154
  setPreparing(false)
154
155
  }
155
- })
156
+ }
157
+ })()
156
158
 
157
159
  return () => {
158
160
  controller.abort()
@@ -163,11 +165,11 @@ export function useDiffPreparation(
163
165
  (segments: readonly DiffSegment[]) => {
164
166
  const file = parsed.file
165
167
  const filetype = parsed.filetype
166
- if (!file || !filetype) return
168
+ if (!file || !(filetype != null && filetype !== '')) return
167
169
  const owner = requestOwner
168
170
  const version = requestVersionRef.current
169
171
  const activeHash = cachedParsed?.hash ?? (localOwnerMatches ? localHash : null)
170
- if (!activeHash) return
172
+ if (!(activeHash != null && activeHash !== '')) return
171
173
  const next = segments.filter(
172
174
  (segment) =>
173
175
  (segment.kind === 'context' || segment.kind === 'change') &&