@brimveyn/aimux 1.13.0 → 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 (34) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +11 -11
  3. package/src/app-runtime/side-effects.ts +61 -38
  4. package/src/daemon/daemon.ts +18 -8
  5. package/src/git/command-queue.ts +18 -2
  6. package/src/session-backend/remote-session-backend.ts +57 -42
  7. package/src/ui/components/git/diff-renderer/fold-strip.tsx +30 -23
  8. package/src/ui/components/git/diff-renderer/split-view.tsx +33 -7
  9. package/src/ui/components/git/diff-renderer/stacked-view.tsx +23 -3
  10. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +9 -5
  11. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +8 -6
  12. package/src/ui/components/git/git-panel.tsx +114 -75
  13. package/src/ui/components/git/git-view.tsx +26 -18
  14. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +10 -10
  15. package/src/ui/components/layout/session-bar.tsx +134 -116
  16. package/src/ui/components/layout/sidebar/sidebar.tsx +89 -53
  17. package/src/ui/components/layout/sidebar/tab-item.tsx +65 -54
  18. package/src/ui/components/layout/split-layout.tsx +27 -20
  19. package/src/ui/components/layout/terminal-pane.tsx +154 -114
  20. package/src/ui/components/modals/app/help-modal.tsx +25 -18
  21. package/src/ui/components/modals/git/git-commit-modal.tsx +15 -8
  22. package/src/ui/components/modals/sessions/create-session-modal.tsx +13 -9
  23. package/src/ui/components/modals/sessions/session-picker-modal.tsx +35 -28
  24. package/src/ui/components/modals/shared/form.tsx +2 -1
  25. package/src/ui/components/modals/shared/picker.tsx +49 -33
  26. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +42 -29
  27. package/src/ui/components/modals/tabs/new-tab-modal.tsx +82 -66
  28. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  29. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +23 -6
  30. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +11 -6
  31. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  32. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  33. package/src/ui/components/primitives/list-item.tsx +20 -4
  34. package/src/ui/root.tsx +77 -48
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.6.0",
63
+ "@brimveyn/aimux-config": "0.6.2",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -109,14 +109,14 @@ export function attachCurrentSession({
109
109
  attachRequestIdRef.current = attachRequestId
110
110
  let cancelled = false
111
111
 
112
- void backend
113
- .attach({
114
- cols: layoutRef.current.terminalCols,
115
- rows: layoutRef.current.terminalRows,
116
- sessionId: currentSessionId,
117
- workspaceSnapshot: currentSessionWorkspaceSnapshot,
118
- })
119
- .then((result) => {
112
+ void (async () => {
113
+ try {
114
+ const result = await backend.attach({
115
+ cols: layoutRef.current.terminalCols,
116
+ rows: layoutRef.current.terminalRows,
117
+ sessionId: currentSessionId,
118
+ workspaceSnapshot: currentSessionWorkspaceSnapshot,
119
+ })
120
120
  if (cancelled || attachRequestIdRef.current !== attachRequestId) {
121
121
  return
122
122
  }
@@ -134,8 +134,7 @@ export function attachCurrentSession({
134
134
  layoutRef,
135
135
  backend
136
136
  )
137
- })
138
- .catch((error) => {
137
+ } catch (error) {
139
138
  if (cancelled || attachRequestIdRef.current !== attachRequestId) {
140
139
  return
141
140
  }
@@ -151,7 +150,8 @@ export function attachCurrentSession({
151
150
  layoutRef,
152
151
  backend
153
152
  )
154
- })
153
+ }
154
+ })()
155
155
 
156
156
  return () => {
157
157
  cancelled = true
@@ -508,9 +508,21 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
508
508
  const worktreeName = state.modal.worktreeName
509
509
  const branchName = state.modal.branchName
510
510
  const sourceWorktreeId = getNewTabTargetWorktreeId(state)
511
- void enqueueGitOp(async () =>
512
- launchAssistantInNewWorktree(ctx, option.id, worktreeName, branchName, sourceWorktreeId)
513
- ).catch((error) => toast.error(error instanceof Error ? error.message : String(error)))
511
+ void (async () => {
512
+ try {
513
+ await enqueueGitOp(async () =>
514
+ launchAssistantInNewWorktree(
515
+ ctx,
516
+ option.id,
517
+ worktreeName,
518
+ branchName,
519
+ sourceWorktreeId
520
+ )
521
+ )
522
+ } catch (error) {
523
+ toast.error(error instanceof Error ? error.message : String(error))
524
+ }
525
+ })()
514
526
  return
515
527
  }
516
528
  launchAssistant(ctx, option.id, getNewTabTargetWorktreeId(state))
@@ -534,44 +546,54 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
534
546
  return
535
547
  }
536
548
  case 'delete-worktree': {
537
- void enqueueGitOp(async () =>
538
- runDeleteWorktree(
539
- { ...ctx, state: ctx.getState() },
540
- effect.sessionId,
541
- effect.worktreeId,
542
- !!(effect.force === true)
543
- )
544
- ).catch((error) => {
545
- const message = error instanceof Error ? error.message : String(error)
546
- const forceable = isForceableWorktreeDeleteError(message)
547
- const latest = ctx.getState()
548
- if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
549
- const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
550
- const selected = session?.worktrees?.[latest.modal.selectedIndex]
551
- if (selected && selected.id !== effect.worktreeId) {
552
- ctx.dispatch({ message, type: 'git-mode-set-message' })
553
- return
549
+ void (async () => {
550
+ try {
551
+ await enqueueGitOp(async () =>
552
+ runDeleteWorktree(
553
+ { ...ctx, state: ctx.getState() },
554
+ effect.sessionId,
555
+ effect.worktreeId,
556
+ !!(effect.force === true)
557
+ )
558
+ )
559
+ } catch (error) {
560
+ const message = error instanceof Error ? error.message : String(error)
561
+ const forceable = isForceableWorktreeDeleteError(message)
562
+ const latest = ctx.getState()
563
+ if (latest.modal.type === 'new-tab' && latest.modal.step === 'worktree') {
564
+ const session = latest.sessions.find((entry) => entry.id === effect.sessionId)
565
+ const selected = session?.worktrees?.[latest.modal.selectedIndex]
566
+ if (selected && selected.id !== effect.worktreeId) {
567
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
568
+ return
569
+ }
554
570
  }
571
+ ctx.dispatch({
572
+ confirmWorktreeId: forceable ? effect.worktreeId : null,
573
+ message: forceable ? message : `Could not delete worktree: ${message}`,
574
+ type: 'set-new-tab-worktree-delete-state',
575
+ })
576
+ ctx.dispatch({ message, type: 'git-mode-set-message' })
555
577
  }
556
- ctx.dispatch({
557
- confirmWorktreeId: forceable ? effect.worktreeId : null,
558
- message: forceable ? message : `Could not delete worktree: ${message}`,
559
- type: 'set-new-tab-worktree-delete-state',
560
- })
561
- ctx.dispatch({ message, type: 'git-mode-set-message' })
562
- })
578
+ })()
563
579
  return
564
580
  }
565
581
  case 'move-worktree': {
566
- void enqueueGitOp(async () =>
567
- runMoveWorktree(
568
- { ...ctx, state: ctx.getState() },
569
- effect.sessionId,
570
- effect.sourceWorktreeId,
571
- effect.targetWorktreeId,
572
- effect.deleteSource === true
573
- )
574
- ).catch((error) => toast.error(error instanceof Error ? error.message : String(error)))
582
+ void (async () => {
583
+ try {
584
+ await enqueueGitOp(async () =>
585
+ runMoveWorktree(
586
+ { ...ctx, state: ctx.getState() },
587
+ effect.sessionId,
588
+ effect.sourceWorktreeId,
589
+ effect.targetWorktreeId,
590
+ effect.deleteSource === true
591
+ )
592
+ )
593
+ } catch (error) {
594
+ toast.error(error instanceof Error ? error.message : String(error))
595
+ }
596
+ })()
575
597
  return
576
598
  }
577
599
  case 'open-rename-selected-session': {
@@ -1364,14 +1386,15 @@ function runUpdateFromTui(ctx: SideEffectContext, latestVersion: string): void {
1364
1386
  stdin: 'inherit',
1365
1387
  stdout: 'inherit',
1366
1388
  })
1367
- void proc.exited.then((code) => {
1389
+ void (async () => {
1390
+ const code = await proc.exited
1368
1391
  if (code === 0) {
1369
1392
  process.stdout.write(`\nUpdated. Run \`aimux\` to start the new version.\n`)
1370
1393
  } else {
1371
1394
  process.stderr.write(`\nUpdate failed (exit code ${code}).\n`)
1372
1395
  }
1373
1396
  process.exit(code ?? 1)
1374
- })
1397
+ })()
1375
1398
  }
1376
1399
 
1377
1400
  async function runGitAction(
@@ -293,12 +293,16 @@ export async function runDaemon(): Promise<void> {
293
293
  * previous broadcast state, matching pre-fix behaviour).
294
294
  */
295
295
  const updateTmBroadcastForClientCount = (count: number): void => {
296
- void manager.setBroadcastEnabled(count > 0).catch((error) => {
297
- logDebug('daemon.setBroadcastEnabled.error', {
298
- count,
299
- error: error instanceof Error ? error.message : String(error),
300
- })
301
- })
296
+ void (async () => {
297
+ try {
298
+ await manager.setBroadcastEnabled(count > 0)
299
+ } catch (error) {
300
+ logDebug('daemon.setBroadcastEnabled.error', {
301
+ count,
302
+ error: error instanceof Error ? error.message : String(error),
303
+ })
304
+ }
305
+ })()
302
306
  }
303
307
 
304
308
  // Initial state: no clients yet, ask the TM to suspend broadcast.
@@ -317,7 +321,13 @@ export async function runDaemon(): Promise<void> {
317
321
  let processing: Promise<void> = Promise.resolve()
318
322
 
319
323
  socket.on('data', (chunk) => {
320
- processing = processing.then(async () => {
324
+ const previous = processing
325
+ processing = (async () => {
326
+ try {
327
+ await previous
328
+ } catch {
329
+ // A prior chunk's failure shouldn't block later chunks on this socket.
330
+ }
321
331
  try {
322
332
  for (const message of decoder.push(chunk)) {
323
333
  try {
@@ -556,7 +566,7 @@ export async function runDaemon(): Promise<void> {
556
566
  decoder.reset()
557
567
  send(socket, { id: crypto.randomUUID(), payload: { message }, type: 'error' })
558
568
  }
559
- })
569
+ })()
560
570
  })
561
571
 
562
572
  socket.on('close', () => {
@@ -1,7 +1,23 @@
1
1
  let tail: Promise<unknown> = Promise.resolve()
2
2
 
3
3
  export async function enqueueGitOp<T>(op: () => Promise<T>): Promise<T> {
4
- const next = tail.then(op, op)
5
- tail = next.catch(() => {})
4
+ const previous = tail
5
+ const run = async (): Promise<T> => {
6
+ try {
7
+ await previous
8
+ } catch {
9
+ // Previous op already surfaced its own failure; keep the queue moving.
10
+ }
11
+ return op()
12
+ }
13
+ const next = run()
14
+ // Advance the queue tail without letting this op's rejection break the chain.
15
+ tail = (async () => {
16
+ try {
17
+ await next
18
+ } catch {
19
+ // Swallow so the next enqueued op still runs.
20
+ }
21
+ })()
6
22
  return next
7
23
  }
@@ -144,6 +144,17 @@ export class RemoteSessionBackend
144
144
  }
145
145
  }
146
146
 
147
+ /** Fire-and-forget a command, reporting any failure via reportCommandError. */
148
+ private dispatchCommand(request: ClientRequest, context: string, tabId?: string): void {
149
+ void (async () => {
150
+ try {
151
+ await this.sendExpectOk(request)
152
+ } catch (error) {
153
+ this.reportCommandError(context, error, tabId)
154
+ }
155
+ })()
156
+ }
157
+
147
158
  private handleServerEvent(message: ServerEvent): void {
148
159
  logDebug('backend.remote.event', { type: message.type })
149
160
  switch (message.type) {
@@ -352,8 +363,10 @@ export class RemoteSessionBackend
352
363
  return
353
364
  }
354
365
 
355
- void this.sendExpectOk({ id: crypto.randomUUID(), payload: options, type: 'createTab' }).catch(
356
- (error) => this.reportCommandError('createTab', error, options.tabId)
366
+ this.dispatchCommand(
367
+ { id: crypto.randomUUID(), payload: options, type: 'createTab' },
368
+ 'createTab',
369
+ options.tabId
357
370
  )
358
371
  }
359
372
 
@@ -362,55 +375,54 @@ export class RemoteSessionBackend
362
375
  logDebug('backend.remote.skipWriteBeforeAttach', { inputLength: input.length, tabId })
363
376
  return
364
377
  }
365
- void this.sendExpectOk({
366
- id: crypto.randomUUID(),
367
- payload: { data: input, tabId },
368
- type: 'write',
369
- }).catch((error) => this.reportCommandError('write', error, tabId))
378
+ this.dispatchCommand(
379
+ { id: crypto.randomUUID(), payload: { data: input, tabId }, type: 'write' },
380
+ 'write',
381
+ tabId
382
+ )
370
383
  }
371
384
 
372
385
  scrollViewport(tabId: string, deltaLines: number): void {
373
386
  if (!this.attached) {
374
387
  return
375
388
  }
376
- void this.sendExpectOk({
377
- id: crypto.randomUUID(),
378
- payload: { deltaLines, tabId },
379
- type: 'scroll',
380
- }).catch((error) => this.reportCommandError('scroll', error, tabId))
389
+ this.dispatchCommand(
390
+ { id: crypto.randomUUID(), payload: { deltaLines, tabId }, type: 'scroll' },
391
+ 'scroll',
392
+ tabId
393
+ )
381
394
  }
382
395
 
383
396
  scrollViewportToBottom(tabId: string): void {
384
397
  if (!this.attached) {
385
398
  return
386
399
  }
387
- void this.sendExpectOk({
388
- id: crypto.randomUUID(),
389
- payload: { tabId },
390
- type: 'scrollToBottom',
391
- }).catch((error) => this.reportCommandError('scrollToBottom', error, tabId))
400
+ this.dispatchCommand(
401
+ { id: crypto.randomUUID(), payload: { tabId }, type: 'scrollToBottom' },
402
+ 'scrollToBottom',
403
+ tabId
404
+ )
392
405
  }
393
406
 
394
407
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
395
408
  if (!this.attached) {
396
409
  return
397
410
  }
398
- void this.sendExpectOk({
399
- id: crypto.randomUUID(),
400
- payload: { intent, tabId },
401
- type: 'reapplyScrollIntent',
402
- }).catch((error) => this.reportCommandError('reapplyScrollIntent', error, tabId))
411
+ this.dispatchCommand(
412
+ { id: crypto.randomUUID(), payload: { intent, tabId }, type: 'reapplyScrollIntent' },
413
+ 'reapplyScrollIntent',
414
+ tabId
415
+ )
403
416
  }
404
417
 
405
418
  setActiveTab(tabId: string | null): void {
406
419
  if (!this.attached) {
407
420
  return
408
421
  }
409
- void this.sendExpectOk({
410
- id: crypto.randomUUID(),
411
- payload: { tabId },
412
- type: 'setActiveTab',
413
- }).catch((error) => this.reportCommandError('setActiveTab', error))
422
+ this.dispatchCommand(
423
+ { id: crypto.randomUUID(), payload: { tabId }, type: 'setActiveTab' },
424
+ 'setActiveTab'
425
+ )
414
426
  }
415
427
 
416
428
  resizeAll(
@@ -425,11 +437,14 @@ export class RemoteSessionBackend
425
437
  }
426
438
  logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
427
439
  const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
428
- void this.sendExpectOk({
429
- id: crypto.randomUUID(),
430
- payload: { cols, intents: intentsRecord, rows },
431
- type: 'resizeClient',
432
- }).catch((error) => this.reportCommandError('resizeClient', error))
440
+ this.dispatchCommand(
441
+ {
442
+ id: crypto.randomUUID(),
443
+ payload: { cols, intents: intentsRecord, rows },
444
+ type: 'resizeClient',
445
+ },
446
+ 'resizeClient'
447
+ )
433
448
  }
434
449
 
435
450
  resizeTab(
@@ -442,19 +457,21 @@ export class RemoteSessionBackend
442
457
  if (!this.attached) {
443
458
  return
444
459
  }
445
- void this.sendExpectOk({
446
- id: crypto.randomUUID(),
447
- payload: { cols, intent, rows, tabId },
448
- type: 'resizeTab',
449
- }).catch((error) => this.reportCommandError('resizeTab', error, tabId))
460
+ this.dispatchCommand(
461
+ { id: crypto.randomUUID(), payload: { cols, intent, rows, tabId }, type: 'resizeTab' },
462
+ 'resizeTab',
463
+ tabId
464
+ )
450
465
  }
451
466
 
452
467
  disposeSession(tabId: string): void {
453
468
  if (!this.attached) {
454
469
  return
455
470
  }
456
- void this.sendExpectOk({ id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' }).catch(
457
- (error) => this.reportCommandError('closeTab', error, tabId)
471
+ this.dispatchCommand(
472
+ { id: crypto.randomUUID(), payload: { tabId }, type: 'closeTab' },
473
+ 'closeTab',
474
+ tabId
458
475
  )
459
476
  }
460
477
 
@@ -462,9 +479,7 @@ export class RemoteSessionBackend
462
479
  if (!this.attached) {
463
480
  return
464
481
  }
465
- void this.sendExpectOk({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }).catch(
466
- (error) => this.reportCommandError('disposeAll', error)
467
- )
482
+ this.dispatchCommand({ id: crypto.randomUUID(), payload: {}, type: 'disposeAll' }, 'disposeAll')
468
483
  }
469
484
 
470
485
  async destroy(keepSessions = true): Promise<void> {
@@ -1,3 +1,5 @@
1
+ import { memo, useCallback } from 'react'
2
+
1
3
  import type { FoldDispatch } from './pierre-diff'
2
4
 
3
5
  import { useTheme } from '../../../theme'
@@ -8,7 +10,7 @@ interface Props {
8
10
  dispatch: FoldDispatch
9
11
  }
10
12
 
11
- function Button({ label, onPress }: { label: string; onPress: () => void }) {
13
+ const Button = memo(function Button({ label, onPress }: { label: string; onPress: () => void }) {
12
14
  const t = useTheme()
13
15
  const bg = t.diffContextBg
14
16
  return (
@@ -16,7 +18,7 @@ function Button({ label, onPress }: { label: string; onPress: () => void }) {
16
18
  <text fg={t.primary}>{label}</text>
17
19
  </box>
18
20
  )
19
- }
21
+ })
20
22
 
21
23
  function Spacer() {
22
24
  return <text> </text>
@@ -31,42 +33,47 @@ export function FoldStrip({ dispatch, fold }: Props) {
31
33
  const shrinkUp = Math.min(FOLD_STEP, topExpanded)
32
34
  const shrinkDown = Math.min(FOLD_STEP, bottomExpanded)
33
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
+
34
57
  const controls: React.ReactNode[] = []
35
58
  if (hidden > 0) {
36
59
  controls.push(
37
- <Button
38
- key="up"
39
- label={`↑${stepUp}`}
40
- onPress={() => dispatch.adjust(foldId, 'top', stepUp)}
41
- />,
60
+ <Button key="up" label={`↑${stepUp}`} onPress={handleExpandTop} />,
42
61
  <Spacer key="sp1" />,
43
- <Button
44
- key="down"
45
- label={`↓${stepDown}`}
46
- onPress={() => dispatch.adjust(foldId, 'bottom', stepDown)}
47
- />,
62
+ <Button key="down" label={`↓${stepDown}`} onPress={handleExpandBottom} />,
48
63
  <Spacer key="sp2" />,
49
- <Button key="all" label="⇅ all" onPress={() => dispatch.set(foldId, total, 0)} />
64
+ <Button key="all" label="⇅ all" onPress={handleExpandAll} />
50
65
  )
51
66
  }
52
67
  if (shrinkUp > 0) {
53
68
  controls.push(
54
69
  <Spacer key="sp3" />,
55
- <Button
56
- key="shrinkUp"
57
- label={`−↑${shrinkUp}`}
58
- onPress={() => dispatch.adjust(foldId, 'top', -shrinkUp)}
59
- />
70
+ <Button key="shrinkUp" label={`−↑${shrinkUp}`} onPress={handleShrinkTop} />
60
71
  )
61
72
  }
62
73
  if (shrinkDown > 0) {
63
74
  controls.push(
64
75
  <Spacer key="sp4" />,
65
- <Button
66
- key="shrinkDown"
67
- label={`−↓${shrinkDown}`}
68
- onPress={() => dispatch.adjust(foldId, 'bottom', -shrinkDown)}
69
- />
76
+ <Button key="shrinkDown" label={`−↓${shrinkDown}`} onPress={handleShrinkBottom} />
70
77
  )
71
78
  }
72
79
 
@@ -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}
@@ -291,6 +315,8 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
291
315
  if (s.italic === true) attributes |= TextAttributes.ITALIC
292
316
  if (s.underline === true) attributes |= TextAttributes.UNDERLINE
293
317
  return (
318
+ // Syntax tokens are positional within a single line and never reorder.
319
+ // eslint-disable-next-line react/no-array-index-key
294
320
  <span key={i} fg={s.fg ?? t.text} attributes={attributes}>
295
321
  {s.text}
296
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}
@@ -236,6 +254,8 @@ function LineContent({ content, tokens }: { content: string; tokens: ThemedToken
236
254
  if (s.italic === true) attributes |= TextAttributes.ITALIC
237
255
  if (s.underline === true) attributes |= TextAttributes.UNDERLINE
238
256
  return (
257
+ // Syntax tokens are positional within a single line and never reorder.
258
+ // eslint-disable-next-line react/no-array-index-key
239
259
  <span key={i} fg={s.fg ?? t.text} attributes={attributes}>
240
260
  {s.text}
241
261
  </span>