@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,5 +1,7 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } 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
7
  import type { FocusMode, TabSession } from '../../../state/types'
@@ -68,15 +70,34 @@ export function SplitLayout({
68
70
  tabs,
69
71
  }: SplitLayoutProps) {
70
72
  const t = useTheme()
71
- if (node.type === 'leaf') {
72
- const tab = tabs.find((t) => t.id === node.tabId)
73
- const isActive = node.tabId === activeTabId
74
- const paneOrigin: TerminalContentOrigin = {
73
+ const paneOrigin = useMemo<TerminalContentOrigin>(
74
+ () => ({
75
75
  cols: Math.max(1, bounds.cols - PANE_CHROME * 2),
76
76
  rows: Math.max(1, bounds.rows - PANE_CHROME * 2),
77
77
  x: contentOrigin.x + bounds.x + PANE_CHROME,
78
78
  y: contentOrigin.y + bounds.y + PANE_CHROME,
79
- }
79
+ }),
80
+ [bounds, contentOrigin]
81
+ )
82
+ const handleSeparatorMouseDown = useCallback(
83
+ (e: OtuiMouseEvent) => {
84
+ e.preventDefault()
85
+ if (node.type !== 'split' || !onSeparatorDragStart) return
86
+ const leafId = getFirstLeafId(node.first)
87
+ if (!(leafId != null && leafId !== '')) return
88
+ onSeparatorDragStart({
89
+ direction: node.direction,
90
+ screenStart:
91
+ node.direction === 'vertical' ? contentOrigin.x + bounds.x : contentOrigin.y + bounds.y,
92
+ tabId: leafId,
93
+ totalSize: node.direction === 'vertical' ? bounds.cols : bounds.rows,
94
+ })
95
+ },
96
+ [bounds, contentOrigin, node, onSeparatorDragStart]
97
+ )
98
+ if (node.type === 'leaf') {
99
+ const tab = tabs.find((t) => t.id === node.tabId)
100
+ const isActive = node.tabId === activeTabId
80
101
  logInputDebug('split.paneOrigin', {
81
102
  boundsCols: bounds.cols,
82
103
  boundsRows: bounds.rows,
@@ -157,21 +178,7 @@ export function SplitLayout({
157
178
  minWidth={node.direction === 'vertical' ? 1 : undefined}
158
179
  minHeight={node.direction === 'horizontal' ? 1 : undefined}
159
180
  backgroundColor={t.border}
160
- onMouseDown={(e: OtuiMouseEvent) => {
161
- e.preventDefault()
162
- if (!onSeparatorDragStart) return
163
- const leafId = getFirstLeafId(node.first)
164
- if (!(leafId != null && leafId !== '')) return
165
- onSeparatorDragStart({
166
- direction: node.direction,
167
- screenStart:
168
- node.direction === 'vertical'
169
- ? contentOrigin.x + bounds.x
170
- : contentOrigin.y + bounds.y,
171
- tabId: leafId,
172
- totalSize: node.direction === 'vertical' ? bounds.cols : bounds.rows,
173
- })
174
- }}
181
+ onMouseDown={handleSeparatorMouseDown}
175
182
  />
176
183
  <box flexGrow={secondGrow} flexDirection="column" overflow="hidden">
177
184
  <SplitLayout
@@ -1,6 +1,6 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
- import { memo, type ReactNode } from 'react'
3
+ import { memo, type ReactNode, useCallback, useMemo } from 'react'
4
4
 
5
5
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
6
6
  import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
@@ -95,6 +95,8 @@ const TerminalViewport = memo(function TerminalViewport({
95
95
  return (
96
96
  <text fg={t.text}>
97
97
  {lines.map((line, lineIndex) => (
98
+ // Terminal rows are a fixed positional grid; the row index is the identity.
99
+ // eslint-disable-next-line react/no-array-index-key
98
100
  <span key={`line-${lineIndex}`}>
99
101
  {line.spans.map((span, spanIndex) => renderSpan(span, `s-${spanIndex}`))}
100
102
  {lineIndex < lines.length - 1 ? '\n' : ''}
@@ -132,125 +134,169 @@ export function TerminalPane({
132
134
  const paneIsActive = isActive ?? true
133
135
  const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
134
136
  const canUseLocalScrollback = focusMode === 'terminal-input' && !!tab && localScrollbackEnabled
135
- const rightClickMenu: ContextMenuItem[] | undefined =
136
- tabId != null && tabId !== ''
137
- ? [
138
- [
139
- 'Split vertically',
140
- () =>
141
- runSideEffectGlobal({
142
- direction: 'vertical',
143
- sourceTabId: tabId,
144
- type: 'split-pane',
145
- }),
146
- ],
147
- [
148
- 'Split horizontally',
149
- () =>
150
- runSideEffectGlobal({
151
- direction: 'horizontal',
152
- sourceTabId: tabId,
153
- type: 'split-pane',
154
- }),
155
- ],
156
- [
157
- 'Close pane',
158
- () => {
159
- dispatchGlobal({ tabId, type: 'close-pane' })
160
- runSideEffectGlobal({ tabId, type: 'close-tab' })
161
- },
162
- ],
163
- ]
164
- : undefined
165
- const isOnPaneBorder = (event: OtuiMouseEvent) =>
166
- event.x === contentOrigin.x - 1 ||
167
- event.x === contentOrigin.x + contentOrigin.cols ||
168
- event.y === contentOrigin.y - 1 ||
169
- event.y === contentOrigin.y + contentOrigin.rows
170
- const forwardMouseEvent = (event: OtuiMouseEvent) => {
171
- if (event.type === 'down' && event.button === 2 && rightClickMenu) {
172
- event.preventDefault()
173
- event.stopPropagation()
174
- openContextMenu(event.x, event.y, rightClickMenu)
175
- return
176
- }
177
- if (
178
- event.type === 'down' &&
179
- event.button === 0 &&
180
- onLeftEdgeMouseDown &&
181
- event.x === contentOrigin.x - 1
182
- ) {
183
- if (onLeftEdgeMouseDown(event)) {
137
+ const rightClickMenu = useMemo<ContextMenuItem[] | undefined>(
138
+ () =>
139
+ tabId != null && tabId !== ''
140
+ ? [
141
+ [
142
+ 'Split vertically',
143
+ () =>
144
+ runSideEffectGlobal({
145
+ direction: 'vertical',
146
+ sourceTabId: tabId,
147
+ type: 'split-pane',
148
+ }),
149
+ ],
150
+ [
151
+ 'Split horizontally',
152
+ () =>
153
+ runSideEffectGlobal({
154
+ direction: 'horizontal',
155
+ sourceTabId: tabId,
156
+ type: 'split-pane',
157
+ }),
158
+ ],
159
+ [
160
+ 'Close pane',
161
+ () => {
162
+ dispatchGlobal({ tabId, type: 'close-pane' })
163
+ runSideEffectGlobal({ tabId, type: 'close-tab' })
164
+ },
165
+ ],
166
+ ]
167
+ : undefined,
168
+ [tabId]
169
+ )
170
+ const isOnPaneBorder = useCallback(
171
+ (event: OtuiMouseEvent) =>
172
+ event.x === contentOrigin.x - 1 ||
173
+ event.x === contentOrigin.x + contentOrigin.cols ||
174
+ event.y === contentOrigin.y - 1 ||
175
+ event.y === contentOrigin.y + contentOrigin.rows,
176
+ [contentOrigin]
177
+ )
178
+ const forwardMouseEvent = useCallback(
179
+ (event: OtuiMouseEvent) => {
180
+ if (event.type === 'down' && event.button === 2 && rightClickMenu) {
184
181
  event.preventDefault()
185
182
  event.stopPropagation()
183
+ openContextMenu(event.x, event.y, rightClickMenu)
186
184
  return
187
185
  }
188
- }
189
- // Absorb left-button clicks on pane borders — they should not focus the
190
- // pane or be forwarded to the terminal. This keeps borders available for
191
- // resize actions (split separators, sidebar edge) without conflicting
192
- // with focus-on-click behavior in the content area.
193
- if (event.type === 'down' && event.button === 0 && isOnPaneBorder(event)) {
194
- event.preventDefault()
195
- event.stopPropagation()
196
- return
197
- }
198
- if (event.type === 'down') {
199
- logInputDebug('pane.mouseDown', {
200
- button: event.button,
201
- canForward: canForwardMouse,
202
- eventType: event.type,
203
- tabId,
204
- willClick: !canForwardMouse && !!tab && event.button === 0 && !!onTerminalClick,
205
- x: event.x,
206
- y: event.y,
207
- })
208
- }
209
- if (event.type === 'drag') {
210
- if (onTerminalDrag?.(event, contentOrigin, tabId) === true) {
211
- event.preventDefault()
212
- return
186
+ if (
187
+ event.type === 'down' &&
188
+ event.button === 0 &&
189
+ onLeftEdgeMouseDown &&
190
+ event.x === contentOrigin.x - 1
191
+ ) {
192
+ if (onLeftEdgeMouseDown(event)) {
193
+ event.preventDefault()
194
+ event.stopPropagation()
195
+ return
196
+ }
213
197
  }
214
- if (onSeparatorDrag?.(event) === true) {
198
+ // Absorb left-button clicks on pane borders — they should not focus the
199
+ // pane or be forwarded to the terminal. This keeps borders available for
200
+ // resize actions (split separators, sidebar edge) without conflicting
201
+ // with focus-on-click behavior in the content area.
202
+ if (event.type === 'down' && event.button === 0 && isOnPaneBorder(event)) {
215
203
  event.preventDefault()
204
+ event.stopPropagation()
216
205
  return
217
206
  }
218
- }
219
- if (event.type === 'up') {
220
- if (onTerminalMouseUp?.(event) === true) {
221
- event.preventDefault()
207
+ if (event.type === 'down') {
208
+ logInputDebug('pane.mouseDown', {
209
+ button: event.button,
210
+ canForward: canForwardMouse,
211
+ eventType: event.type,
212
+ tabId,
213
+ willClick: !canForwardMouse && !!tab && event.button === 0 && !!onTerminalClick,
214
+ x: event.x,
215
+ y: event.y,
216
+ })
217
+ }
218
+ if (event.type === 'drag') {
219
+ if (onTerminalDrag?.(event, contentOrigin, tabId) === true) {
220
+ event.preventDefault()
221
+ return
222
+ }
223
+ if (onSeparatorDrag?.(event) === true) {
224
+ event.preventDefault()
225
+ return
226
+ }
227
+ }
228
+ if (event.type === 'up') {
229
+ if (onTerminalMouseUp?.(event) === true) {
230
+ event.preventDefault()
231
+ }
232
+ onSeparatorDragEnd?.()
222
233
  }
223
- onSeparatorDragEnd?.()
224
- }
225
- if (tabId != null && tabId !== '' && onPaneActivate && event.type === 'down') {
226
- onPaneActivate(tabId)
227
- }
228
- if (!canForwardMouse) {
229
- if (tab && event.type === 'down' && event.button === 0 && onTerminalClick) {
230
- onTerminalClick(event, contentOrigin, tabId)
234
+ if (tabId != null && tabId !== '' && onPaneActivate && event.type === 'down') {
235
+ onPaneActivate(tabId)
236
+ }
237
+ if (!canForwardMouse) {
238
+ if (tab && event.type === 'down' && event.button === 0 && onTerminalClick) {
239
+ onTerminalClick(event, contentOrigin, tabId)
240
+ }
241
+ return
231
242
  }
232
- return
233
- }
234
243
 
235
- event.preventDefault()
236
- event.stopPropagation()
237
- onTerminalMouseEvent(event, contentOrigin)
238
- }
239
- const forwardScrollEvent = (event: OtuiMouseEvent) => {
240
- if (!canForwardMouse && !canUseLocalScrollback) {
241
- return
242
- }
244
+ event.preventDefault()
245
+ event.stopPropagation()
246
+ onTerminalMouseEvent(event, contentOrigin)
247
+ },
248
+ [
249
+ canForwardMouse,
250
+ contentOrigin,
251
+ isOnPaneBorder,
252
+ onLeftEdgeMouseDown,
253
+ onPaneActivate,
254
+ onSeparatorDrag,
255
+ onSeparatorDragEnd,
256
+ onTerminalClick,
257
+ onTerminalDrag,
258
+ onTerminalMouseEvent,
259
+ onTerminalMouseUp,
260
+ rightClickMenu,
261
+ tab,
262
+ tabId,
263
+ ]
264
+ )
265
+ const forwardScrollEvent = useCallback(
266
+ (event: OtuiMouseEvent) => {
267
+ if (!canForwardMouse && !canUseLocalScrollback) {
268
+ return
269
+ }
243
270
 
244
- event.preventDefault()
245
- event.stopPropagation()
271
+ event.preventDefault()
272
+ event.stopPropagation()
246
273
 
247
- if (canForwardMouse) {
248
- onTerminalMouseEvent(event, contentOrigin)
249
- return
250
- }
274
+ if (canForwardMouse) {
275
+ onTerminalMouseEvent(event, contentOrigin)
276
+ return
277
+ }
251
278
 
252
- onTerminalScrollEvent(event)
253
- }
279
+ onTerminalScrollEvent(event)
280
+ },
281
+ [
282
+ canForwardMouse,
283
+ canUseLocalScrollback,
284
+ contentOrigin,
285
+ onTerminalMouseEvent,
286
+ onTerminalScrollEvent,
287
+ ]
288
+ )
289
+ const handleNoTabMouseDown = useCallback((event: OtuiMouseEvent) => {
290
+ event.stopPropagation()
291
+ dispatchGlobal({ type: 'open-new-tab-modal' })
292
+ }, [])
293
+ const handleContentMouseDown = useCallback(
294
+ (e: OtuiMouseEvent) => {
295
+ e.stopPropagation()
296
+ forwardMouseEvent(e)
297
+ },
298
+ [forwardMouseEvent]
299
+ )
254
300
  return (
255
301
  <box flexDirection="column" flexGrow={1} gap={0}>
256
302
  <ContextMenuBox
@@ -282,10 +328,7 @@ export function TerminalPane({
282
328
  marginTop={1}
283
329
  paddingX={2}
284
330
  backgroundColor={t.backgroundPanel}
285
- onMouseDown={(event) => {
286
- event.stopPropagation()
287
- dispatchGlobal({ type: 'open-new-tab-modal' })
288
- }}
331
+ onMouseDown={handleNoTabMouseDown}
289
332
  >
290
333
  <text fg={t.text}>New assistant</text>
291
334
  </box>
@@ -297,10 +340,7 @@ export function TerminalPane({
297
340
  flexGrow={1}
298
341
  width="100%"
299
342
  overflow="hidden"
300
- onMouseDown={(e) => {
301
- e.stopPropagation()
302
- forwardMouseEvent(e)
303
- }}
343
+ onMouseDown={handleContentMouseDown}
304
344
  onMouseUp={forwardMouseEvent}
305
345
  onMouseDrag={forwardMouseEvent}
306
346
  onMouseScroll={forwardScrollEvent}
@@ -1,6 +1,6 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
 
3
- import { useLayoutEffect, useMemo } from 'react'
3
+ import { useCallback, useLayoutEffect, useMemo } from 'react'
4
4
 
5
5
  import { collectHelpEntries, HELP_MODE_LABELS } from '../../../../input/keymap/help-entries'
6
6
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
@@ -48,22 +48,29 @@ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModal
48
48
  dispatchGlobal({ count: filtered.length, type: 'set-help-entry-count' })
49
49
  }, [filtered.length])
50
50
 
51
- const items: PickerItem[] = filtered.map((entry, index) => {
52
- return {
53
- group: entry.modeLabel,
54
- key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
55
- title: (
56
- <text fg={t.textMuted} wrapMode="none">
57
- {entry.description ?? ''}
58
- </text>
59
- ),
60
- trailing: (
61
- <text fg={t.textMuted} wrapMode="none">
62
- {entry.keysDisplay}
63
- </text>
64
- ),
65
- }
66
- })
51
+ const items = useMemo<PickerItem[]>(
52
+ () =>
53
+ filtered.map((entry, index) => ({
54
+ group: entry.modeLabel,
55
+ key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
56
+ title: (
57
+ <text fg={t.textMuted} wrapMode="none">
58
+ {entry.description ?? ''}
59
+ </text>
60
+ ),
61
+ trailing: (
62
+ <text fg={t.textMuted} wrapMode="none">
63
+ {entry.keysDisplay}
64
+ </text>
65
+ ),
66
+ })),
67
+ [filtered, t]
68
+ )
69
+
70
+ const handleHover = useCallback(
71
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
72
+ []
73
+ )
67
74
 
68
75
  return (
69
76
  <Picker
@@ -79,7 +86,7 @@ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModal
79
86
  {filter != null && filter !== '' ? 'No matching bindings.' : 'No bindings registered.'}
80
87
  </text>
81
88
  }
82
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
89
+ onHover={handleHover}
83
90
  />
84
91
  )
85
92
  }
@@ -1,5 +1,7 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
1
3
  import { isAutoCommitEnabled } from '@brimveyn/aimux-config'
2
- import { useEffect, useState } from 'react'
4
+ import { useCallback, useEffect, useState } from 'react'
3
5
 
4
6
  import type { ModeId } from '../../../../input/modes/types'
5
7
 
@@ -99,7 +101,7 @@ export function GitCommitModal({
99
101
  (s) => s.gitPanel.files.filter((f) => f.section === 'staged').length
100
102
  )
101
103
 
102
- const onAutoCommitClick = (): void => {
104
+ const onAutoCommitClick = useCallback((): void => {
103
105
  const hasTitle = title.trim().length > 0
104
106
  if (hasTitle || !(currentSessionId != null && currentSessionId !== '')) {
105
107
  dispatchGlobal({ type: 'git-commit-enter-confirm' })
@@ -113,7 +115,16 @@ export function GitCommitModal({
113
115
  if (bgKind !== 'generating') {
114
116
  runSideEffectGlobal({ sessionId: currentSessionId, type: 'generate-auto-commit-now' })
115
117
  }
116
- }
118
+ }, [bgKind, currentSessionId, title])
119
+
120
+ const handleAutoCommitMouseDown = useCallback(
121
+ (event: OtuiMouseEvent) => {
122
+ event.preventDefault()
123
+ event.stopPropagation()
124
+ onAutoCommitClick()
125
+ },
126
+ [onAutoCommitClick]
127
+ )
117
128
 
118
129
  const modeId = pickModeId(stage)
119
130
  const shellTitle = pickShellTitle(stage)
@@ -169,11 +180,7 @@ export function GitCommitModal({
169
180
  flexDirection="row"
170
181
  gap={1}
171
182
  alignItems="center"
172
- onMouseDown={(event) => {
173
- event.preventDefault()
174
- event.stopPropagation()
175
- onAutoCommitClick()
176
- }}
183
+ onMouseDown={handleAutoCommitMouseDown}
177
184
  >
178
185
  {showBgSpinner ? <text fg={t.primary}>{bgSpinnerFrame}</text> : null}
179
186
  <text fg={t.text}>
@@ -1,3 +1,5 @@
1
+ import { useMemo } from 'react'
2
+
1
3
  import type { DirectoryResult } from '../../../../state/types'
2
4
 
3
5
  import { abbreviatePath } from '../../../path-format'
@@ -41,15 +43,17 @@ export function CreateSessionModal({
41
43
  const dirActive = activeField === 'directory'
42
44
  const nameActive = activeField === 'name'
43
45
 
44
- const items: FormOptionItem[] = results.map((result) => {
45
- return {
46
- key: result.path,
47
- leading: <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>,
48
- title: (active) => (
49
- <text fg={active ? t.text : t.textMuted}>{abbreviatePath(result.path)}</text>
50
- ),
51
- }
52
- })
46
+ const items = useMemo<FormOptionItem[]>(
47
+ () =>
48
+ results.map((result) => ({
49
+ key: result.path,
50
+ leading: <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>,
51
+ title: (active) => (
52
+ <text fg={active ? t.text : t.textMuted}>{abbreviatePath(result.path)}</text>
53
+ ),
54
+ })),
55
+ [results, t]
56
+ )
53
57
 
54
58
  return (
55
59
  <Form
@@ -1,3 +1,5 @@
1
+ import { useCallback, useMemo } from 'react'
2
+
1
3
  import type { SessionRecord } from '../../../../state/types'
2
4
 
3
5
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
@@ -44,41 +46,46 @@ export function SessionPickerModal({
44
46
  sessions,
45
47
  }: SessionPickerModalProps) {
46
48
  const t = useTheme()
47
- const ordered = orderSessionsForDisplay(sessions)
48
- const baselineOrder = ordered.map((s) => s.id)
49
- const filtered = filterSessions(ordered, filter)
49
+ const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
50
+ const filtered = useMemo(() => filterSessions(ordered, filter), [filter, ordered])
50
51
  const hasFilter = !!(filter != null && filter !== '')
51
52
 
52
- const sessionItems: PickerItem[] = filtered.map((session, index) => {
53
- const active = index === selectedIndex
54
- const displayIndex = baselineOrder.indexOf(session.id) + 1
55
- return {
56
- key: session.id,
53
+ const items = useMemo<PickerItem[]>(() => {
54
+ const baselineOrder = ordered.map((s) => s.id)
55
+ const sessionItems: PickerItem[] = filtered.map((session, index) => {
56
+ const active = index === selectedIndex
57
+ const displayIndex = baselineOrder.indexOf(session.id) + 1
58
+ return {
59
+ key: session.id,
60
+ onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
61
+ onDelete: () => runSideEffectGlobal({ type: 'delete-selected-session' }),
62
+ subtitle:
63
+ session.projectPath != null && session.projectPath !== '' ? (
64
+ <text fg={t.textMuted}>{abbreviatePath(session.projectPath)}</text>
65
+ ) : undefined,
66
+ title: (
67
+ <text fg={active ? t.text : t.textMuted}>
68
+ {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
69
+ </text>
70
+ ),
71
+ }
72
+ })
73
+ const createNewItem: PickerItem = {
74
+ key: '__create-new__',
57
75
  onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
58
- onDelete: () => runSideEffectGlobal({ type: 'delete-selected-session' }),
59
- subtitle:
60
- session.projectPath != null && session.projectPath !== '' ? (
61
- <text fg={t.textMuted}>{abbreviatePath(session.projectPath)}</text>
62
- ) : undefined,
63
76
  title: (
64
- <text fg={active ? t.text : t.textMuted}>
65
- {formatSessionLine(session, currentSessionId, currentTabCount, displayIndex)}
77
+ <text fg={selectedIndex === filtered.length ? t.text : t.textMuted}>
78
+ Create new workspace
66
79
  </text>
67
80
  ),
68
81
  }
69
- })
82
+ return [...sessionItems, createNewItem]
83
+ }, [currentSessionId, currentTabCount, filtered, ordered, selectedIndex, t])
70
84
 
71
- const createNewItem: PickerItem = {
72
- key: '__create-new__',
73
- onClick: () => runSideEffectGlobal({ type: 'confirm-selected-session' }),
74
- title: (
75
- <text fg={selectedIndex === filtered.length ? t.text : t.textMuted}>
76
- Create new workspace
77
- </text>
78
- ),
79
- }
80
-
81
- const items = [...sessionItems, createNewItem]
85
+ const handleHover = useCallback(
86
+ (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
87
+ []
88
+ )
82
89
 
83
90
  return (
84
91
  <Picker
@@ -95,7 +102,7 @@ export function SessionPickerModal({
95
102
  <text fg={t.textMuted}>{getEmptyStateMessage(hasFilter)}</text>
96
103
  ) : undefined
97
104
  }
98
- onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
105
+ onHover={handleHover}
99
106
  />
100
107
  )
101
108
  }
@@ -147,12 +147,13 @@ export function AutoComplete({
147
147
  return (
148
148
  <ListItem
149
149
  key={item.key}
150
+ index={optionIndex}
150
151
  active={optionActive}
151
152
  leading={resolveItemContent(item.leading, optionActive)}
152
153
  title={resolveItemContent(item.title, optionActive)}
153
154
  subtitle={resolveItemContent(item.subtitle, optionActive)}
154
155
  trailing={resolveItemContent(item.trailing, optionActive)}
155
- onHover={onHover ? () => onHover(optionIndex) : undefined}
156
+ onHoverIndex={onHover}
156
157
  onClick={item.onClick}
157
158
  />
158
159
  )