@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
@@ -1,5 +1,7 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
1
3
  import { useKeyboard } from '@opentui/react'
2
- import { useEffect, useState } from 'react'
4
+ import { memo, useCallback, useEffect, useState } from 'react'
3
5
 
4
6
  import { useAppStore } from '../../../../state/app-store'
5
7
  import {
@@ -9,6 +11,50 @@ import {
9
11
  } from '../../../context-menu/controller'
10
12
  import { useTheme } from '../../../theme'
11
13
 
14
+ const MenuItem = memo(function MenuItem({
15
+ active,
16
+ index,
17
+ label,
18
+ onHover,
19
+ onSelect,
20
+ width,
21
+ }: {
22
+ active: boolean
23
+ index: number
24
+ label: string
25
+ onHover: (index: number) => void
26
+ onSelect: () => void
27
+ width: number
28
+ }) {
29
+ const t = useTheme()
30
+ const handleMouseOver = useCallback(() => onHover(index), [index, onHover])
31
+ const handleMouseDown = useCallback(
32
+ (e: OtuiMouseEvent) => {
33
+ e.preventDefault()
34
+ e.stopPropagation()
35
+ if (e.button !== 0) return
36
+ closeContextMenu()
37
+ onSelect()
38
+ },
39
+ [onSelect]
40
+ )
41
+ return (
42
+ <box
43
+ width={width - 2}
44
+ flexShrink={0}
45
+ paddingLeft={1}
46
+ paddingRight={1}
47
+ backgroundColor={active ? t.backgroundElement : undefined}
48
+ onMouseOver={handleMouseOver}
49
+ onMouseDown={handleMouseDown}
50
+ >
51
+ <text fg={active ? t.text : t.textMuted} selectable={false}>
52
+ {label}
53
+ </text>
54
+ </box>
55
+ )
56
+ })
57
+
12
58
  export function ContextMenuOverlay() {
13
59
  const [menu, setMenu] = useState<ContextMenuState | null>(null)
14
60
  const [selected, setSelected] = useState(0)
@@ -48,6 +94,15 @@ export function ContextMenuOverlay() {
48
94
  }
49
95
  })
50
96
 
97
+ const handleBackdropMouseDown = useCallback((e: OtuiMouseEvent) => {
98
+ e.preventDefault()
99
+ e.stopPropagation()
100
+ closeContextMenu()
101
+ }, [])
102
+ const handleMenuMouseDown = useCallback((e: OtuiMouseEvent) => {
103
+ e.stopPropagation()
104
+ }, [])
105
+
51
106
  if (!menu) return null
52
107
 
53
108
  const maxLabel = Math.max(...menu.items.map(([label]) => label.length))
@@ -66,11 +121,7 @@ export function ContextMenuOverlay() {
66
121
  left={0}
67
122
  width="100%"
68
123
  height="100%"
69
- onMouseDown={(e) => {
70
- e.preventDefault()
71
- e.stopPropagation()
72
- closeContextMenu()
73
- }}
124
+ onMouseDown={handleBackdropMouseDown}
74
125
  />
75
126
  <box
76
127
  position="absolute"
@@ -81,35 +132,19 @@ export function ContextMenuOverlay() {
81
132
  border
82
133
  borderColor={t.border}
83
134
  backgroundColor={t.backgroundPanel}
84
- onMouseDown={(e) => {
85
- e.stopPropagation()
86
- }}
135
+ onMouseDown={handleMenuMouseDown}
87
136
  >
88
- {menu.items.map(([label, onSelect], index) => {
89
- const active = index === selected
90
- return (
91
- <box
92
- key={`${label}-${index}`}
93
- width={width - 2}
94
- flexShrink={0}
95
- paddingLeft={1}
96
- paddingRight={1}
97
- backgroundColor={active ? t.backgroundElement : undefined}
98
- onMouseOver={() => setSelected(index)}
99
- onMouseDown={(e) => {
100
- e.preventDefault()
101
- e.stopPropagation()
102
- if (e.button !== 0) return
103
- closeContextMenu()
104
- onSelect()
105
- }}
106
- >
107
- <text fg={active ? t.text : t.textMuted} selectable={false}>
108
- {label}
109
- </text>
110
- </box>
111
- )
112
- })}
137
+ {menu.items.map(([label, onSelect], index) => (
138
+ <MenuItem
139
+ key={label}
140
+ index={index}
141
+ label={label}
142
+ onHover={setSelected}
143
+ onSelect={onSelect}
144
+ active={index === selected}
145
+ width={width}
146
+ />
147
+ ))}
113
148
  </box>
114
149
  </box>
115
150
  )
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react'
1
+ import { memo, type ReactNode, useMemo } from 'react'
2
2
 
3
3
  import { useTheme } from '../../theme'
4
4
  import { Surface } from './surface'
@@ -9,27 +9,43 @@ interface ListItemProps {
9
9
  active: boolean
10
10
  direction?: ListItemDirection
11
11
  id?: string
12
+ index?: number
12
13
  leading?: ReactNode
13
14
  subtitle?: ReactNode
14
15
  title: ReactNode
15
16
  trailing?: ReactNode
16
17
  onHover?: () => void
17
18
  onClick?: () => void
19
+ onHoverIndex?: (index: number) => void
20
+ onClickIndex?: (index: number) => void
18
21
  }
19
22
 
20
- export function ListItem({
23
+ export const ListItem = memo(function ListItem({
21
24
  active,
22
25
  direction = 'column',
23
26
  id,
27
+ index,
24
28
  leading,
25
29
  onClick,
30
+ onClickIndex,
26
31
  onHover,
32
+ onHoverIndex,
27
33
  subtitle,
28
34
  title,
29
35
  trailing,
30
36
  }: ListItemProps) {
31
37
  const t = useTheme()
32
38
  const isRow = direction === 'row'
39
+ // Build per-item handlers from the stable index-based callbacks so callers can
40
+ // avoid creating fresh closures inside their list `.map()`.
41
+ const handleHover = useMemo<(() => void) | undefined>(() => {
42
+ if (onHoverIndex && index !== undefined) return () => onHoverIndex(index)
43
+ return onHover
44
+ }, [index, onHover, onHoverIndex])
45
+ const handleClick = useMemo<(() => void) | undefined>(() => {
46
+ if (onClickIndex && index !== undefined) return () => onClickIndex(index)
47
+ return onClick
48
+ }, [index, onClick, onClickIndex])
33
49
  const inner = (
34
50
  <box flexDirection="column">
35
51
  <box flexDirection="row">
@@ -48,7 +64,7 @@ export function ListItem({
48
64
  </box>
49
65
  )
50
66
  return (
51
- <box id={id} onMouseOver={onHover} onMouseDown={onClick}>
67
+ <box id={id} onMouseOver={handleHover} onMouseDown={handleClick}>
52
68
  {active ? (
53
69
  <Surface tone="selected" paddingLeft={isRow ? 2 : 1} paddingRight={isRow ? 2 : 1}>
54
70
  {inner}
@@ -60,4 +76,4 @@ export function ListItem({
60
76
  )}
61
77
  </box>
62
78
  )
63
- }
79
+ })
package/src/ui/root.tsx CHANGED
@@ -1,8 +1,16 @@
1
1
  import type { MouseEvent } from '@opentui/core'
2
2
 
3
+ import { useCallback, useMemo } from 'react'
4
+
3
5
  import type { MeasuredPaneRect } from '../app-runtime/use-pane-size-report'
4
6
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
5
- import type { FocusMode, ModalState, SessionRecord, SnippetRecord } from '../state/types'
7
+ import type {
8
+ FocusMode,
9
+ ModalState,
10
+ SessionRecord,
11
+ SnippetRecord,
12
+ WorktreeRecord,
13
+ } from '../state/types'
6
14
  import type { ThemeId } from './themes'
7
15
 
8
16
  import { useAppStore } from '../state/app-store'
@@ -35,6 +43,8 @@ import { PendingChordOverlay } from './components/overlays/pending-chord-overlay
35
43
  import { ToastViewport } from './components/overlays/toast/toast-viewport'
36
44
  import { useTheme } from './theme'
37
45
 
46
+ const EMPTY_WORKTREES: WorktreeRecord[] = []
47
+
38
48
  function getCreateSessionFields(modal: ModalState) {
39
49
  if (modal.type !== 'create-session') {
40
50
  return { directoryQuery: '', sessionName: '' }
@@ -106,8 +116,8 @@ function renderModal(
106
116
  worktrees={
107
117
  options.currentSessionId != null && options.currentSessionId !== ''
108
118
  ? (options.sessions.find((session) => session.id === options.currentSessionId)
109
- ?.worktrees ?? [])
110
- : []
119
+ ?.worktrees ?? EMPTY_WORKTREES)
120
+ : EMPTY_WORKTREES
111
121
  }
112
122
  worktreeName={modal.type === 'new-tab' ? modal.worktreeName : ''}
113
123
  />
@@ -189,21 +199,13 @@ function renderModal(
189
199
  options.currentSessionId != null && options.currentSessionId !== ''
190
200
  ? options.sessions.find((entry) => entry.id === options.currentSessionId)
191
201
  : undefined
192
- const worktrees = session?.worktrees ?? []
193
- const sourceId = modal.sourceWorktreeId
194
- const source = worktrees.find((w) => w.id === sourceId)
195
- const targets = worktrees.filter((w) => w.id !== sourceId)
196
- const sourceLabel =
197
- source?.branch != null && source.branch !== ''
198
- ? source.branch
199
- : (source?.name ?? 'worktree')
200
202
  return (
201
203
  <WorktreeMoveModal
202
204
  deleteSource={modal.type === 'worktree-move' ? modal.deleteSource : false}
203
205
  divergence={options.worktreeDivergence}
204
206
  selectedIndex={modal.selectedIndex}
205
- sourceLabel={sourceLabel}
206
- targets={targets}
207
+ sourceWorktreeId={modal.sourceWorktreeId}
208
+ worktrees={session?.worktrees ?? EMPTY_WORKTREES}
207
209
  />
208
210
  )
209
211
  }
@@ -322,14 +324,59 @@ export function RootView({
322
324
  const sidebarVisible = useAppStore((s) => s.sidebar.visible)
323
325
 
324
326
  const gitPaneInPaneOnLeft = gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'left'
327
+ const splitChrome = PANE_BORDER * 2
328
+
329
+ const handleSidebarEdgeResize = useCallback(
330
+ (event: MouseEvent): boolean => {
331
+ onSidebarResizeStart?.({ initialWidth: sidebarWidth, screenStart: event.x })
332
+ return true
333
+ },
334
+ [onSidebarResizeStart, sidebarWidth]
335
+ )
325
336
  const handleTerminalLeftEdgeMouseDown =
326
337
  sidebarVisible && !gitPaneInPaneOnLeft && onSidebarResizeStart
327
- ? (event: MouseEvent) => {
328
- onSidebarResizeStart({ initialWidth: sidebarWidth, screenStart: event.x })
329
- return true
330
- }
338
+ ? handleSidebarEdgeResize
331
339
  : undefined
332
340
 
341
+ const handleRootMouseDrag = useCallback(
342
+ (event: MouseEvent) => {
343
+ if (onSeparatorDrag?.(event) === true) {
344
+ event.preventDefault()
345
+ event.stopPropagation()
346
+ }
347
+ },
348
+ [onSeparatorDrag]
349
+ )
350
+ const handleRootMouseUp = useCallback(
351
+ (event: MouseEvent) => {
352
+ // Catch releases that land outside any TerminalPane (sidebar, gap,
353
+ // status bar, …) so an in-flight multi-click drag — and its
354
+ // auto-scroll interval — is always finalised.
355
+ onTerminalMouseUp?.(event)
356
+ onSeparatorDragEnd?.()
357
+ },
358
+ [onSeparatorDragEnd, onTerminalMouseUp]
359
+ )
360
+
361
+ const splitContentOrigin = useMemo(
362
+ () => ({
363
+ cols: terminalCols + splitChrome,
364
+ rows: terminalRows + splitChrome,
365
+ x: contentOrigin.x - PANE_BORDER,
366
+ y: contentOrigin.y - PANE_BORDER,
367
+ }),
368
+ [contentOrigin, splitChrome, terminalCols, terminalRows]
369
+ )
370
+ const splitBounds = useMemo(
371
+ () => ({
372
+ cols: terminalCols + splitChrome,
373
+ rows: terminalRows + splitChrome,
374
+ x: 0,
375
+ y: 0,
376
+ }),
377
+ [splitChrome, terminalCols, terminalRows]
378
+ )
379
+
333
380
  const activeTab = tabs.find((tab) => tab.id === activeTabId)
334
381
  const activeTree =
335
382
  activeTabId != null && activeTabId !== ''
@@ -337,7 +384,6 @@ export function RootView({
337
384
  : null
338
385
  const createSessionFields = getCreateSessionFields(modal)
339
386
  const snippetEditorFields = getSnippetEditorFields(modal)
340
- const splitChrome = PANE_BORDER * 2
341
387
 
342
388
  const inGitMode = focusMode === 'git' || modal.type === 'git-commit'
343
389
  if (inGitMode) {
@@ -373,19 +419,8 @@ export function RootView({
373
419
  width="100%"
374
420
  height="100%"
375
421
  backgroundColor={editorBg}
376
- onMouseDrag={(event) => {
377
- if (onSeparatorDrag?.(event) === true) {
378
- event.preventDefault()
379
- event.stopPropagation()
380
- }
381
- }}
382
- onMouseUp={(event) => {
383
- // Catch releases that land outside any TerminalPane (sidebar, gap,
384
- // status bar, …) so an in-flight multi-click drag — and its
385
- // auto-scroll interval — is always finalised.
386
- onTerminalMouseUp?.(event)
387
- onSeparatorDragEnd?.()
388
- }}
422
+ onMouseDrag={handleRootMouseDrag}
423
+ onMouseUp={handleRootMouseUp}
389
424
  >
390
425
  {sessionBarPosition === 'top' && <SessionBar />}
391
426
  <box flexDirection="row" gap={0} padding={0} flexGrow={1}>
@@ -408,12 +443,7 @@ export function RootView({
408
443
  tabs={tabs}
409
444
  activeTabId={activeTabId}
410
445
  focusMode={focusMode}
411
- contentOrigin={{
412
- cols: terminalCols + splitChrome,
413
- rows: terminalRows + splitChrome,
414
- x: contentOrigin.x - PANE_BORDER,
415
- y: contentOrigin.y - PANE_BORDER,
416
- }}
446
+ contentOrigin={splitContentOrigin}
417
447
  mouseForwardingEnabled={mouseForwardingEnabled}
418
448
  localScrollbackEnabled={localScrollbackEnabled}
419
449
  onTerminalMouseEvent={onTerminalMouseEvent}
@@ -428,12 +458,7 @@ export function RootView({
428
458
  onSeparatorDragEnd={onSeparatorDragEnd}
429
459
  onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
430
460
  onMeasure={onMeasure}
431
- bounds={{
432
- cols: terminalCols + splitChrome,
433
- rows: terminalRows + splitChrome,
434
- x: 0,
435
- y: 0,
436
- }}
461
+ bounds={splitBounds}
437
462
  />
438
463
  ) : (
439
464
  <TerminalPane
@@ -509,16 +534,20 @@ function GitPaneInPaneMode({
509
534
  dispatchGlobal({ position: nextPosition, type: 'set-git-pane-position' })
510
535
  }
511
536
  )
537
+ const handleResizeMouseDown = useCallback(
538
+ (event: MouseEvent) => {
539
+ event.preventDefault()
540
+ event.stopPropagation()
541
+ onGitPaneResizeStart?.({ initialWidth: width, screenStart: event.x, side: position })
542
+ },
543
+ [onGitPaneResizeStart, position, width]
544
+ )
512
545
  const handle = (
513
546
  <box
514
547
  width={1}
515
548
  flexShrink={0}
516
549
  backgroundColor={tokens.border}
517
- onMouseDown={(event) => {
518
- event.preventDefault()
519
- event.stopPropagation()
520
- onGitPaneResizeStart?.({ initialWidth: width, screenStart: event.x, side: position })
521
- }}
550
+ onMouseDown={handleResizeMouseDown}
522
551
  />
523
552
  )
524
553
  return (