@brimveyn/aimux 1.13.2 → 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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +1 -3
  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 +149 -19
  6. package/src/app-runtime/snippet-actions.ts +2 -3
  7. package/src/app-runtime/split-drag-controller.ts +3 -1
  8. package/src/app-runtime/use-backend-runtime.ts +4 -7
  9. package/src/app-runtime/use-mouse-handlers.ts +30 -1
  10. package/src/app-runtime/use-renderer-bindings.ts +4 -12
  11. package/src/app-runtime/use-terminal-resize.ts +7 -22
  12. package/src/app.tsx +0 -6
  13. package/src/config.ts +1 -12
  14. package/src/daemon/daemon.ts +2 -19
  15. package/src/daemon/session-manager.ts +3 -15
  16. package/src/daemon/session-registry.ts +11 -30
  17. package/src/git/worktree-branch-poller.ts +54 -0
  18. package/src/input/modes/types.ts +2 -0
  19. package/src/input/terminal-text-extraction.ts +7 -8
  20. package/src/ipc/manager-protocol.ts +6 -38
  21. package/src/ipc/protocol.ts +9 -37
  22. package/src/pty/pty-manager.ts +20 -31
  23. package/src/pty/terminal-snapshot.ts +43 -0
  24. package/src/session-backend/local-session-backend.ts +7 -31
  25. package/src/session-backend/remote-session-backend.ts +5 -32
  26. package/src/session-backend/types.ts +2 -15
  27. package/src/state/layout-tree.ts +114 -4
  28. package/src/state/reducers/session-state.ts +20 -0
  29. package/src/state/reducers/tab-state.ts +22 -22
  30. package/src/state/reducers/ui-state.ts +0 -3
  31. package/src/state/session-persistence.ts +2 -22
  32. package/src/state/session-worktrees.ts +45 -2
  33. package/src/state/store.ts +0 -3
  34. package/src/state/tab-entries.ts +74 -0
  35. package/src/state/types.ts +4 -15
  36. package/src/state/validation.ts +0 -8
  37. package/src/state/workspace-save.ts +0 -1
  38. package/src/terminal-manager/manager-client.ts +5 -29
  39. package/src/terminal-manager/terminal-manager.ts +2 -17
  40. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  41. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  42. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  43. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  44. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  45. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  46. package/src/ui/components/layout/split-layout.tsx +20 -39
  47. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  48. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  49. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  50. package/src/ui/root.tsx +68 -63
  51. package/src/ui/components/layout/session-bar.tsx +0 -296
  52. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  53. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  54. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
@@ -1,34 +1,16 @@
1
- import type {
2
- BoxRenderable,
3
- MouseEvent as OtuiMouseEvent,
4
- ScrollBoxRenderable,
5
- } from '@opentui/core'
1
+ import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
6
2
 
7
- import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
3
+ import { useCallback, useMemo, useRef } from 'react'
8
4
 
9
- import type { BranchDivergence } from '../../../../state/types'
10
-
11
- import { useWorktreeDivergencePolling } from '../../../../git/worktree-divergence-poller'
12
5
  import { useAppStore } from '../../../../state/app-store'
13
6
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
14
- import {
15
- getActiveWorktree,
16
- getRenderedTabWorktreeId,
17
- getSessionProjectPath,
18
- getWorktreeColor,
19
- orderTabsByWorktree,
20
- } from '../../../../state/session-worktrees'
21
- import { getCurrentTheme, type ResolvedTuiTheme, useTheme } from '../../../theme'
7
+ import { useTheme } from '../../../theme'
22
8
  import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
23
9
  import { GitPaneWidget } from '../../git/pane/git-pane-widget'
24
10
  import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
25
- import { buildTabGroupInfo } from './sidebar-group-metadata'
26
- import { TabItem } from './tab-item'
27
- import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
28
- import { useSidebarBranch } from './use-sidebar-branch'
11
+ import { WorkspaceList } from './workspace-list'
29
12
 
30
13
  interface SidebarProps {
31
- onTabActivate?: (tabId: string) => void
32
14
  onResizeDrag?: (event: OtuiMouseEvent) => boolean
33
15
  onResizeDragEnd?: () => void
34
16
  onEmbeddedGitResizeStart?: (info: {
@@ -38,314 +20,9 @@ interface SidebarProps {
38
20
  }) => void
39
21
  }
40
22
 
41
- const GUTTER_START = '╭'
42
- const GUTTER_MIDDLE = '├'
43
- const GUTTER_END = '╰'
44
- const GUTTER_PAD = '│'
45
23
  const RESIZE_HANDLE = '─'
46
- const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
47
-
48
- // Left accent strip + trailing space for a worktree group header.
49
- const WORKTREE_STRIP = '▍ '
50
-
51
- // Compact "↑ahead ↓behind" label for a worktree group header; empty when the
52
- // branch is level with its base (or divergence isn't known yet).
53
- function formatDivergence(divergence: BranchDivergence | undefined): string {
54
- if (divergence == null) return ''
55
- const parts: string[] = []
56
- if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
57
- if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
58
- return parts.join(' ')
59
- }
60
-
61
- // Fit a worktree label into `max` columns, ellipsizing when it would overflow.
62
- // Branch names are ASCII, so character count tracks rendered column width.
63
- function truncateLabel(label: string, max: number): string {
64
- if (max <= 0) return ''
65
- if (label.length <= max) return label
66
- if (max === 1) return '…'
67
- return `${label.slice(0, max - 1)}…`
68
- }
69
-
70
- function getRowBackground({
71
- alternate,
72
- isActive,
73
- t,
74
- }: {
75
- isActive: boolean
76
- alternate: boolean
77
- t: ResolvedTuiTheme
78
- }): string | undefined {
79
- if (isActive) return t.backgroundElement
80
- if (alternate) return t.backgroundPanel
81
- return t.backgroundPanel
82
- }
83
-
84
- const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: number }) {
85
- const t = useTheme()
86
- const currentSessionId = useAppStore((s) => s.currentSessionId)
87
- const sessions = useAppStore((s) => s.sessions)
88
- const currentSession =
89
- currentSessionId != null && currentSessionId !== ''
90
- ? sessions.find((s) => s.id === currentSessionId)
91
- : undefined
92
- const activeWorktree = getActiveWorktree(currentSession)
93
- const projectPath = getSessionProjectPath(currentSession)
94
- const branch = useSidebarBranch(projectPath)
95
-
96
- const handleNewAssistant = useCallback((e: OtuiMouseEvent) => {
97
- e.stopPropagation()
98
- dispatchGlobal({ type: 'open-new-tab-modal' })
99
- }, [])
100
-
101
- return (
102
- <box flexDirection="column" flexShrink={0} gap={0}>
103
- <text fg={t.text} selectable={false}>
104
- <strong>aimux</strong>
105
- </text>
106
- <text fg={t.text} selectable={false}>
107
- {currentSession ? currentSession.name : 'No workspace selected'}
108
- </text>
109
- {(branch != null && branch !== '') || activeWorktree ? (
110
- <box flexDirection="row">
111
- <text fg={t.text} selectable={false}>
112
- {'\u{e702}'}{' '}
113
- </text>
114
- <text fg={t.text} selectable={false}>
115
- {branch ?? activeWorktree?.branch ?? activeWorktree?.name}
116
- </text>
117
- {activeWorktree?.source === 'aimux-temp' ? (
118
- <text fg={t.textMuted} selectable={false}>
119
- {' '}
120
- tmp
121
- </text>
122
- ) : null}
123
- </box>
124
- ) : null}
125
- <box
126
- flexDirection="row"
127
- paddingY={1}
128
- backgroundColor={t.backgroundPanel}
129
- justifyContent="center"
130
- marginTop={1}
131
- onMouseDown={handleNewAssistant}
132
- >
133
- <text fg={t.text} selectable={false}>
134
- + New assistant
135
- </text>
136
- </box>
137
- <text fg={t.textMuted} selectable={false}>
138
- {'·'.repeat(Math.max(0, contentWidth - 2))}
139
- </text>
140
- </box>
141
- )
142
- })
143
-
144
- function renderGroupGutter(isGroupStart: boolean, isGroupMiddle: boolean, isGroupEnd: boolean) {
145
- const t = getCurrentTheme()
146
- return (
147
- <box flexDirection="column" width={1} overflow="hidden">
148
- <text fg={t.border} selectable={false}>
149
- {/* oxlint-disable-next-line no-nested-ternary */}
150
- {isGroupStart ? GUTTER_START : isGroupMiddle ? GUTTER_MIDDLE : GUTTER_PAD}
151
- </text>
152
- <text fg={t.border} selectable={false}>
153
- {isGroupEnd ? GUTTER_END : GUTTER_PAD}
154
- </text>
155
- </box>
156
- )
157
- }
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
-
184
- interface TabsBodyProps {
185
- onTabActivate?: (tabId: string) => void
186
- contentWidth: number
187
- }
188
-
189
- const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBodyProps) {
190
- const t = useTheme()
191
- const tabs = useAppStore((s) => s.tabs)
192
- const activeTabId = useAppStore((s) => s.activeTabId)
193
- const focusMode = useAppStore((s) => s.focusMode)
194
- const layoutTrees = useAppStore((s) => s.layoutTrees)
195
- const sidebarVisible = useAppStore((s) => s.sidebar.visible)
196
- const currentSessionId = useAppStore((s) => s.currentSessionId)
197
- const sessions = useAppStore((s) => s.sessions)
198
- const worktreeDivergence = useAppStore((s) => s.worktreeDivergence)
199
- const currentSession =
200
- currentSessionId != null && currentSessionId !== ''
201
- ? sessions.find((s) => s.id === currentSessionId)
202
- : undefined
203
- const worktrees = useMemo(() => currentSession?.worktrees ?? [], [currentSession?.worktrees])
204
- const worktreeById = useMemo(
205
- () => new Map(worktrees.map((worktree) => [worktree.id, worktree])),
206
- [worktrees]
207
- )
208
- const groupedTabs = useMemo(() => {
209
- return orderTabsByWorktree(tabs, currentSession)
210
- }, [currentSession, tabs])
211
- const showWorktreeSeparators = useMemo(() => {
212
- const ids = new Set<string>()
213
- for (const tab of groupedTabs) {
214
- ids.add(getRenderedTabWorktreeId(tab, worktrees))
215
- }
216
- const activeWorktreeId =
217
- currentSession?.activeWorktreeId ?? getActiveWorktree(currentSession)?.id
218
- if (activeWorktreeId != null && activeWorktreeId !== '') ids.add(activeWorktreeId)
219
- return ids.size >= 2
220
- }, [currentSession, groupedTabs, worktrees])
221
-
222
- const scrollRef = useRef<ScrollBoxRenderable | null>(null)
223
- const activeIndex = groupedTabs.findIndex((tab) => tab.id === activeTabId)
224
- const tabGroupInfo = useMemo(
225
- () => buildTabGroupInfo(layoutTrees, groupedTabs),
226
- [layoutTrees, groupedTabs]
227
- )
228
-
229
- useSidebarAutoScroll({
230
- activeIndex,
231
- activeTabId,
232
- scrollRef,
233
- tabCount: groupedTabs.length,
234
- visible: sidebarVisible,
235
- })
236
-
237
- return (
238
- <scrollbox
239
- paddingTop={0}
240
- ref={scrollRef}
241
- flexGrow={1}
242
- scrollY
243
- viewportCulling
244
- contentOptions={COLUMN_CONTENT_OPTIONS}
245
- >
246
- {tabs.length === 0 ? (
247
- <box paddingTop={1}>
248
- <text fg={t.textMuted} selectable={false}>
249
- No tabs yet. Press Ctrl+n.
250
- </text>
251
- </box>
252
- ) : (
253
- groupedTabs.map((tab, index) => {
254
- const isActive = tab.id === activeTabId
255
- const alternate = index % 2 === 1
256
- const info = tabGroupInfo.get(tab.id)
257
- const inLayout = !!(info?.inLayout === true)
258
- const inGroup = info ? index >= info.groupStart && index <= info.groupEnd : false
259
- const isGroupStart = info ? index === info.groupStart : false
260
- const isGroupEnd = info ? index === info.groupEnd : false
261
- const isGroupMiddle = inGroup && !isGroupStart && !isGroupEnd
262
- const tabOwnWorktree =
263
- tab.worktreeId != null && tab.worktreeId !== ''
264
- ? worktreeById.get(tab.worktreeId)
265
- : undefined
266
- let tabWorktree = tabOwnWorktree
267
- if (!tabWorktree && worktrees.length > 1) {
268
- tabWorktree = worktrees[0]
269
- }
270
- // A tab's worktree can be moved when it has a branch (squash needs one,
271
- // so not the primary) and there's at least one other worktree to land in.
272
- const moveWorktreeId =
273
- tabOwnWorktree?.branch != null && tabOwnWorktree.branch !== '' && worktrees.length > 1
274
- ? tabOwnWorktree.id
275
- : undefined
276
- const prevTab = groupedTabs[index - 1]
277
- const startsWorktreeGroup =
278
- !prevTab ||
279
- getRenderedTabWorktreeId(prevTab, worktrees) !==
280
- getRenderedTabWorktreeId(tab, worktrees)
281
- const worktreeColor = tabWorktree
282
- ? (tabWorktree.color ?? getWorktreeColor(tabWorktree.id))
283
- : t.textMuted
284
- const worktreeLabel = tabWorktree?.branch ?? tabWorktree?.name ?? 'main'
285
- const aheadBehind = formatDivergence(
286
- tabWorktree != null ? worktreeDivergence[tabWorktree.id] : undefined
287
- )
288
- const aheadBehindText = aheadBehind === '' ? '' : ` ${aheadBehind}`
289
- // Reserve room for the strip, the ahead/behind chip and a 1-col gap so
290
- // the header never wraps; the divider fills whatever remains (min 0).
291
- const labelBudget = Math.max(
292
- 0,
293
- contentWidth - WORKTREE_STRIP.length - aheadBehindText.length - 1
294
- )
295
- const headerLabel = truncateLabel(worktreeLabel, labelBudget)
296
24
 
297
- return (
298
- <box key={tab.id} flexDirection="column">
299
- {showWorktreeSeparators && startsWorktreeGroup ? (
300
- <box flexDirection="row" overflow="hidden" paddingTop={index === 0 ? 0 : 1}>
301
- <text selectable={false} wrapMode="none" flexShrink={0}>
302
- <span fg={worktreeColor}>
303
- {WORKTREE_STRIP}
304
- {headerLabel}
305
- </span>
306
- {aheadBehindText !== '' ? (
307
- <span fg={t.textMuted}>{aheadBehindText}</span>
308
- ) : null}
309
- </text>
310
- <box flexGrow={1} flexShrink={1} flexBasis={0} overflow="hidden">
311
- <text fg={t.textMuted} selectable={false} wrapMode="none">
312
- {' '}
313
- {'─'.repeat(contentWidth)}
314
- </text>
315
- </box>
316
- </box>
317
- ) : null}
318
- <TabRowButton
319
- tabId={tab.id}
320
- onActivate={onTabActivate}
321
- backgroundColor={getRowBackground({ alternate, isActive, t })}
322
- >
323
- {inGroup ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd) : null}
324
- <box flexGrow={1}>
325
- <TabItem
326
- id={`sidebar-tab-${tab.id}`}
327
- tab={tab}
328
- active={isActive}
329
- focused={focusMode === 'navigation'}
330
- inLayout={inLayout}
331
- moveWorktreeId={moveWorktreeId}
332
- />
333
- </box>
334
- </TabRowButton>
335
- </box>
336
- )
337
- })
338
- )}
339
- </scrollbox>
340
- )
341
- })
342
-
343
- export function Sidebar({
344
- onEmbeddedGitResizeStart,
345
- onResizeDrag,
346
- onResizeDragEnd,
347
- onTabActivate,
348
- }: SidebarProps) {
25
+ export function Sidebar({ onEmbeddedGitResizeStart, onResizeDrag, onResizeDragEnd }: SidebarProps) {
349
26
  const t = useTheme()
350
27
  const sidebarBg = t.background
351
28
  const sidebarVisible = useAppStore((s) => s.sidebar.visible)
@@ -353,7 +30,6 @@ export function Sidebar({
353
30
  const gitPane = useAppStore((s) => s.gitPane)
354
31
  const focusMode = useAppStore((s) => s.focusMode)
355
32
  const bodyRef = useRef<BoxRenderable | null>(null)
356
- useWorktreeDivergencePolling(sidebarVisible)
357
33
 
358
34
  const handleSidebarMouseDown = useCallback(() => {
359
35
  if (focusMode === 'terminal-input') {
@@ -418,15 +94,10 @@ export function Sidebar({
418
94
  }
419
95
  )
420
96
 
421
- // flex-grow scaled by 100 (integer preferred); tabs gets (1-ratio), git gets ratio.
422
- const tabsGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.embeddedRatio) * 100)) : 1
97
+ // flex-grow scaled by 100 (integer preferred); workspace list gets (1-ratio), git gets ratio.
98
+ const listGrow = gitEmbedded ? Math.max(1, Math.round((1 - gitPane.embeddedRatio) * 100)) : 1
423
99
  const gitGrow = gitEmbedded ? Math.max(1, Math.round(gitPane.embeddedRatio * 100)) : 0
424
100
 
425
- const separator = (
426
- <text fg={t.textMuted} selectable={false}>
427
- {'·'.repeat(Math.max(0, contentWidth - 2))}
428
- </text>
429
- )
430
101
  const gitBody = gitEmbedded ? (
431
102
  <ContextMenuBox
432
103
  flexDirection="column"
@@ -462,7 +133,6 @@ export function Sidebar({
462
133
  >
463
134
  <box flexDirection="row" width={sidebarWidth} flexGrow={1} overflow="hidden">
464
135
  <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
465
- <SidebarTop contentWidth={contentWidth} />
466
136
  <box ref={bodyRef} flexDirection="column" flexGrow={1} overflow="hidden">
467
137
  {gitOnTop ? (
468
138
  <>
@@ -472,12 +142,12 @@ export function Sidebar({
472
142
  ) : null}
473
143
  <box
474
144
  flexDirection="column"
475
- flexGrow={tabsGrow}
145
+ flexGrow={listGrow}
476
146
  flexShrink={1}
477
147
  flexBasis={0}
478
148
  overflow="hidden"
479
149
  >
480
- <TabsBody onTabActivate={onTabActivate} contentWidth={contentWidth} />
150
+ <WorkspaceList contentWidth={contentWidth} />
481
151
  </box>
482
152
  {gitOnBottom ? (
483
153
  <>
@@ -486,7 +156,6 @@ export function Sidebar({
486
156
  </>
487
157
  ) : null}
488
158
  </box>
489
- {!gitEmbedded ? separator : null}
490
159
  </box>
491
160
  </box>
492
161
  </ContextMenuBox>
@@ -17,6 +17,10 @@ interface TabItemProps {
17
17
  inLayout?: boolean
18
18
  /** When set, this tab's worktree can be moved — adds a "Move worktree" entry. */
19
19
  moveWorktreeId?: string
20
+ /** 1-based position in the visible tab list — rendered as `[N]` and matches Leader+N. */
21
+ indexLabel?: string
22
+ /** Render the close × unconditionally instead of only on hover. */
23
+ alwaysShowClose?: boolean
20
24
  }
21
25
 
22
26
  function getStatusColor(status: TabSession['status']): string {
@@ -50,31 +54,34 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
50
54
  return inLayout ? t.textMuted : t.textMuted
51
55
  }
52
56
 
53
- function BusyIndicator() {
57
+ function BusyGlyph() {
54
58
  const t = useTheme()
55
59
  const frame = useBusySpinner()
56
60
  return (
57
61
  <text fg={t.primary} selectable={false}>
58
- {frame} working
62
+ {' '}
63
+ {frame}
59
64
  </text>
60
65
  )
61
66
  }
62
67
 
63
- function WaitingIndicator() {
68
+ function WaitingGlyph() {
64
69
  const t = useTheme()
65
70
  return (
66
71
  <text fg={t.warning} selectable={false}>
67
- ? waiting
72
+ {' '}
73
+ ?
68
74
  </text>
69
75
  )
70
76
  }
71
77
 
72
- function ActivityIndicator({ tab }: { tab: TabSession }) {
78
+ function ActivityGlyph({ tab }: { tab: TabSession }) {
73
79
  const t = useTheme()
74
80
  if (tab.status === 'error') {
75
81
  return (
76
82
  <text fg={t.error} selectable={false}>
77
- error
83
+ {' '}
84
+
78
85
  </text>
79
86
  )
80
87
  }
@@ -82,37 +89,48 @@ function ActivityIndicator({ tab }: { tab: TabSession }) {
82
89
  if (tab.status === 'disconnected') {
83
90
  return (
84
91
  <text fg={t.warning} selectable={false}>
85
- restore
92
+ {' '}
93
+
86
94
  </text>
87
95
  )
88
96
  }
89
97
 
90
98
  if (tab.activity === 'working') {
91
- return <BusyIndicator />
99
+ return <BusyGlyph />
92
100
  }
93
101
 
94
102
  if (tab.activity === 'waiting-input') {
95
- return <WaitingIndicator />
103
+ return <WaitingGlyph />
96
104
  }
97
105
 
98
106
  if (tab.activity === 'idle') {
99
107
  return (
100
108
  <text fg={t.success} selectable={false}>
101
- idle
109
+ {' '}
110
+
102
111
  </text>
103
112
  )
104
113
  }
105
114
 
106
115
  return (
107
116
  <text fg={getStatusColor(tab.status)} selectable={false}>
108
- {tab.status}
117
+ {' '}
118
+ ·
109
119
  </text>
110
120
  )
111
121
  }
112
122
 
113
- export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }: TabItemProps) {
123
+ export function TabItem({
124
+ active,
125
+ alwaysShowClose,
126
+ focused,
127
+ id,
128
+ indexLabel,
129
+ inLayout,
130
+ moveWorktreeId,
131
+ tab,
132
+ }: TabItemProps) {
114
133
  const t = useTheme()
115
- const label = tab.command.split(' ')[0]
116
134
  const isInLayout = inLayout ?? false
117
135
  const indicator = getIndicator(active, focused, isInLayout)
118
136
  const indicatorColor = getIndicatorColor(active, focused, isInLayout)
@@ -135,21 +153,19 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
135
153
  },
136
154
  ],
137
155
  [
138
- 'Move up',
156
+ 'Move left',
139
157
  () => {
140
158
  dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
141
159
  dispatchGlobal({ delta: -1, type: 'reorder-active-tab' })
142
160
  },
143
161
  ],
144
162
  [
145
- 'Move down',
163
+ 'Move right',
146
164
  () => {
147
165
  dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
148
166
  dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
149
167
  },
150
168
  ],
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
169
  ...(moveWorktreeId != null && moveWorktreeId !== ''
154
170
  ? [
155
171
  [
@@ -181,38 +197,31 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
181
197
  id={id}
182
198
  paddingLeft={1}
183
199
  paddingRight={1}
184
- paddingTop={0}
185
- paddingBottom={0}
186
- flexDirection="column"
187
- gap={0}
200
+ flexDirection="row"
201
+ alignItems="center"
188
202
  rightClickMenu={rightClickMenu}
189
203
  onMouseOver={handleMouseOver}
190
204
  onMouseOut={handleMouseOut}
191
205
  >
192
- <box flexDirection="row" alignItems="center">
193
- <text fg={indicatorColor} selectable={false}>
194
- {indicator}{' '}
206
+ <text fg={indicatorColor} selectable={false}>
207
+ {indicator}{' '}
208
+ </text>
209
+ {indexLabel != null && indexLabel !== '' ? (
210
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
211
+ {indexLabel}{' '}
195
212
  </text>
196
- <box flexGrow={1}>
197
- <text fg={active ? t.text : t.textMuted} selectable={false}>
198
- {tab.title}
213
+ ) : null}
214
+ <text fg={active ? t.text : t.textMuted} selectable={false} wrapMode="none">
215
+ {tab.title}
216
+ </text>
217
+ <ActivityGlyph tab={tab} />
218
+ {alwaysShowClose === true || hovered ? (
219
+ <box paddingLeft={1} onMouseDown={handleCloseMouseDown}>
220
+ <text fg={t.textMuted} selectable={false}>
221
+ ×
199
222
  </text>
200
223
  </box>
201
- {hovered ? (
202
- <box onMouseDown={handleCloseMouseDown}>
203
- <text fg={t.textMuted} selectable={false}>
204
- ×
205
- </text>
206
- </box>
207
- ) : null}
208
- </box>
209
- <box flexDirection="row">
210
- <text fg={t.textMuted} selectable={false}>
211
- {' '}
212
- {label}{' '}
213
- </text>
214
- <ActivityIndicator tab={tab} />
215
- </box>
224
+ ) : null}
216
225
  </ContextMenuBox>
217
226
  )
218
227
  }
@@ -1,63 +1,20 @@
1
1
  import type { ScrollBoxRenderable } from '@opentui/core'
2
2
 
3
- import { useEffect, useRef } from 'react'
3
+ import { useEffect } from 'react'
4
4
 
5
- import { getSidebarScrollTarget } from './sidebar-scroll'
6
-
7
- interface UseSidebarAutoScrollOptions {
5
+ interface Options {
8
6
  scrollRef: React.RefObject<ScrollBoxRenderable | null>
9
7
  visible: boolean
10
- activeTabId: string | null
11
- activeIndex: number
12
- tabCount: number
8
+ /** Full id of the active row to bring into view (workspace OR worktree). */
9
+ activeRowId: string | null
13
10
  }
14
11
 
15
- export function useSidebarAutoScroll({
16
- activeIndex,
17
- activeTabId,
18
- scrollRef,
19
- tabCount,
20
- visible,
21
- }: UseSidebarAutoScrollOptions): void {
22
- const previousActiveIndexRef = useRef(-1)
23
- const previousVisibilityRef = useRef(visible)
24
-
12
+ export function useSidebarAutoScroll({ activeRowId, scrollRef, visible }: Options): void {
25
13
  useEffect(() => {
26
- if (!visible) {
27
- previousVisibilityRef.current = false
28
- previousActiveIndexRef.current = activeIndex
29
- return
30
- }
31
-
14
+ if (!visible) return
15
+ if (activeRowId == null || activeRowId === '') return
32
16
  const scrollbox = scrollRef.current
33
- if (!scrollbox) {
34
- previousVisibilityRef.current = visible
35
- previousActiveIndexRef.current = activeIndex
36
- return
37
- }
38
-
39
- if (!previousVisibilityRef.current && activeTabId != null && activeTabId !== '') {
40
- scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
41
- previousVisibilityRef.current = true
42
- previousActiveIndexRef.current = activeIndex
43
- return
44
- }
45
-
46
- const scrollTarget = getSidebarScrollTarget({
47
- nextActiveIndex: activeIndex,
48
- previousActiveIndex: previousActiveIndexRef.current,
49
- tabCount,
50
- })
51
-
52
- if (scrollTarget === 'top') {
53
- scrollbox.scrollTo({ x: 0, y: 0 })
54
- } else if (scrollTarget === 'bottom') {
55
- scrollbox.scrollTo({ x: 0, y: scrollbox.scrollHeight })
56
- } else if (scrollTarget === 'active-item' && activeTabId != null && activeTabId !== '') {
57
- scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
58
- }
59
-
60
- previousVisibilityRef.current = visible
61
- previousActiveIndexRef.current = activeIndex
62
- }, [activeIndex, activeTabId, scrollRef, tabCount, visible])
17
+ if (!scrollbox) return
18
+ scrollbox.scrollChildIntoView(activeRowId)
19
+ }, [activeRowId, scrollRef, visible])
63
20
  }