@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
@@ -1,8 +1,8 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
- import type { ScrollBoxRenderable } from '@opentui/core'
2
+ import type { MouseEvent as OtuiMouseEvent, ScrollBoxRenderable } from '@opentui/core'
3
3
 
4
4
  import { useTerminalDimensions } from '@opentui/react'
5
- import { type ReactNode, useLayoutEffect, useRef } from 'react'
5
+ import { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef } from 'react'
6
6
 
7
7
  import { useTheme } from '../../../theme'
8
8
  import { BareInput } from '../../primitives/bare-input'
@@ -49,35 +49,41 @@ function PickerItemCtas({
49
49
  onWorktree?: () => void
50
50
  }) {
51
51
  const t = useTheme()
52
+ const handleEdit = useCallback(
53
+ (event: OtuiMouseEvent) => {
54
+ event.stopPropagation()
55
+ onEdit?.()
56
+ },
57
+ [onEdit]
58
+ )
59
+ const handleWorktree = useCallback(
60
+ (event: OtuiMouseEvent) => {
61
+ event.stopPropagation()
62
+ onWorktree?.()
63
+ },
64
+ [onWorktree]
65
+ )
66
+ const handleDelete = useCallback(
67
+ (event: OtuiMouseEvent) => {
68
+ event.stopPropagation()
69
+ onDelete?.()
70
+ },
71
+ [onDelete]
72
+ )
52
73
  return (
53
74
  <box flexDirection="row" gap={1}>
54
75
  {onEdit ? (
55
- <box
56
- onMouseDown={(event) => {
57
- event.stopPropagation()
58
- onEdit()
59
- }}
60
- >
76
+ <box onMouseDown={handleEdit}>
61
77
  <text fg={t.primary}>[edit]</text>
62
78
  </box>
63
79
  ) : null}
64
80
  {onWorktree ? (
65
- <box
66
- onMouseDown={(event) => {
67
- event.stopPropagation()
68
- onWorktree()
69
- }}
70
- >
81
+ <box onMouseDown={handleWorktree}>
71
82
  <text fg={t.warning}>[WT]</text>
72
83
  </box>
73
84
  ) : null}
74
85
  {onDelete ? (
75
- <box
76
- onMouseDown={(event) => {
77
- event.stopPropagation()
78
- onDelete()
79
- }}
80
- >
86
+ <box onMouseDown={handleDelete}>
81
87
  <text fg={t.error}>[del]</text>
82
88
  </box>
83
89
  ) : null}
@@ -109,13 +115,29 @@ export function Picker({
109
115
  const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
110
116
  const skipNextScrollRef = useRef(false)
111
117
 
112
- const resetScrollTimer = () => {
118
+ const resetScrollTimer = useCallback(() => {
113
119
  if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
114
120
  scrollTimerRef.current = setTimeout(() => {
115
121
  isScrollingRef.current = false
116
122
  scrollTimerRef.current = null
117
123
  }, 10)
118
- }
124
+ }, [])
125
+
126
+ const handleScrollboxScroll = useCallback(() => {
127
+ isScrollingRef.current = true
128
+ resetScrollTimer()
129
+ }, [resetScrollTimer])
130
+
131
+ const handleItemHover = useCallback(
132
+ (index: number) => {
133
+ if (isScrollingRef.current) return
134
+ skipNextScrollRef.current = true
135
+ onHover(index)
136
+ },
137
+ [onHover]
138
+ )
139
+
140
+ const listContentOptions = useMemo(() => ({ flexDirection: 'column' as const, gap }), [gap])
119
141
 
120
142
  useLayoutEffect(() => {
121
143
  if (!scrollboxRef.current) return
@@ -128,7 +150,7 @@ export function Picker({
128
150
  isScrollingRef.current = true
129
151
  scrollboxRef.current.scrollChildIntoView(target)
130
152
  resetScrollTimer()
131
- }, [selectedIndex, items])
153
+ }, [selectedIndex, items, resetScrollTimer])
132
154
 
133
155
  return (
134
156
  <ModalShell
@@ -144,11 +166,8 @@ export function Picker({
144
166
  ref={scrollboxRef}
145
167
  scrollY
146
168
  height={items.length === 0 ? 0 : listHeight}
147
- contentOptions={{ flexDirection: 'column', gap }}
148
- onMouseScroll={() => {
149
- isScrollingRef.current = true
150
- resetScrollTimer()
151
- }}
169
+ contentOptions={listContentOptions}
170
+ onMouseScroll={handleScrollboxScroll}
152
171
  >
153
172
  {(() => {
154
173
  let prevGroup: string | undefined
@@ -181,15 +200,12 @@ export function Picker({
181
200
  <ListItem
182
201
  key={item.key}
183
202
  id={item.key}
203
+ index={capturedIndex}
184
204
  active={active}
185
205
  title={item.title}
186
206
  subtitle={item.subtitle}
187
207
  trailing={trailing}
188
- onHover={() => {
189
- if (isScrollingRef.current) return
190
- skipNextScrollRef.current = true
191
- onHover(capturedIndex)
192
- }}
208
+ onHoverIndex={handleItemHover}
193
209
  onClick={item.onClick}
194
210
  />
195
211
  )
@@ -1,3 +1,5 @@
1
+ import { useCallback, useMemo } from 'react'
2
+
1
3
  import type { SnippetRecord } from '../../../../state/types'
2
4
 
3
5
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
@@ -31,35 +33,46 @@ export function SnippetPickerModal({
31
33
  snippets,
32
34
  }: SnippetPickerModalProps) {
33
35
  const t = useTheme()
34
- const filtered = filterSnippets(snippets, filter)
36
+ const filtered = useMemo(() => filterSnippets(snippets, filter), [filter, snippets])
37
+
38
+ const items = useMemo<PickerItem[]>(
39
+ () =>
40
+ filtered.map((snippet, index) => {
41
+ const active = index === selectedIndex
42
+ const fromConfig = isConfigSnippetId(snippet.id)
43
+ return {
44
+ key: snippet.id,
45
+ onClick: () => {
46
+ dispatchGlobal({ type: 'close-modal' })
47
+ runSideEffectGlobal({ type: 'paste-selected-snippet' })
48
+ },
49
+ onDelete: fromConfig
50
+ ? undefined
51
+ : () => runSideEffectGlobal({ type: 'delete-selected-snippet' }),
52
+ onEdit: fromConfig
53
+ ? undefined
54
+ : () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
55
+ subtitle: <text fg={t.textMuted}>{truncateContent(snippet.content)}</text>,
56
+ title: (
57
+ <box flexDirection="row">
58
+ <text fg={active ? t.text : t.textMuted}>
59
+ <strong>{snippet.name}</strong>
60
+ </text>
61
+ {snippet.trigger != null && snippet.trigger !== '' ? (
62
+ <text fg={t.textMuted}>{` :${snippet.trigger}`}</text>
63
+ ) : null}
64
+ {fromConfig ? <text fg={t.textMuted}>{' [config]'}</text> : null}
65
+ </box>
66
+ ),
67
+ }
68
+ }),
69
+ [filtered, selectedIndex, t]
70
+ )
35
71
 
36
- const items: PickerItem[] = filtered.map((snippet, index) => {
37
- const active = index === selectedIndex
38
- const fromConfig = isConfigSnippetId(snippet.id)
39
- return {
40
- key: snippet.id,
41
- onClick: () => {
42
- dispatchGlobal({ type: 'close-modal' })
43
- runSideEffectGlobal({ type: 'paste-selected-snippet' })
44
- },
45
- onDelete: fromConfig
46
- ? undefined
47
- : () => runSideEffectGlobal({ type: 'delete-selected-snippet' }),
48
- onEdit: fromConfig ? undefined : () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
49
- subtitle: <text fg={t.textMuted}>{truncateContent(snippet.content)}</text>,
50
- title: (
51
- <box flexDirection="row">
52
- <text fg={active ? t.text : t.textMuted}>
53
- <strong>{snippet.name}</strong>
54
- </text>
55
- {snippet.trigger != null && snippet.trigger !== '' ? (
56
- <text fg={t.textMuted}>{` :${snippet.trigger}`}</text>
57
- ) : null}
58
- {fromConfig ? <text fg={t.textMuted}>{' [config]'}</text> : null}
59
- </box>
60
- ),
61
- }
62
- })
72
+ const handleHover = useCallback(
73
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
74
+ []
75
+ )
63
76
 
64
77
  return (
65
78
  <Picker
@@ -83,7 +96,7 @@ export function SnippetPickerModal({
83
96
  <text fg={t.error}>{actionMessage}</text>
84
97
  ) : undefined
85
98
  }
86
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
99
+ onHover={handleHover}
87
100
  />
88
101
  )
89
102
  }
@@ -1,3 +1,5 @@
1
+ import { useCallback, useMemo } from 'react'
2
+
1
3
  import type { AssistantId, WorktreeRecord } from '../../../../state/types'
2
4
 
3
5
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
@@ -68,11 +70,86 @@ export function NewTabModal({
68
70
  worktrees,
69
71
  }: NewTabModalProps) {
70
72
  const t = useTheme()
73
+ const options = useMemo(() => getAllAssistantOptions(customCommands), [customCommands])
74
+ const filtered = useMemo(() => filterAssistants(options, filter), [filter, options])
75
+
76
+ const handleHover = useCallback(
77
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
78
+ []
79
+ )
80
+
81
+ const worktreeItems = useMemo<PickerItem[]>(
82
+ () => [
83
+ ...worktrees.map((worktree, index) => {
84
+ const active = index === selectedIndex
85
+ const label = worktree.branch ?? worktree.name
86
+ const blockedReason = getDeleteBlockedReason({ currentSessionId, worktree, worktrees })
87
+ const canDelete = blockedReason == null || blockedReason === ''
88
+ return {
89
+ key: worktree.id,
90
+ onClick: () => {
91
+ dispatchGlobal({ index, type: 'set-modal-selection-index' })
92
+ runSideEffectGlobal({ type: 'launch-selected-assistant' })
93
+ },
94
+ onDelete:
95
+ active && canDelete && currentSessionId != null && currentSessionId !== ''
96
+ ? () => {
97
+ dispatchGlobal({ index, type: 'set-modal-selection-index' })
98
+ dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
99
+ runSideEffectGlobal({
100
+ force: worktreeDeleteConfirmId === worktree.id,
101
+ sessionId: currentSessionId,
102
+ type: 'delete-worktree',
103
+ worktreeId: worktree.id,
104
+ })
105
+ }
106
+ : undefined,
107
+ subtitle: (
108
+ <box flexDirection="column">
109
+ <text fg={t.textMuted}>{worktree.path}</text>
110
+ <text fg={t.textMuted}>{worktree.source}</text>
111
+ </box>
112
+ ),
113
+ title: <text fg={active ? t.text : t.textMuted}>{label}</text>,
114
+ }
115
+ }),
116
+ {
117
+ key: '__create-worktree__',
118
+ onClick: () => dispatchGlobal({ type: 'enter-new-tab-worktree-create' }),
119
+ subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
120
+ title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
121
+ },
122
+ ],
123
+ [createWorktree, currentSessionId, selectedIndex, t, worktreeDeleteConfirmId, worktrees]
124
+ )
125
+
126
+ const items = useMemo<PickerItem[]>(
127
+ () =>
128
+ filtered.map((option, index) => {
129
+ const active = index === selectedIndex
130
+ const customCmd = customCommands[option.id]
131
+ return {
132
+ key: option.id,
133
+ onClick: () =>
134
+ dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
135
+ onEdit: () =>
136
+ dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
137
+ subtitle: (
138
+ <box flexDirection="column">
139
+ <text fg={t.textMuted}>{option.description}</text>
140
+ {customCmd != null && customCmd !== '' ? (
141
+ <text fg={t.primary}>{customCmd}</text>
142
+ ) : null}
143
+ </box>
144
+ ),
145
+ title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
146
+ }
147
+ }),
148
+ [customCommands, filtered, selectedIndex, t]
149
+ )
71
150
 
72
151
  if (editingCommand !== null) {
73
- const option =
74
- getAllAssistantOptions(customCommands).find((o) => o.id === editingCommand) ??
75
- getAssistantOption(0)
152
+ const option = options.find((o) => o.id === editingCommand) ?? getAssistantOption(0)
76
153
  return (
77
154
  <Form
78
155
  title={`Edit command — ${option.label}`}
@@ -90,9 +167,6 @@ export function NewTabModal({
90
167
  )
91
168
  }
92
169
 
93
- const options = getAllAssistantOptions(customCommands)
94
- const filtered = filterAssistants(options, filter)
95
-
96
170
  if (step === 'worktree-create') {
97
171
  const selectedAssistant =
98
172
  options.find((option) => option.id === selectedAssistantId) ?? options[0]
@@ -139,47 +213,6 @@ export function NewTabModal({
139
213
  worktree: selectedWorktree,
140
214
  worktrees,
141
215
  })
142
- const worktreeItems: PickerItem[] = [
143
- ...worktrees.map((worktree, index) => {
144
- const active = index === selectedIndex
145
- const label = worktree.branch ?? worktree.name
146
- const blockedReason = getDeleteBlockedReason({ currentSessionId, worktree, worktrees })
147
- const canDelete = blockedReason == null || blockedReason === ''
148
- return {
149
- key: worktree.id,
150
- onClick: () => {
151
- dispatchGlobal({ index, type: 'set-modal-selection-index' })
152
- runSideEffectGlobal({ type: 'launch-selected-assistant' })
153
- },
154
- onDelete:
155
- active && canDelete && currentSessionId != null && currentSessionId !== ''
156
- ? () => {
157
- dispatchGlobal({ index, type: 'set-modal-selection-index' })
158
- dispatchGlobal({ message: null, type: 'set-new-tab-worktree-delete-state' })
159
- runSideEffectGlobal({
160
- force: worktreeDeleteConfirmId === worktree.id,
161
- sessionId: currentSessionId,
162
- type: 'delete-worktree',
163
- worktreeId: worktree.id,
164
- })
165
- }
166
- : undefined,
167
- subtitle: (
168
- <box flexDirection="column">
169
- <text fg={t.textMuted}>{worktree.path}</text>
170
- <text fg={t.textMuted}>{worktree.source}</text>
171
- </box>
172
- ),
173
- title: <text fg={active ? t.text : t.textMuted}>{label}</text>,
174
- }
175
- }),
176
- {
177
- key: '__create-worktree__',
178
- onClick: () => dispatchGlobal({ type: 'enter-new-tab-worktree-create' }),
179
- subtitle: <text fg={t.textMuted}>Create an Aimux temp worktree</text>,
180
- title: <text fg={createWorktree ? t.text : t.textMuted}>Create new worktree</text>,
181
- },
182
- ]
183
216
  return (
184
217
  <Picker
185
218
  title={`New assistant: ${selectedAssistant?.label ?? 'assistant'}`}
@@ -190,7 +223,7 @@ export function NewTabModal({
190
223
  items={worktreeItems}
191
224
  selectedIndex={selectedIndex}
192
225
  emptyState={<text fg={t.textMuted}>No worktrees.</text>}
193
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
226
+ onHover={handleHover}
194
227
  footer={
195
228
  <box flexDirection="column">
196
229
  {(worktreeDeleteMessage != null && worktreeDeleteMessage !== '') ||
@@ -213,23 +246,6 @@ export function NewTabModal({
213
246
  )
214
247
  }
215
248
 
216
- const items: PickerItem[] = filtered.map((option, index) => {
217
- const active = index === selectedIndex
218
- const customCmd = customCommands[option.id]
219
- return {
220
- key: option.id,
221
- onClick: () => dispatchGlobal({ assistantId: option.id, type: 'select-new-tab-assistant' }),
222
- onEdit: () => dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
223
- subtitle: (
224
- <box flexDirection="column">
225
- <text fg={t.textMuted}>{option.description}</text>
226
- {customCmd != null && customCmd !== '' ? <text fg={t.primary}>{customCmd}</text> : null}
227
- </box>
228
- ),
229
- title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
230
- }
231
- })
232
-
233
249
  return (
234
250
  <Picker
235
251
  title="New assistant: choose assistant"
@@ -241,7 +257,7 @@ export function NewTabModal({
241
257
  items={items}
242
258
  selectedIndex={selectedIndex}
243
259
  emptyState={<text fg={t.textMuted}>No matching assistants.</text>}
244
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
260
+ onHover={handleHover}
245
261
  footer={
246
262
  <box flexDirection="column">
247
263
  <text fg={t.textMuted}>Step 1/2: choose assistant</text>
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useMemo } from 'react'
1
+ import { useCallback, useLayoutEffect, useMemo } from 'react'
2
2
 
3
3
  import type { ThemeId } from '../../../themes'
4
4
 
@@ -37,19 +37,28 @@ export function ThemePickerModal({
37
37
 
38
38
  const effectiveIndex = clampSelection(selectedIndex, filtered.length)
39
39
 
40
- const items: PickerItem[] = filtered.map((id, rowIndex) => {
41
- const active = rowIndex === effectiveIndex
42
- const isCurrent = id === currentThemeId
43
- return {
44
- key: id,
45
- onClick: () => {
46
- dispatchGlobal({ type: 'close-modal' })
47
- runSideEffectGlobal({ action: 'confirm', type: 'apply-theme' })
48
- },
49
- title: <text fg={active ? t.text : t.textMuted}>{themeDisplayName(id)}</text>,
50
- trailing: isCurrent ? <text fg={t.primary}>current</text> : undefined,
51
- }
52
- })
40
+ const items = useMemo<PickerItem[]>(
41
+ () =>
42
+ filtered.map((id, rowIndex) => {
43
+ const active = rowIndex === effectiveIndex
44
+ const isCurrent = id === currentThemeId
45
+ return {
46
+ key: id,
47
+ onClick: () => {
48
+ dispatchGlobal({ type: 'close-modal' })
49
+ runSideEffectGlobal({ action: 'confirm', type: 'apply-theme' })
50
+ },
51
+ title: <text fg={active ? t.text : t.textMuted}>{themeDisplayName(id)}</text>,
52
+ trailing: isCurrent ? <text fg={t.primary}>current</text> : undefined,
53
+ }
54
+ }),
55
+ [currentThemeId, effectiveIndex, filtered, t]
56
+ )
57
+
58
+ const handleHover = useCallback(
59
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
60
+ []
61
+ )
53
62
 
54
63
  return (
55
64
  <Picker
@@ -70,7 +79,7 @@ export function ThemePickerModal({
70
79
  items={items}
71
80
  selectedIndex={effectiveIndex}
72
81
  emptyState={<text fg={t.textMuted}>No themes available.</text>}
73
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
82
+ onHover={handleHover}
74
83
  />
75
84
  )
76
85
  }
@@ -1,3 +1,5 @@
1
+ import { useCallback, useMemo } from 'react'
2
+
1
3
  import type { WorktreeRecord } from '../../../../state/types'
2
4
 
3
5
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
@@ -10,8 +12,8 @@ interface WorktreeMoveModalProps {
10
12
  deleteSource: boolean
11
13
  divergence: Record<string, { ahead: number; behind: number }>
12
14
  selectedIndex: number
13
- sourceLabel: string
14
- targets: WorktreeRecord[]
15
+ sourceWorktreeId: string
16
+ worktrees: WorktreeRecord[]
15
17
  }
16
18
 
17
19
  const MOVE_HINTS: [key: string, label: string][] = [
@@ -33,10 +35,24 @@ export function WorktreeMoveModal({
33
35
  deleteSource,
34
36
  divergence,
35
37
  selectedIndex,
36
- sourceLabel,
37
- targets,
38
+ sourceWorktreeId,
39
+ worktrees,
38
40
  }: WorktreeMoveModalProps) {
39
41
  const t = useTheme()
42
+ const source = useMemo(
43
+ () => worktrees.find((w) => w.id === sourceWorktreeId),
44
+ [sourceWorktreeId, worktrees]
45
+ )
46
+ const targets = useMemo(
47
+ () => worktrees.filter((w) => w.id !== sourceWorktreeId),
48
+ [sourceWorktreeId, worktrees]
49
+ )
50
+ const sourceLabel =
51
+ source?.branch != null && source.branch !== '' ? source.branch : (source?.name ?? 'worktree')
52
+ const handleSelectIndex = useCallback(
53
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
54
+ []
55
+ )
40
56
  return (
41
57
  <ModalShell
42
58
  title={`Move ${sourceLabel} →`}
@@ -74,9 +90,10 @@ export function WorktreeMoveModal({
74
90
  <ListItem
75
91
  key={worktree.id}
76
92
  id={worktree.id}
93
+ index={index}
77
94
  active={active}
78
- onHover={() => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
79
- onClick={() => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
95
+ onHoverIndex={handleSelectIndex}
96
+ onClickIndex={handleSelectIndex}
80
97
  title={
81
98
  <text fg={active ? t.text : t.textMuted} wrapMode="none">
82
99
  {label}
@@ -1,5 +1,7 @@
1
1
  import type { AIUsageTool } from '@brimveyn/aimux-config'
2
2
 
3
+ import { useCallback } from 'react'
4
+
3
5
  import { useAIUsageStore } from '../../../../state/ai-usage-store'
4
6
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
5
7
  import { useTheme } from '../../../theme'
@@ -52,6 +54,15 @@ export function AIUsageIndicator() {
52
54
  const enabled = useAIUsageStore((s) => s.enabled)
53
55
  const snapshots = useAIUsageStore((s) => s.snapshots)
54
56
 
57
+ const openModal = useCallback(
58
+ (e: { preventDefault: () => void; stopPropagation: () => void }) => {
59
+ e.preventDefault()
60
+ e.stopPropagation()
61
+ dispatchGlobal({ type: 'open-ai-usage-modal' })
62
+ },
63
+ []
64
+ )
65
+
55
66
  if (!enabled) return null
56
67
 
57
68
  const ordered: AIUsageTool[] = ['claude', 'codex']
@@ -59,12 +70,6 @@ export function AIUsageIndicator() {
59
70
  .map((tool) => ({ snap: snapshots[tool], tool }))
60
71
  .filter((entry) => entry.snap !== undefined)
61
72
 
62
- const openModal = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
63
- e.preventDefault()
64
- e.stopPropagation()
65
- dispatchGlobal({ type: 'open-ai-usage-modal' })
66
- }
67
-
68
73
  if (entries.length === 0) {
69
74
  return (
70
75
  <box
@@ -1,21 +1,30 @@
1
1
  import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
  import type { BoxProps } from '@opentui/react'
3
3
 
4
+ import { memo, useCallback } from 'react'
5
+
4
6
  import { type ContextMenuItem, openContextMenu } from '../../../context-menu/controller'
5
7
 
6
8
  interface ContextMenuBoxProps extends BoxProps {
7
9
  rightClickMenu?: ContextMenuItem[]
8
10
  }
9
11
 
10
- export function ContextMenuBox({ onMouseDown, rightClickMenu, ...boxProps }: ContextMenuBoxProps) {
11
- function handleMouseDown(this: BoxRenderable, event: OtuiMouseEvent): void {
12
- if (event.button === 2 && rightClickMenu && rightClickMenu.length > 0) {
13
- event.preventDefault()
14
- event.stopPropagation()
15
- openContextMenu(event.x, event.y, rightClickMenu)
16
- return
17
- }
18
- onMouseDown?.call(this, event)
19
- }
12
+ export const ContextMenuBox = memo(function ContextMenuBox({
13
+ onMouseDown,
14
+ rightClickMenu,
15
+ ...boxProps
16
+ }: ContextMenuBoxProps) {
17
+ const handleMouseDown = useCallback(
18
+ function (this: BoxRenderable, event: OtuiMouseEvent): void {
19
+ if (event.button === 2 && rightClickMenu && rightClickMenu.length > 0) {
20
+ event.preventDefault()
21
+ event.stopPropagation()
22
+ openContextMenu(event.x, event.y, rightClickMenu)
23
+ return
24
+ }
25
+ onMouseDown?.call(this, event)
26
+ },
27
+ [onMouseDown, rightClickMenu]
28
+ )
20
29
  return <box {...boxProps} onMouseDown={handleMouseDown} />
21
- }
30
+ })