@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,6 +1,6 @@
1
1
  import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
- import { useMemo, useRef, useState } from 'react'
3
+ import { memo, useCallback, useMemo, useRef, useState } from 'react'
4
4
 
5
5
  import type { SessionRecord, SessionStatus } from '../../../state/types'
6
6
 
@@ -31,52 +31,49 @@ export function SessionBar({ forceVisible = false }: SessionBarProps) {
31
31
  const chipRefs = useRef(new Map<string, BoxRenderable>())
32
32
 
33
33
  const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
34
- if ((!bar.visible && !forceVisible) || ordered.length === 0) return null
34
+ const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
35
35
 
36
- const visibleSessions =
37
- dragOrder !== null
38
- ? dragOrder
39
- .map((id) => ordered.find((s) => s.id === id))
40
- .filter((s): s is SessionRecord => !!s)
41
- : ordered
42
-
43
- const baselineOrder = ordered.map((s) => s.id)
44
-
45
- function setChipRef(id: string, ref: BoxRenderable | null): void {
36
+ const setChipRef = useCallback((id: string, ref: BoxRenderable | null): void => {
46
37
  if (ref) chipRefs.current.set(id, ref)
47
38
  else chipRefs.current.delete(id)
48
- }
39
+ }, [])
49
40
 
50
- function findChipAtX(x: number): string | null {
41
+ const findChipAtX = useCallback((x: number): string | null => {
51
42
  for (const [id, ref] of chipRefs.current) {
52
43
  if (x >= ref.x && x < ref.x + ref.width) return id
53
44
  }
54
45
  return null
55
- }
46
+ }, [])
56
47
 
57
- const handleMouseDown = (id: string) => {
58
- setDraggingId(id)
59
- setDragOrder(baselineOrder)
60
- lastSwapWithRef.current = null
61
- }
62
-
63
- const handleMouseDrag = (event: OtuiMouseEvent) => {
64
- if (!(draggingId != null && draggingId !== '')) return
65
- const hit = findChipAtX(event.x)
66
- if (hit === null) {
67
- lastSwapWithRef.current = null
68
- return
69
- }
70
- if (hit === draggingId) {
48
+ const handleMouseDown = useCallback(
49
+ (id: string) => {
50
+ setDraggingId(id)
51
+ setDragOrder(baselineOrder)
71
52
  lastSwapWithRef.current = null
72
- return
73
- }
74
- if (hit === lastSwapWithRef.current) return
75
- setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
76
- lastSwapWithRef.current = hit
77
- }
53
+ },
54
+ [baselineOrder]
55
+ )
78
56
 
79
- const commitDrop = () => {
57
+ const handleMouseDrag = useCallback(
58
+ (event: OtuiMouseEvent) => {
59
+ if (!(draggingId != null && draggingId !== '')) return
60
+ const hit = findChipAtX(event.x)
61
+ if (hit === null) {
62
+ lastSwapWithRef.current = null
63
+ return
64
+ }
65
+ if (hit === draggingId) {
66
+ lastSwapWithRef.current = null
67
+ return
68
+ }
69
+ if (hit === lastSwapWithRef.current) return
70
+ setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
71
+ lastSwapWithRef.current = hit
72
+ },
73
+ [draggingId, findChipAtX]
74
+ )
75
+
76
+ const commitDrop = useCallback(() => {
80
77
  const source = draggingId
81
78
  const finalOrder = dragOrder
82
79
  setDraggingId(null)
@@ -95,13 +92,27 @@ export function SessionBar({ forceVisible = false }: SessionBarProps) {
95
92
  if (idx >= 0) {
96
93
  runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
97
94
  }
98
- }
95
+ }, [baselineOrder, dragOrder, draggingId])
99
96
 
100
- const cancelDrag = () => {
97
+ const cancelDrag = useCallback(() => {
101
98
  setDraggingId(null)
102
99
  setDragOrder(null)
103
100
  lastSwapWithRef.current = null
104
- }
101
+ }, [])
102
+
103
+ const handleNewSession = useCallback((e: OtuiMouseEvent) => {
104
+ e.stopPropagation()
105
+ dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
106
+ }, [])
107
+
108
+ if ((!bar.visible && !forceVisible) || ordered.length === 0) return null
109
+
110
+ const visibleSessions =
111
+ dragOrder !== null
112
+ ? dragOrder
113
+ .map((id) => ordered.find((s) => s.id === id))
114
+ .filter((s): s is SessionRecord => !!s)
115
+ : ordered
105
116
 
106
117
  return (
107
118
  <box
@@ -112,50 +123,28 @@ export function SessionBar({ forceVisible = false }: SessionBarProps) {
112
123
  paddingRight={1}
113
124
  backgroundColor={headerBg}
114
125
  >
115
- {visibleSessions.map((session) => {
116
- const displayIndex = baselineOrder.indexOf(session.id) + 1
117
- return (
118
- <SessionChip
119
- key={session.id}
120
- session={session}
121
- index={displayIndex}
122
- active={session.id === currentId}
123
- status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
124
- dragging={draggingId === session.id}
125
- onRef={(r) => setChipRef(session.id, r)}
126
- onMouseDown={() => handleMouseDown(session.id)}
127
- onMouseDrag={handleMouseDrag}
128
- onMouseUp={commitDrop}
129
- onMouseDragEnd={cancelDrag}
130
- rightClickMenu={[
131
- [
132
- 'Rename workspace',
133
- () =>
134
- dispatchGlobal({
135
- initialName: session.name,
136
- returnToSessionPicker: false,
137
- sessionTargetId: session.id,
138
- type: 'open-session-name-modal',
139
- }),
140
- ],
141
- [
142
- 'Delete workspace',
143
- () => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
144
- ],
145
- ]}
146
- />
147
- )
148
- })}
126
+ {visibleSessions.map((session) => (
127
+ <SessionChip
128
+ key={session.id}
129
+ session={session}
130
+ index={baselineOrder.indexOf(session.id) + 1}
131
+ active={session.id === currentId}
132
+ status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
133
+ dragging={draggingId === session.id}
134
+ setChipRef={setChipRef}
135
+ onDragStart={handleMouseDown}
136
+ onDrag={handleMouseDrag}
137
+ onDrop={commitDrop}
138
+ onDragCancel={cancelDrag}
139
+ />
140
+ ))}
149
141
  <box flexGrow={1} />
150
142
  <box
151
143
  flexDirection="row"
152
144
  paddingLeft={1}
153
145
  paddingRight={1}
154
146
  backgroundColor={t.backgroundElement}
155
- onMouseDown={(e) => {
156
- e.stopPropagation()
157
- dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
158
- }}
147
+ onMouseDown={handleNewSession}
159
148
  >
160
149
  <text fg={t.text} selectable={false}>
161
150
  + New
@@ -179,25 +168,23 @@ interface SessionChipProps {
179
168
  active: boolean
180
169
  status: SessionStatus
181
170
  dragging: boolean
182
- onRef: (ref: BoxRenderable | null) => void
183
- onMouseDown: (event: OtuiMouseEvent) => void
184
- onMouseDrag: (event: OtuiMouseEvent) => void
185
- onMouseUp: (event: OtuiMouseEvent) => void
186
- onMouseDragEnd: (event: OtuiMouseEvent) => void
187
- rightClickMenu: [string, () => void][]
171
+ setChipRef: (id: string, ref: BoxRenderable | null) => void
172
+ onDragStart: (id: string) => void
173
+ onDrag: (event: OtuiMouseEvent) => void
174
+ onDrop: () => void
175
+ onDragCancel: () => void
188
176
  }
189
177
 
190
- function SessionChip({
178
+ const SessionChip = memo(function SessionChip({
191
179
  active,
192
180
  dragging,
193
181
  index,
194
- onMouseDown,
195
- onMouseDrag,
196
- onMouseDragEnd,
197
- onMouseUp,
198
- onRef,
199
- rightClickMenu,
182
+ onDrag,
183
+ onDragCancel,
184
+ onDragStart,
185
+ onDrop,
200
186
  session,
187
+ setChipRef,
201
188
  status,
202
189
  }: SessionChipProps) {
203
190
  const t = useTheme()
@@ -212,30 +199,68 @@ function SessionChip({
212
199
  const waitingColor = t.warning
213
200
  const [hovered, setHovered] = useState(false)
214
201
 
202
+ const handleRef = useCallback(
203
+ (r: BoxRenderable | null) => setChipRef(session.id, r),
204
+ [setChipRef, session.id]
205
+ )
206
+ const handleMouseDown = useCallback(
207
+ (e: OtuiMouseEvent) => {
208
+ e.preventDefault()
209
+ onDragStart(session.id)
210
+ },
211
+ [onDragStart, session.id]
212
+ )
213
+ const handleMouseUp = useCallback(
214
+ (e: OtuiMouseEvent) => {
215
+ e.preventDefault()
216
+ onDrop()
217
+ },
218
+ [onDrop]
219
+ )
220
+ const handleMouseOver = useCallback(() => setHovered(true), [])
221
+ const handleMouseOut = useCallback(() => setHovered(false), [])
222
+ const handleDeleteMouseDown = useCallback(
223
+ (event: OtuiMouseEvent) => {
224
+ event.preventDefault()
225
+ event.stopPropagation()
226
+ runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' })
227
+ },
228
+ [session.id]
229
+ )
230
+ const rightClickMenu = useMemo<[string, () => void][]>(
231
+ () => [
232
+ [
233
+ 'Rename workspace',
234
+ () =>
235
+ dispatchGlobal({
236
+ initialName: session.name,
237
+ returnToSessionPicker: false,
238
+ sessionTargetId: session.id,
239
+ type: 'open-session-name-modal',
240
+ }),
241
+ ],
242
+ [
243
+ 'Delete workspace',
244
+ () => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
245
+ ],
246
+ ],
247
+ [session.id, session.name]
248
+ )
249
+
215
250
  return (
216
251
  <ContextMenuBox
217
- ref={onRef}
252
+ ref={handleRef}
218
253
  flexDirection="row"
219
254
  paddingLeft={1}
220
255
  paddingRight={1}
221
256
  backgroundColor={bgColor}
222
257
  rightClickMenu={rightClickMenu}
223
- onMouseOver={() => setHovered(true)}
224
- onMouseOut={() => setHovered(false)}
225
- onMouseDown={(e) => {
226
- e.preventDefault()
227
- onMouseDown(e)
228
- }}
229
- onMouseDrag={(e) => {
230
- onMouseDrag(e)
231
- }}
232
- onMouseUp={(e) => {
233
- e.preventDefault()
234
- onMouseUp(e)
235
- }}
236
- onMouseDragEnd={(e) => {
237
- onMouseDragEnd(e)
238
- }}
258
+ onMouseOver={handleMouseOver}
259
+ onMouseOut={handleMouseOut}
260
+ onMouseDown={handleMouseDown}
261
+ onMouseDrag={onDrag}
262
+ onMouseUp={handleMouseUp}
263
+ onMouseDragEnd={onDragCancel}
239
264
  >
240
265
  {showWaiting ? (
241
266
  <text fg={waitingColor} selectable={false}>
@@ -256,14 +281,7 @@ function SessionChip({
256
281
  [{index}] {session.name}
257
282
  </text>
258
283
  {hovered ? (
259
- <box
260
- paddingLeft={1}
261
- onMouseDown={(event) => {
262
- event.preventDefault()
263
- event.stopPropagation()
264
- runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' })
265
- }}
266
- >
284
+ <box paddingLeft={1} onMouseDown={handleDeleteMouseDown}>
267
285
  <text fg={t.textMuted} selectable={false}>
268
286
  ×
269
287
  </text>
@@ -275,4 +293,4 @@ function SessionChip({
275
293
  )}
276
294
  </ContextMenuBox>
277
295
  )
278
- }
296
+ })
@@ -4,7 +4,7 @@ import type {
4
4
  ScrollBoxRenderable,
5
5
  } from '@opentui/core'
6
6
 
7
- import { memo, useMemo, useRef } from 'react'
7
+ import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
8
8
 
9
9
  import type { BranchDivergence } from '../../../../state/types'
10
10
 
@@ -43,6 +43,7 @@ const GUTTER_MIDDLE = '├'
43
43
  const GUTTER_END = '╰'
44
44
  const GUTTER_PAD = '│'
45
45
  const RESIZE_HANDLE = '─'
46
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
46
47
 
47
48
  // Left accent strip + trailing space for a worktree group header.
48
49
  const WORKTREE_STRIP = '▍ '
@@ -92,6 +93,11 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
92
93
  const projectPath = getSessionProjectPath(currentSession)
93
94
  const branch = useSidebarBranch(projectPath)
94
95
 
96
+ const handleNewAssistant = useCallback((e: OtuiMouseEvent) => {
97
+ e.stopPropagation()
98
+ dispatchGlobal({ type: 'open-new-tab-modal' })
99
+ }, [])
100
+
95
101
  return (
96
102
  <box flexDirection="column" flexShrink={0} gap={0}>
97
103
  <text fg={t.text} selectable={false}>
@@ -122,10 +128,7 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
122
128
  backgroundColor={t.backgroundPanel}
123
129
  justifyContent="center"
124
130
  marginTop={1}
125
- onMouseDown={(e) => {
126
- e.stopPropagation()
127
- dispatchGlobal({ type: 'open-new-tab-modal' })
128
- }}
131
+ onMouseDown={handleNewAssistant}
129
132
  >
130
133
  <text fg={t.text} selectable={false}>
131
134
  + New assistant
@@ -153,6 +156,31 @@ function renderGroupGutter(isGroupStart: boolean, isGroupMiddle: boolean, isGrou
153
156
  )
154
157
  }
155
158
 
159
+ const TabRowButton = memo(function TabRowButton({
160
+ backgroundColor,
161
+ children,
162
+ onActivate,
163
+ tabId,
164
+ }: {
165
+ tabId: string
166
+ backgroundColor: string | undefined
167
+ onActivate?: (tabId: string) => void
168
+ children: ReactNode
169
+ }) {
170
+ const handleMouseDown = useCallback(
171
+ (event: OtuiMouseEvent) => {
172
+ event.stopPropagation()
173
+ onActivate?.(tabId)
174
+ },
175
+ [onActivate, tabId]
176
+ )
177
+ return (
178
+ <box backgroundColor={backgroundColor} flexDirection="row" onMouseDown={handleMouseDown}>
179
+ {children}
180
+ </box>
181
+ )
182
+ })
183
+
156
184
  interface TabsBodyProps {
157
185
  onTabActivate?: (tabId: string) => void
158
186
  contentWidth: number
@@ -213,7 +241,7 @@ const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBod
213
241
  flexGrow={1}
214
242
  scrollY
215
243
  viewportCulling
216
- contentOptions={{ flexDirection: 'column', gap: 0 }}
244
+ contentOptions={COLUMN_CONTENT_OPTIONS}
217
245
  >
218
246
  {tabs.length === 0 ? (
219
247
  <box paddingTop={1}>
@@ -287,13 +315,10 @@ const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBod
287
315
  </box>
288
316
  </box>
289
317
  ) : null}
290
- <box
318
+ <TabRowButton
319
+ tabId={tab.id}
320
+ onActivate={onTabActivate}
291
321
  backgroundColor={getRowBackground({ alternate, isActive, t })}
292
- flexDirection="row"
293
- onMouseDown={(event) => {
294
- event.stopPropagation()
295
- onTabActivate?.(tab.id)
296
- }}
297
322
  >
298
323
  {inGroup ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd) : null}
299
324
  <box flexGrow={1}>
@@ -306,7 +331,7 @@ const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBod
306
331
  moveWorktreeId={moveWorktreeId}
307
332
  />
308
333
  </box>
309
- </box>
334
+ </TabRowButton>
310
335
  </box>
311
336
  )
312
337
  })
@@ -330,6 +355,52 @@ export function Sidebar({
330
355
  const bodyRef = useRef<BoxRenderable | null>(null)
331
356
  useWorktreeDivergencePolling(sidebarVisible)
332
357
 
358
+ const handleSidebarMouseDown = useCallback(() => {
359
+ if (focusMode === 'terminal-input') {
360
+ dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
361
+ }
362
+ }, [focusMode])
363
+ const handleSidebarMouseDrag = useCallback(
364
+ (event: OtuiMouseEvent) => {
365
+ if (onResizeDrag?.(event) === true) {
366
+ event.preventDefault()
367
+ event.stopPropagation()
368
+ }
369
+ },
370
+ [onResizeDrag]
371
+ )
372
+ const handleSidebarMouseUp = useCallback(() => {
373
+ onResizeDragEnd?.()
374
+ }, [onResizeDragEnd])
375
+ const handleEmbeddedHandleMouseDown = useCallback(
376
+ (event: OtuiMouseEvent) => {
377
+ const body = bodyRef.current
378
+ if (!body) return
379
+ event.preventDefault()
380
+ event.stopPropagation()
381
+ onEmbeddedGitResizeStart?.({
382
+ containerStart: body.y,
383
+ position: gitPane.position === 'top' ? 'top' : 'bottom',
384
+ totalSize: Math.max(1, body.height),
385
+ })
386
+ },
387
+ [gitPane.position, onEmbeddedGitResizeStart]
388
+ )
389
+ const sidebarMenu = useMemo<[string, () => void][]>(
390
+ () => [
391
+ ['Hide sidebar', () => dispatchGlobal({ type: 'toggle-sidebar' })],
392
+ ['Toggle git pane', () => dispatchGlobal({ type: 'toggle-git-pane' })],
393
+ [
394
+ 'Toggle diff mode',
395
+ () => {
396
+ dispatchGlobal({ type: 'enter-git-mode' })
397
+ dispatchGlobal({ type: 'git-mode-toggle-diff-view' })
398
+ },
399
+ ],
400
+ ],
401
+ []
402
+ )
403
+
333
404
  if (!sidebarVisible) {
334
405
  return null
335
406
  }
@@ -369,21 +440,7 @@ export function Sidebar({
369
440
  </ContextMenuBox>
370
441
  ) : null
371
442
  const embeddedHandle = gitEmbedded ? (
372
- <box
373
- minHeight={1}
374
- flexShrink={0}
375
- onMouseDown={(event) => {
376
- const body = bodyRef.current
377
- if (!body) return
378
- event.preventDefault()
379
- event.stopPropagation()
380
- onEmbeddedGitResizeStart?.({
381
- containerStart: body.y,
382
- position: gitPane.position === 'top' ? 'top' : 'bottom',
383
- totalSize: Math.max(1, body.height),
384
- })
385
- }}
386
- >
443
+ <box minHeight={1} flexShrink={0} onMouseDown={handleEmbeddedHandleMouseDown}>
387
444
  <text fg={t.border} selectable={false}>
388
445
  {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
389
446
  </text>
@@ -398,31 +455,10 @@ export function Sidebar({
398
455
  backgroundColor={sidebarBg}
399
456
  gap={0}
400
457
  overflow="hidden"
401
- rightClickMenu={[
402
- ['Hide sidebar', () => dispatchGlobal({ type: 'toggle-sidebar' })],
403
- ['Toggle git pane', () => dispatchGlobal({ type: 'toggle-git-pane' })],
404
- [
405
- 'Toggle diff mode',
406
- () => {
407
- dispatchGlobal({ type: 'enter-git-mode' })
408
- dispatchGlobal({ type: 'git-mode-toggle-diff-view' })
409
- },
410
- ],
411
- ]}
412
- onMouseDown={() => {
413
- if (focusMode === 'terminal-input') {
414
- dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
415
- }
416
- }}
417
- onMouseDrag={(event) => {
418
- if (onResizeDrag?.(event) === true) {
419
- event.preventDefault()
420
- event.stopPropagation()
421
- }
422
- }}
423
- onMouseUp={() => {
424
- onResizeDragEnd?.()
425
- }}
458
+ rightClickMenu={sidebarMenu}
459
+ onMouseDown={handleSidebarMouseDown}
460
+ onMouseDrag={handleSidebarMouseDrag}
461
+ onMouseUp={handleSidebarMouseUp}
426
462
  >
427
463
  <box flexDirection="row" width={sidebarWidth} flexGrow={1} overflow="hidden">
428
464
  <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
@@ -1,4 +1,6 @@
1
- import { useState } from 'react'
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { useCallback, useMemo, useState } from 'react'
2
4
 
3
5
  import type { TabSession } from '../../../../state/types'
4
6
 
@@ -116,6 +118,64 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
116
118
  const indicatorColor = getIndicatorColor(active, focused, isInLayout)
117
119
  const [hovered, setHovered] = useState(false)
118
120
 
121
+ const rightClickMenu = useMemo<[string, () => void][]>(
122
+ () => [
123
+ [
124
+ 'Rename',
125
+ () => {
126
+ dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
127
+ dispatchGlobal({ type: 'open-rename-tab-modal' })
128
+ },
129
+ ],
130
+ [
131
+ 'Close',
132
+ () => {
133
+ dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
134
+ runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
135
+ },
136
+ ],
137
+ [
138
+ 'Move up',
139
+ () => {
140
+ dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
141
+ dispatchGlobal({ delta: -1, type: 'reorder-active-tab' })
142
+ },
143
+ ],
144
+ [
145
+ 'Move down',
146
+ () => {
147
+ dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
148
+ dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
149
+ },
150
+ ],
151
+ // Move this tab's worktree into another one. Opened from here it overlays
152
+ // the normal view (no git mode) since the open action doesn't touch focus.
153
+ ...(moveWorktreeId != null && moveWorktreeId !== ''
154
+ ? [
155
+ [
156
+ 'Move worktree',
157
+ () =>
158
+ dispatchGlobal({
159
+ sourceWorktreeId: moveWorktreeId,
160
+ type: 'open-worktree-move-modal',
161
+ }),
162
+ ] as [string, () => void],
163
+ ]
164
+ : []),
165
+ ],
166
+ [moveWorktreeId, tab.id]
167
+ )
168
+ const handleMouseOver = useCallback(() => setHovered(true), [])
169
+ const handleMouseOut = useCallback(() => setHovered(false), [])
170
+ const handleCloseMouseDown = useCallback(
171
+ (event: OtuiMouseEvent) => {
172
+ event.stopPropagation()
173
+ dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
174
+ runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
175
+ },
176
+ [tab.id]
177
+ )
178
+
119
179
  return (
120
180
  <ContextMenuBox
121
181
  id={id}
@@ -125,52 +185,9 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
125
185
  paddingBottom={0}
126
186
  flexDirection="column"
127
187
  gap={0}
128
- rightClickMenu={[
129
- [
130
- 'Rename',
131
- () => {
132
- dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
133
- dispatchGlobal({ type: 'open-rename-tab-modal' })
134
- },
135
- ],
136
- [
137
- 'Close',
138
- () => {
139
- dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
140
- runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
141
- },
142
- ],
143
- [
144
- 'Move up',
145
- () => {
146
- dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
147
- dispatchGlobal({ delta: -1, type: 'reorder-active-tab' })
148
- },
149
- ],
150
- [
151
- 'Move down',
152
- () => {
153
- dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
154
- dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
155
- },
156
- ],
157
- // Move this tab's worktree into another one. Opened from here it overlays
158
- // the normal view (no git mode) since the open action doesn't touch focus.
159
- ...(moveWorktreeId != null && moveWorktreeId !== ''
160
- ? [
161
- [
162
- 'Move worktree',
163
- () =>
164
- dispatchGlobal({
165
- sourceWorktreeId: moveWorktreeId,
166
- type: 'open-worktree-move-modal',
167
- }),
168
- ] as [string, () => void],
169
- ]
170
- : []),
171
- ]}
172
- onMouseOver={() => setHovered(true)}
173
- onMouseOut={() => setHovered(false)}
188
+ rightClickMenu={rightClickMenu}
189
+ onMouseOver={handleMouseOver}
190
+ onMouseOut={handleMouseOut}
174
191
  >
175
192
  <box flexDirection="row" alignItems="center">
176
193
  <text fg={indicatorColor} selectable={false}>
@@ -182,13 +199,7 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
182
199
  </text>
183
200
  </box>
184
201
  {hovered ? (
185
- <box
186
- onMouseDown={(event) => {
187
- event.stopPropagation()
188
- dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
189
- runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
190
- }}
191
- >
202
+ <box onMouseDown={handleCloseMouseDown}>
192
203
  <text fg={t.textMuted} selectable={false}>
193
204
  ×
194
205
  </text>