@brimveyn/aimux 1.14.0 → 1.14.1

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 (32) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +145 -1
  3. package/src/app-runtime/split-drag-controller.ts +3 -1
  4. package/src/app-runtime/use-terminal-resize.ts +1 -4
  5. package/src/app.tsx +0 -3
  6. package/src/config.ts +1 -12
  7. package/src/git/worktree-branch-poller.ts +54 -0
  8. package/src/input/modes/types.ts +2 -0
  9. package/src/state/layout-tree.ts +114 -4
  10. package/src/state/reducers/session-state.ts +20 -0
  11. package/src/state/reducers/tab-state.ts +20 -2
  12. package/src/state/reducers/ui-state.ts +0 -3
  13. package/src/state/session-worktrees.ts +45 -2
  14. package/src/state/store.ts +0 -3
  15. package/src/state/tab-entries.ts +74 -0
  16. package/src/state/types.ts +1 -4
  17. package/src/state/workspace-save.ts +0 -1
  18. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  19. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  20. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  21. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  22. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  23. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  24. package/src/ui/components/layout/split-layout.tsx +20 -39
  25. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  26. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  27. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  28. package/src/ui/root.tsx +68 -63
  29. package/src/ui/components/layout/session-bar.tsx +0 -296
  30. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  31. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  32. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
@@ -0,0 +1,92 @@
1
+ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
+
3
+ import { memo, useCallback, useMemo } from 'react'
4
+
5
+ import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
6
+
7
+ import { useAppStore } from '../../../../state/app-store'
8
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
9
+ import { useTheme } from '../../../theme'
10
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
11
+
12
+ interface WorktreeRowProps {
13
+ session: SessionRecord
14
+ worktree: WorktreeRecord
15
+ /** 1-based index of the workspace in the visible order (for switch-session-by-index). */
16
+ sessionIndex: number
17
+ /** True when this row is the active cursor item. */
18
+ isActiveItem: boolean
19
+ /** True when this row's workspace is the current session (selection scope). */
20
+ inCurrentGroup: boolean
21
+ }
22
+
23
+ export const WorktreeRow = memo(function WorktreeRow({
24
+ inCurrentGroup,
25
+ isActiveItem,
26
+ session,
27
+ sessionIndex,
28
+ worktree,
29
+ }: WorktreeRowProps) {
30
+ const t = useTheme()
31
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
32
+ const isCurrentSession = session.id === currentSessionId
33
+
34
+ const handleMouseDown = useCallback(
35
+ (event: OtuiMouseEvent) => {
36
+ if (event.button !== 0) return
37
+ event.preventDefault()
38
+ event.stopPropagation()
39
+ dispatchGlobal({
40
+ sessionId: session.id,
41
+ type: 'set-active-worktree',
42
+ worktreeId: worktree.id,
43
+ })
44
+ if (!isCurrentSession) {
45
+ runSideEffectGlobal({ index: sessionIndex, type: 'switch-session-by-index' })
46
+ }
47
+ },
48
+ [isCurrentSession, session.id, sessionIndex, worktree.id]
49
+ )
50
+
51
+ const rightClickMenu = useMemo<[string, () => void][] | undefined>(() => {
52
+ if (worktree.source === 'primary') return
53
+ return [
54
+ [
55
+ 'Remove worktree',
56
+ () =>
57
+ dispatchGlobal({
58
+ sessionId: session.id,
59
+ type: 'remove-worktree-record',
60
+ worktreeId: worktree.id,
61
+ }),
62
+ ],
63
+ ]
64
+ }, [session.id, worktree.id, worktree.source])
65
+
66
+ let bgColor: string | undefined
67
+ if (isActiveItem) {
68
+ bgColor = t.backgroundElement
69
+ } else if (inCurrentGroup) {
70
+ bgColor = t.backgroundPanel
71
+ }
72
+
73
+ return (
74
+ <ContextMenuBox
75
+ id={`sidebar-wt-${worktree.id}`}
76
+ flexDirection="row"
77
+ paddingLeft={1}
78
+ paddingRight={1}
79
+ alignItems="center"
80
+ backgroundColor={bgColor}
81
+ rightClickMenu={rightClickMenu}
82
+ onMouseDown={handleMouseDown}
83
+ >
84
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
85
+ {' > '}
86
+ </text>
87
+ <text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
88
+ {worktree.name}
89
+ </text>
90
+ </ContextMenuBox>
91
+ )
92
+ })
@@ -1,6 +1,6 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
- import { useCallback, useMemo } from 'react'
3
+ import { useMemo } from 'react'
4
4
 
5
5
  import type { MeasuredPaneRect } from '../../../app-runtime/use-pane-size-report'
6
6
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
@@ -8,13 +8,14 @@ import type { FocusMode, TabSession } from '../../../state/types'
8
8
 
9
9
  import { logInputDebug } from '../../../debug/input-log'
10
10
  import {
11
+ computeJunctionEdges,
11
12
  computePaneRects,
13
+ type JunctionEdges,
12
14
  type LayoutNode,
13
15
  PANE_BORDER,
14
16
  type PaneRect,
15
17
  type SplitDirection,
16
18
  } from '../../../state/layout-tree'
17
- import { useTheme } from '../../theme'
18
19
  import { TerminalPane } from './terminal-pane'
19
20
 
20
21
  const PANE_CHROME = PANE_BORDER
@@ -45,6 +46,7 @@ interface SplitLayoutProps {
45
46
  onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
46
47
  contentOrigin: TerminalContentOrigin
47
48
  bounds: PaneRect
49
+ junctionEdgesMap?: Map<string, JunctionEdges>
48
50
  }
49
51
 
50
52
  export function SplitLayout({
@@ -52,6 +54,7 @@ export function SplitLayout({
52
54
  bounds,
53
55
  contentOrigin,
54
56
  focusMode,
57
+ junctionEdgesMap: providedJunctionEdgesMap,
55
58
  localScrollbackEnabled,
56
59
  mouseForwardingEnabled,
57
60
  node,
@@ -69,7 +72,12 @@ export function SplitLayout({
69
72
  onTerminalScrollEvent,
70
73
  tabs,
71
74
  }: SplitLayoutProps) {
72
- const t = useTheme()
75
+ const junctionEdgesMap = useMemo(
76
+ () =>
77
+ providedJunctionEdgesMap ??
78
+ computeJunctionEdges(node, bounds, { x: contentOrigin.x, y: contentOrigin.y }),
79
+ [providedJunctionEdgesMap, node, bounds, contentOrigin]
80
+ )
73
81
  const paneOrigin = useMemo<TerminalContentOrigin>(
74
82
  () => ({
75
83
  cols: Math.max(1, bounds.cols - PANE_CHROME * 2),
@@ -79,22 +87,6 @@ export function SplitLayout({
79
87
  }),
80
88
  [bounds, contentOrigin]
81
89
  )
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
90
  if (node.type === 'leaf') {
99
91
  const tab = tabs.find((t) => t.id === node.tabId)
100
92
  const isActive = node.tabId === activeTabId
@@ -117,6 +109,7 @@ export function SplitLayout({
117
109
  focusMode={focusMode}
118
110
  isActive={isActive}
119
111
  contentOrigin={paneOrigin}
112
+ junctionEdges={junctionEdgesMap.get(node.tabId)}
120
113
  mouseForwardingEnabled={isActive && mouseForwardingEnabled}
121
114
  localScrollbackEnabled={isActive && localScrollbackEnabled}
122
115
  onTerminalMouseEvent={onTerminalMouseEvent}
@@ -127,6 +120,7 @@ export function SplitLayout({
127
120
  onPaneActivate={onPaneActivate}
128
121
  onSeparatorDrag={onSeparatorDrag}
129
122
  onSeparatorDragEnd={onSeparatorDragEnd}
123
+ onSeparatorDragStart={onSeparatorDragStart}
130
124
  onLeftEdgeMouseDown={onLeftEdgeMouseDown}
131
125
  onMeasure={onMeasure}
132
126
  />
@@ -134,23 +128,19 @@ export function SplitLayout({
134
128
  }
135
129
 
136
130
  const flexDir = node.direction === 'vertical' ? 'row' : 'column'
137
- const firstGrow = Math.round(node.ratio * 100)
138
- const secondGrow = 100 - firstGrow
139
131
 
140
- // Compute sub-bounds for each child
141
132
  const rects = computePaneRects(node, bounds)
142
-
143
- // Compute the bounding rect for each subtree
144
133
  const firstBounds = subtreeBounds(node.first, rects, bounds)
145
134
  const secondBounds = subtreeBounds(node.second, rects, bounds)
146
135
 
147
- // For vertical splits only the first subtree is leftmost; for horizontal
148
- // splits both first and second start at the same x.
136
+ const firstSize = node.direction === 'vertical' ? firstBounds.cols : firstBounds.rows
137
+ const firstSizeProp = node.direction === 'vertical' ? { width: firstSize } : { height: firstSize }
138
+
149
139
  const secondLeftEdgeMouseDown = node.direction === 'horizontal' ? onLeftEdgeMouseDown : undefined
150
140
 
151
141
  return (
152
142
  <box flexDirection={flexDir} flexGrow={1} gap={0}>
153
- <box flexGrow={firstGrow} flexDirection="column" overflow="hidden">
143
+ <box {...firstSizeProp} flexShrink={0} flexDirection="column" overflow="hidden">
154
144
  <SplitLayout
155
145
  node={node.first}
156
146
  tabs={tabs}
@@ -172,15 +162,10 @@ export function SplitLayout({
172
162
  onMeasure={onMeasure}
173
163
  contentOrigin={contentOrigin}
174
164
  bounds={firstBounds}
165
+ junctionEdgesMap={junctionEdgesMap}
175
166
  />
176
167
  </box>
177
- <box
178
- minWidth={node.direction === 'vertical' ? 1 : undefined}
179
- minHeight={node.direction === 'horizontal' ? 1 : undefined}
180
- backgroundColor={t.border}
181
- onMouseDown={handleSeparatorMouseDown}
182
- />
183
- <box flexGrow={secondGrow} flexDirection="column" overflow="hidden">
168
+ <box flexGrow={1} flexDirection="column" overflow="hidden">
184
169
  <SplitLayout
185
170
  node={node.second}
186
171
  tabs={tabs}
@@ -202,17 +187,13 @@ export function SplitLayout({
202
187
  onMeasure={onMeasure}
203
188
  contentOrigin={contentOrigin}
204
189
  bounds={secondBounds}
190
+ junctionEdgesMap={junctionEdgesMap}
205
191
  />
206
192
  </box>
207
193
  </box>
208
194
  )
209
195
  }
210
196
 
211
- function getFirstLeafId(node: LayoutNode): string | null {
212
- if (node.type === 'leaf') return node.tabId
213
- return getFirstLeafId(node.first)
214
- }
215
-
216
197
  function subtreeBounds(
217
198
  node: LayoutNode,
218
199
  rects: Map<string, PaneRect>,
@@ -3,6 +3,7 @@ import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
3
3
  import { memo, type ReactNode, useCallback, useMemo } from 'react'
4
4
 
5
5
  import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
6
+ import type { JunctionEdgeInfo, JunctionEdges } from '../../../state/layout-tree'
6
7
  import type { FocusMode, TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
7
8
 
8
9
  import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
@@ -28,8 +29,10 @@ interface TerminalPaneProps {
28
29
  onPaneActivate?: (tabId: string) => void
29
30
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
30
31
  onSeparatorDragEnd?: () => void
32
+ onSeparatorDragStart?: (info: JunctionEdgeInfo) => void
31
33
  onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
32
34
  onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
35
+ junctionEdges?: JunctionEdges
33
36
  }
34
37
 
35
38
  function getTitle(
@@ -113,6 +116,7 @@ export function TerminalPane({
113
116
  contentOrigin,
114
117
  focusMode,
115
118
  isActive,
119
+ junctionEdges,
116
120
  localScrollbackEnabled,
117
121
  mouseForwardingEnabled,
118
122
  onLeftEdgeMouseDown,
@@ -120,6 +124,7 @@ export function TerminalPane({
120
124
  onPaneActivate,
121
125
  onSeparatorDrag,
122
126
  onSeparatorDragEnd,
127
+ onSeparatorDragStart,
123
128
  onTerminalClick,
124
129
  onTerminalDrag,
125
130
  onTerminalMouseEvent,
@@ -175,6 +180,16 @@ export function TerminalPane({
175
180
  event.y === contentOrigin.y + contentOrigin.rows,
176
181
  [contentOrigin]
177
182
  )
183
+ const getJunctionSideAt = useCallback(
184
+ (event: OtuiMouseEvent): keyof JunctionEdges | null => {
185
+ if (event.x === contentOrigin.x - 1) return 'left'
186
+ if (event.x === contentOrigin.x + contentOrigin.cols) return 'right'
187
+ if (event.y === contentOrigin.y - 1) return 'top'
188
+ if (event.y === contentOrigin.y + contentOrigin.rows) return 'bottom'
189
+ return null
190
+ },
191
+ [contentOrigin]
192
+ )
178
193
  const forwardMouseEvent = useCallback(
179
194
  (event: OtuiMouseEvent) => {
180
195
  if (event.type === 'down' && event.button === 2 && rightClickMenu) {
@@ -195,6 +210,18 @@ export function TerminalPane({
195
210
  return
196
211
  }
197
212
  }
213
+ if (event.type === 'down' && event.button === 0 && junctionEdges && onSeparatorDragStart) {
214
+ const side = getJunctionSideAt(event)
215
+ if (side !== null) {
216
+ const info = junctionEdges[side]
217
+ if (info) {
218
+ event.preventDefault()
219
+ event.stopPropagation()
220
+ onSeparatorDragStart(info)
221
+ return
222
+ }
223
+ }
224
+ }
198
225
  // Absorb left-button clicks on pane borders — they should not focus the
199
226
  // pane or be forwarded to the terminal. This keeps borders available for
200
227
  // resize actions (split separators, sidebar edge) without conflicting
@@ -248,11 +275,14 @@ export function TerminalPane({
248
275
  [
249
276
  canForwardMouse,
250
277
  contentOrigin,
278
+ getJunctionSideAt,
251
279
  isOnPaneBorder,
280
+ junctionEdges,
252
281
  onLeftEdgeMouseDown,
253
282
  onPaneActivate,
254
283
  onSeparatorDrag,
255
284
  onSeparatorDragEnd,
285
+ onSeparatorDragStart,
256
286
  onTerminalClick,
257
287
  onTerminalDrag,
258
288
  onTerminalMouseEvent,
@@ -0,0 +1,304 @@
1
+ import type { MouseEvent as OtuiMouseEvent, ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
4
+
5
+ import type { FocusMode, TabSession } from '../../../state/types'
6
+
7
+ import { useWorktreeDivergencePolling } from '../../../git/worktree-divergence-poller'
8
+ import { useAppStore } from '../../../state/app-store'
9
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
10
+ import { filterTabsForActiveWorktree } from '../../../state/session-worktrees'
11
+ import { buildTabEntries, type GroupEntry } from '../../../state/tab-entries'
12
+ import { useTheme } from '../../theme'
13
+ import { ContextMenuBox } from '../overlays/context-menu/context-menu-box'
14
+ import { TabItem } from './sidebar/tab-item'
15
+ import { useTopTabBarAutoScroll } from './sidebar/use-top-tab-bar-auto-scroll'
16
+
17
+ interface TopTabBarProps {
18
+ forceVisible?: boolean
19
+ }
20
+
21
+ const ROW_CONTENT_OPTIONS = {
22
+ flexDirection: 'row' as const,
23
+ gap: 0,
24
+ justifyContent: 'flex-start' as const,
25
+ }
26
+
27
+ function getGroupIndicator(active: boolean, focused: boolean): string {
28
+ if (!active) return '·'
29
+ return focused ? '›' : '•'
30
+ }
31
+
32
+ function getGroupIndicatorColor(
33
+ t: ReturnType<typeof useTheme>,
34
+ active: boolean,
35
+ focused: boolean
36
+ ): string {
37
+ if (!active) return t.textMuted
38
+ return focused ? t.primary : t.text
39
+ }
40
+
41
+ const TopTabCell = memo(function TopTabCell({
42
+ active,
43
+ backgroundColor,
44
+ children,
45
+ entryId,
46
+ onActivate,
47
+ }: {
48
+ entryId: string
49
+ active: boolean
50
+ backgroundColor: string | undefined
51
+ onActivate?: (entryId: string) => void
52
+ children: ReactNode
53
+ }) {
54
+ const handleMouseDown = useCallback(
55
+ (event: OtuiMouseEvent) => {
56
+ event.stopPropagation()
57
+ onActivate?.(entryId)
58
+ },
59
+ [onActivate, entryId]
60
+ )
61
+ return (
62
+ <box
63
+ backgroundColor={backgroundColor}
64
+ flexDirection="row"
65
+ flexShrink={0}
66
+ onMouseDown={handleMouseDown}
67
+ data-active={active ? 'true' : undefined}
68
+ >
69
+ {children}
70
+ </box>
71
+ )
72
+ })
73
+
74
+ function GroupTabItem({
75
+ active,
76
+ entry,
77
+ focused,
78
+ indexLabel,
79
+ }: {
80
+ entry: GroupEntry
81
+ active: boolean
82
+ focused: boolean
83
+ indexLabel?: string
84
+ }) {
85
+ const t = useTheme()
86
+ const indicator = getGroupIndicator(active, focused)
87
+ const indicatorColor = getGroupIndicatorColor(t, active, focused)
88
+
89
+ const closeGroup = useCallback(() => {
90
+ for (const tab of entry.tabs) {
91
+ dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
92
+ runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
93
+ }
94
+ }, [entry.tabs])
95
+
96
+ const handleCloseMouseDown = useCallback(
97
+ (event: OtuiMouseEvent) => {
98
+ event.stopPropagation()
99
+ closeGroup()
100
+ },
101
+ [closeGroup]
102
+ )
103
+
104
+ const rightClickMenu = useMemo<[string, () => void][]>(
105
+ () => [['Close group', closeGroup]],
106
+ [closeGroup]
107
+ )
108
+
109
+ return (
110
+ <ContextMenuBox
111
+ id={`top-tab-${entry.id}`}
112
+ paddingLeft={1}
113
+ paddingRight={1}
114
+ flexDirection="row"
115
+ alignItems="center"
116
+ rightClickMenu={rightClickMenu}
117
+ >
118
+ <text fg={indicatorColor} selectable={false}>
119
+ {indicator}{' '}
120
+ </text>
121
+ {indexLabel != null && indexLabel !== '' ? (
122
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
123
+ {indexLabel}{' '}
124
+ </text>
125
+ ) : null}
126
+ {entry.tabs.map((tab, i) => {
127
+ const isLeafActive = tab.id === entry.activeLeafId
128
+ return (
129
+ <box key={tab.id} flexDirection="row" flexShrink={0}>
130
+ {i > 0 ? (
131
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
132
+ {' | '}
133
+ </text>
134
+ ) : null}
135
+ <text fg={isLeafActive ? t.text : t.textMuted} selectable={false} wrapMode="none">
136
+ {tab.title}
137
+ </text>
138
+ </box>
139
+ )
140
+ })}
141
+ <box paddingLeft={1} onMouseDown={handleCloseMouseDown}>
142
+ <text fg={t.textMuted} selectable={false}>
143
+ ×
144
+ </text>
145
+ </box>
146
+ </ContextMenuBox>
147
+ )
148
+ }
149
+
150
+ export function TopTabBar({ forceVisible = false }: TopTabBarProps) {
151
+ const t = useTheme()
152
+ const headerBg = t.backgroundPanel
153
+ const tabs = useAppStore((s) => s.tabs)
154
+ const activeTabId = useAppStore((s) => s.activeTabId)
155
+ const bar = useAppStore((s) => s.sessionBar)
156
+ const sidebar = useAppStore((s) => s.sidebar)
157
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
158
+ const sessions = useAppStore((s) => s.sessions)
159
+ const focusMode: FocusMode = useAppStore((s) => s.focusMode)
160
+ const layoutTrees = useAppStore((s) => s.layoutTrees)
161
+ const tabGroupMap = useAppStore((s) => s.tabGroupMap)
162
+
163
+ // Sidebar now also shows worktree chips with divergence — poll whenever
164
+ // either surface is visible.
165
+ useWorktreeDivergencePolling(bar.visible || sidebar.visible || forceVisible)
166
+
167
+ const currentSession = useMemo(
168
+ () =>
169
+ currentSessionId != null && currentSessionId !== ''
170
+ ? sessions.find((s) => s.id === currentSessionId)
171
+ : undefined,
172
+ [currentSessionId, sessions]
173
+ )
174
+
175
+ const visibleTabs = useMemo(
176
+ () => filterTabsForActiveWorktree(tabs, currentSession),
177
+ [tabs, currentSession]
178
+ )
179
+
180
+ const entries = useMemo(
181
+ () => buildTabEntries(visibleTabs, layoutTrees, tabGroupMap, activeTabId),
182
+ [visibleTabs, layoutTrees, tabGroupMap, activeTabId]
183
+ )
184
+
185
+ const activeEntryId = useMemo(() => {
186
+ for (const entry of entries) {
187
+ if (entry.kind === 'single') {
188
+ if (entry.tab.id === activeTabId) return entry.id
189
+ } else if (entry.tabs.some((tab) => tab.id === activeTabId)) {
190
+ return entry.id
191
+ }
192
+ }
193
+ return null
194
+ }, [entries, activeTabId])
195
+
196
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
197
+ useTopTabBarAutoScroll({
198
+ activeTabId: activeEntryId,
199
+ idPrefix: 'top-tab-',
200
+ scrollRef,
201
+ visible: bar.visible || forceVisible,
202
+ })
203
+
204
+ const handleEntryActivate = useCallback(
205
+ (entryId: string) => {
206
+ const entry = entries.find((e) => e.id === entryId)
207
+ if (!entry) return
208
+ const targetTabId = entry.kind === 'single' ? entry.tab.id : entry.activeLeafId
209
+ if (targetTabId !== activeTabId) {
210
+ dispatchGlobal({ tabId: targetTabId, type: 'set-active-tab' })
211
+ }
212
+ dispatchGlobal({ focusMode: 'terminal-input', type: 'set-focus-mode' })
213
+ },
214
+ [entries, activeTabId]
215
+ )
216
+
217
+ const handleNewTab = useCallback((e: OtuiMouseEvent) => {
218
+ e.stopPropagation()
219
+ dispatchGlobal({ type: 'open-new-tab-modal' })
220
+ }, [])
221
+
222
+ if (!bar.visible && !forceVisible) return null
223
+ if (entries.length === 0) return null
224
+
225
+ const isFocused = focusMode === 'terminal-input' || focusMode === 'navigation'
226
+
227
+ return (
228
+ <box
229
+ width="100%"
230
+ height={1}
231
+ flexDirection="row"
232
+ flexShrink={0}
233
+ backgroundColor={headerBg}
234
+ overflow="hidden"
235
+ >
236
+ <scrollbox
237
+ ref={scrollRef}
238
+ height={1}
239
+ flexGrow={1}
240
+ flexShrink={1}
241
+ flexBasis={0}
242
+ scrollX
243
+ viewportCulling
244
+ contentOptions={ROW_CONTENT_OPTIONS}
245
+ >
246
+ {entries.map((entry, index) => {
247
+ // [N] is shown only for the first 9 entries — that's the range
248
+ // Leader+1..9 can address.
249
+ const indexLabel = index < 9 ? `[${index + 1}]` : undefined
250
+ if (entry.kind === 'single') {
251
+ const tab: TabSession = entry.tab
252
+ const isActive = tab.id === activeTabId
253
+ return (
254
+ <TopTabCell
255
+ key={entry.id}
256
+ entryId={entry.id}
257
+ active={isActive}
258
+ onActivate={handleEntryActivate}
259
+ backgroundColor={isActive ? t.backgroundElement : undefined}
260
+ >
261
+ <TabItem
262
+ id={`top-tab-${tab.id}`}
263
+ tab={tab}
264
+ active={isActive}
265
+ focused={isFocused}
266
+ indexLabel={indexLabel}
267
+ alwaysShowClose
268
+ />
269
+ </TopTabCell>
270
+ )
271
+ }
272
+ const isActive = entry.tabs.some((tab) => tab.id === activeTabId)
273
+ return (
274
+ <TopTabCell
275
+ key={entry.id}
276
+ entryId={entry.id}
277
+ active={isActive}
278
+ onActivate={handleEntryActivate}
279
+ backgroundColor={isActive ? t.backgroundElement : undefined}
280
+ >
281
+ <GroupTabItem
282
+ entry={entry}
283
+ active={isActive}
284
+ focused={isFocused}
285
+ indexLabel={indexLabel}
286
+ />
287
+ </TopTabCell>
288
+ )
289
+ })}
290
+ <box
291
+ flexDirection="row"
292
+ flexShrink={0}
293
+ paddingLeft={1}
294
+ paddingRight={1}
295
+ onMouseDown={handleNewTab}
296
+ >
297
+ <text fg={t.textMuted} selectable={false}>
298
+ +
299
+ </text>
300
+ </box>
301
+ </scrollbox>
302
+ </box>
303
+ )
304
+ }
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'
3
3
  import type { WorktreeRecord } from '../../../../state/types'
4
4
 
5
5
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
6
+ import { formatDivergence } from '../../../../state/session-worktrees'
6
7
  import { useTheme } from '../../../theme'
7
8
  import { uiTokens } from '../../../ui-tokens'
8
9
  import { ListItem } from '../../primitives/list-item'
@@ -23,14 +24,6 @@ const MOVE_HINTS: [key: string, label: string][] = [
23
24
  ['esc', 'cancel'],
24
25
  ]
25
26
 
26
- function formatDivergence(divergence: { ahead: number; behind: number } | undefined): string {
27
- if (divergence == null) return ''
28
- const parts: string[] = []
29
- if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
30
- if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
31
- return parts.join(' ')
32
- }
33
-
34
27
  export function WorktreeMoveModal({
35
28
  deleteSource,
36
29
  divergence,