@brimveyn/aimux 1.13.0 → 1.14.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 (57) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +12 -14
  3. package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
  4. package/src/app-runtime/pty-write.ts +6 -9
  5. package/src/app-runtime/side-effects.ts +65 -56
  6. package/src/app-runtime/snippet-actions.ts +2 -3
  7. package/src/app-runtime/use-backend-runtime.ts +4 -7
  8. package/src/app-runtime/use-mouse-handlers.ts +30 -1
  9. package/src/app-runtime/use-renderer-bindings.ts +4 -12
  10. package/src/app-runtime/use-terminal-resize.ts +6 -18
  11. package/src/app.tsx +0 -3
  12. package/src/daemon/daemon.ts +20 -27
  13. package/src/daemon/session-manager.ts +3 -15
  14. package/src/daemon/session-registry.ts +11 -30
  15. package/src/git/command-queue.ts +18 -2
  16. package/src/input/terminal-text-extraction.ts +7 -8
  17. package/src/ipc/manager-protocol.ts +6 -38
  18. package/src/ipc/protocol.ts +9 -37
  19. package/src/pty/pty-manager.ts +20 -31
  20. package/src/pty/terminal-snapshot.ts +43 -0
  21. package/src/session-backend/local-session-backend.ts +7 -31
  22. package/src/session-backend/remote-session-backend.ts +51 -63
  23. package/src/session-backend/types.ts +2 -15
  24. package/src/state/reducers/tab-state.ts +2 -20
  25. package/src/state/session-persistence.ts +2 -22
  26. package/src/state/types.ts +3 -11
  27. package/src/state/validation.ts +0 -8
  28. package/src/terminal-manager/manager-client.ts +5 -29
  29. package/src/terminal-manager/terminal-manager.ts +2 -17
  30. package/src/ui/components/git/diff-renderer/fold-strip.tsx +30 -23
  31. package/src/ui/components/git/diff-renderer/split-view.tsx +33 -7
  32. package/src/ui/components/git/diff-renderer/stacked-view.tsx +23 -3
  33. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +9 -5
  34. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +8 -6
  35. package/src/ui/components/git/git-panel.tsx +114 -75
  36. package/src/ui/components/git/git-view.tsx +26 -18
  37. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +10 -10
  38. package/src/ui/components/layout/session-bar.tsx +134 -116
  39. package/src/ui/components/layout/sidebar/sidebar.tsx +89 -53
  40. package/src/ui/components/layout/sidebar/tab-item.tsx +65 -54
  41. package/src/ui/components/layout/split-layout.tsx +27 -20
  42. package/src/ui/components/layout/terminal-pane.tsx +154 -114
  43. package/src/ui/components/modals/app/help-modal.tsx +25 -18
  44. package/src/ui/components/modals/git/git-commit-modal.tsx +15 -8
  45. package/src/ui/components/modals/sessions/create-session-modal.tsx +13 -9
  46. package/src/ui/components/modals/sessions/session-picker-modal.tsx +35 -28
  47. package/src/ui/components/modals/shared/form.tsx +2 -1
  48. package/src/ui/components/modals/shared/picker.tsx +49 -33
  49. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +42 -29
  50. package/src/ui/components/modals/tabs/new-tab-modal.tsx +82 -66
  51. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  52. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +23 -6
  53. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +11 -6
  54. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  55. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  56. package/src/ui/components/primitives/list-item.tsx +20 -4
  57. package/src/ui/root.tsx +77 -48
@@ -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>
@@ -67,11 +67,15 @@ class PrefetchQueue {
67
67
  if (!task) break
68
68
  this.inflight.set(task.key, task.controller)
69
69
  this.running += 1
70
- void this.execute(task).finally(() => {
71
- this.inflight.delete(task.key)
72
- this.running -= 1
73
- this.pump()
74
- })
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
+ })()
75
79
  }
76
80
  }
77
81
  }
@@ -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()
@@ -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>
@@ -167,23 +167,30 @@ function renderDiffCount(
167
167
  )
168
168
  }
169
169
 
170
- function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode {
171
- const t = getCurrentTheme()
172
- const bg = isSelected && !getTransparent() ? t.backgroundElement : undefined
173
- 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(() => {
174
181
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
175
- }
176
- const onToggle = (): void => {
182
+ }, [row.key])
183
+ const onToggle = useCallback(() => {
177
184
  dispatchGlobal({ key: row.key, type: 'git-mode-toggle-folder' })
178
- }
185
+ }, [row.key])
179
186
  return (
180
- <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
187
+ <box flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
181
188
  <box flexGrow={1} overflow="hidden" paddingLeft={row.depth * 2}>
182
189
  <box flexDirection="row" gap={1} onMouseDown={onToggle}>
183
190
  <text selectable={false} fg={t.textMuted} bg={bg}>
184
191
  {row.isCollapsed ? '▸' : '▾'}
185
192
  </text>
186
- <text selectable={false} fg={getCurrentTheme().textMuted} bg={bg}>
193
+ <text selectable={false} fg={t.textMuted} bg={bg}>
187
194
  {row.isCollapsed ? '\uf07b' : '\uf07c'}
188
195
  </text>
189
196
  <text selectable={false} fg={t.textMuted} bg={bg} wrapMode="none">
@@ -193,24 +200,35 @@ function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode
193
200
  </box>
194
201
  </box>
195
202
  )
196
- }
203
+ })
197
204
 
198
- function renderFileRow(
199
- row: GitTreeFileRow,
200
- addedW: number,
201
- removedW: number,
202
- isSelected: boolean,
203
- fileListMode: GitFileListMode,
204
- pathConfig: GitPanePathConfig,
205
- 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
206
222
  repoPrefixes: Record<string, string>
207
- ): ReactNode {
223
+ }) {
224
+ const t = useTheme()
225
+ const transparent = useTransparent()
208
226
  const file = row.file
209
227
  const hasNumstat = file.added !== null || file.removed !== null
210
- const bg = isSelected && !getTransparent() ? getCurrentTheme().backgroundElement : undefined
211
- const onSelect = (): void => {
228
+ const bg = isSelected && !transparent ? t.backgroundElement : undefined
229
+ const onSelect = useCallback(() => {
212
230
  dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
213
- }
231
+ }, [row.key])
214
232
  // Repo disambiguation prefix: only in flat mode, only when the file came
215
233
  // from a sub-repo (root repo files get an empty prefix).
216
234
  const repoTag =
@@ -218,7 +236,7 @@ function renderFileRow(
218
236
  ? (repoPrefixes[file.repoPath] ?? '')
219
237
  : ''
220
238
  return (
221
- <box key={row.key} flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
239
+ <box flexDirection="row" gap={1} backgroundColor={bg} onMouseDown={onSelect}>
222
240
  <box width={2} flexShrink={0} justifyContent="center">
223
241
  <text selectable={false} fg={statusColor(file.status)} bg={bg}>
224
242
  <strong>{displayStatus(file)}</strong>
@@ -226,7 +244,7 @@ function renderFileRow(
226
244
  </box>
227
245
  {repoTag ? (
228
246
  <box flexShrink={0}>
229
- <text selectable={false} fg={getCurrentTheme().primary} bg={bg}>
247
+ <text selectable={false} fg={t.primary} bg={bg}>
230
248
  <strong>{repoTag}</strong>
231
249
  </text>
232
250
  </box>
@@ -237,32 +255,44 @@ function renderFileRow(
237
255
  {renderDiffCount(file, addedW, removedW, bg, diffCountConfig, hasNumstat)}
238
256
  </box>
239
257
  )
240
- }
258
+ })
241
259
 
242
- function renderTreeSection(
243
- section: GitFileSection,
244
- title: string,
245
- files: GitFileEntry[],
246
- rows: (GitTreeFolderRow | GitTreeFileRow)[],
247
- addedW: number,
248
- removedW: number,
249
- fileListMode: GitFileListMode,
250
- selectedEntryKey: string | null | undefined,
251
- showListModeToggle: boolean,
252
- pathConfig: GitPanePathConfig,
253
- diffCountConfig: GitPaneDiffCountConfig,
254
- 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
255
285
  repoPrefixes: Record<string, string>
256
- ): ReactNode {
257
- if (files.length === 0) return null
258
- const t2 = getCurrentTheme()
286
+ }) {
287
+ const t2 = useTheme()
259
288
  const nextFileListMode = fileListMode === 'tree' ? 'flat' : 'tree'
260
- const toggleListMode = (): void => {
289
+ const toggleListMode = useCallback(() => {
261
290
  dispatchGlobal({ type: 'git-mode-toggle-file-list-mode' })
262
291
  runSideEffectGlobal({ mode: nextFileListMode, type: 'persist-git-file-list-mode' })
263
- }
292
+ }, [nextFileListMode])
293
+ if (files.length === 0) return null
264
294
  return (
265
- <box key={section} flexDirection="column" marginTop={marginTop}>
295
+ <box flexDirection="column" marginTop={marginTop}>
266
296
  <box flexDirection="row" justifyContent="space-between">
267
297
  <text selectable={false} fg={t2.text}>
268
298
  <strong>
@@ -284,22 +314,25 @@ function renderTreeSection(
284
314
  ) : null}
285
315
  </box>
286
316
  {rows.map((row) =>
287
- row.kind === 'folder'
288
- ? renderFolderRow(row, row.key === selectedEntryKey)
289
- : renderFileRow(
290
- row,
291
- addedW,
292
- removedW,
293
- row.key === selectedEntryKey,
294
- fileListMode,
295
- pathConfig,
296
- diffCountConfig,
297
- repoPrefixes
298
- )
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
+ )
299
332
  )}
300
333
  </box>
301
334
  )
302
- }
335
+ })
303
336
 
304
337
  interface StatusPlaceholder {
305
338
  label: string
@@ -340,6 +373,10 @@ function computeStatusPlaceholder(
340
373
 
341
374
  const DEFAULT_PATH_CONFIG: GitPanePathConfig = { enabled: true }
342
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)[] = []
343
380
 
344
381
  export const GitPanel = memo(function GitPanel({
345
382
  baseLabel,
@@ -399,29 +436,31 @@ export const GitPanel = memo(function GitPanel({
399
436
  flexGrow={1}
400
437
  ref={scrollRef}
401
438
  scrollY
402
- scrollbarOptions={{ visible: false }}
439
+ scrollbarOptions={HIDDEN_SCROLLBAR_OPTIONS}
403
440
  viewportCulling
404
- contentOptions={{ flexDirection: 'column', gap: 0 }}
441
+ contentOptions={COLUMN_CONTENT_OPTIONS}
405
442
  >
406
443
  {sectionOrder.map((key, idx) => {
407
444
  const section = tree.sections.find((entry) => entry.section === key)
408
445
  const priorHasFiles = sectionOrder
409
446
  .slice(0, idx)
410
447
  .some((k) => (tree.sections.find((e) => e.section === k)?.files.length ?? 0) > 0)
411
- return renderTreeSection(
412
- key,
413
- sectionTitle(key, headOffset, baseLabel),
414
- section?.files ?? [],
415
- section?.rows ?? [],
416
- addedW,
417
- removedW,
418
- fileListMode,
419
- selectedEntryKey,
420
- key === toggleSection,
421
- pathConfig,
422
- diffCountConfig,
423
- priorHasFiles ? 1 : 0,
424
- 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
+ />
425
464
  )
426
465
  })}
427
466
  </scrollbox>
@@ -1,5 +1,7 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
1
3
  import { useTerminalDimensions } from '@opentui/react'
2
- import { memo, useEffect, useRef, useState } 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'
@@ -145,9 +147,10 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
145
147
  return
146
148
  }
147
149
  let cancelled = false
148
- void getMergeBase(projectPath, reviewBaseRef).then((mergeBase) => {
150
+ void (async () => {
151
+ const mergeBase = await getMergeBase(projectPath, reviewBaseRef)
149
152
  if (!cancelled) setCompareRef(mergeBase)
150
- })
153
+ })()
151
154
  return () => {
152
155
  cancelled = true
153
156
  }
@@ -207,23 +210,24 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
207
210
  if (!(selectedDiffKey != null && selectedDiffKey !== '')) return
208
211
  if (diff || loading) return
209
212
  dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
210
- void fetchDiff(
211
- selectedFile.repoPath ?? projectPath,
212
- selectedFile,
213
- gitMode.headOffset,
214
- compareRef
215
- )
216
- .then((d) =>
213
+ void (async () => {
214
+ try {
215
+ const d = await fetchDiff(
216
+ selectedFile.repoPath ?? projectPath,
217
+ selectedFile,
218
+ gitMode.headOffset,
219
+ compareRef
220
+ )
217
221
  dispatchGlobal({
218
222
  diff: d,
219
223
  hash: diffHash(d.rawDiff),
220
224
  key: selectedDiffKey,
221
225
  type: 'git-mode-set-diff',
222
226
  })
223
- )
224
- .catch(() =>
227
+ } catch {
225
228
  dispatchGlobal({ key: selectedDiffKey, loading: false, type: 'git-mode-set-loading' })
226
- )
229
+ }
230
+ })()
227
231
  }, [
228
232
  focusMode,
229
233
  projectPath,
@@ -256,6 +260,8 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
256
260
  )
257
261
  } else if (actionMessage != null && actionMessage !== '') {
258
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
259
265
  <text key={idx} fg={t.primary}>
260
266
  {line}
261
267
  </text>
@@ -264,6 +270,12 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
264
270
 
265
271
  const baseLabel = compareRef != null && reviewBaseRef != null ? `vs ${reviewBaseRef}` : undefined
266
272
 
273
+ const handleExitDiff = useCallback((event: OtuiMouseEvent) => {
274
+ event.preventDefault()
275
+ event.stopPropagation()
276
+ dispatchGlobal({ type: 'exit-git-mode' })
277
+ }, [])
278
+
267
279
  return (
268
280
  <box flexDirection="column" flexGrow={1} overflow="hidden">
269
281
  <box flexDirection="row" flexGrow={1}>
@@ -331,11 +343,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
331
343
  paddingLeft={1}
332
344
  paddingRight={1}
333
345
  backgroundColor={actionBg}
334
- onMouseDown={(event) => {
335
- event.preventDefault()
336
- event.stopPropagation()
337
- dispatchGlobal({ type: 'exit-git-mode' })
338
- }}
346
+ onMouseDown={handleExitDiff}
339
347
  >
340
348
  <text fg={t.text}>
341
349
  <strong>Exit diff</strong>
@@ -1,6 +1,6 @@
1
1
  import type { BoxRenderable } from '@opentui/core'
2
2
 
3
- import { memo, useEffect, useRef, useState } from 'react'
3
+ import { memo, useCallback, useEffect, useRef, useState } from 'react'
4
4
 
5
5
  import { convertToPng, isPng } from '../../../terminal-graphics/format-fallback'
6
6
  import {
@@ -77,15 +77,7 @@ export const TerminalImagePane = memo(function TerminalImagePane({
77
77
  }
78
78
  }, [bytes, mime])
79
79
 
80
- if (error != null && error !== '') {
81
- return (
82
- <box flexGrow={1} alignItems="center" justifyContent="center" padding={1}>
83
- <text fg={t.warning}>({error})</text>
84
- </box>
85
- )
86
- }
87
-
88
- function renderAfter(this: BoxRenderable): void {
80
+ const renderAfter = useCallback(function (this: BoxRenderable): void {
89
81
  const state = stateRef.current
90
82
  if (!state.uploaded || state.imageId === null) return
91
83
  const key = `${this.screenX},${this.screenY},${this.width},${this.height}`
@@ -94,6 +86,14 @@ export const TerminalImagePane = memo(function TerminalImagePane({
94
86
  const seq = buildPlacement(state.imageId, this.screenX, this.screenY)
95
87
  // Queue write to land AFTER opentui's native cell flush in this frame.
96
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
+ )
97
97
  }
98
98
 
99
99
  return <box flexGrow={1} renderAfter={renderAfter} />