@brimveyn/aimux 1.12.6 → 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 (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +29 -13
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/selection-scroll.ts +2 -2
  8. package/src/app-runtime/session-actions.ts +16 -4
  9. package/src/app-runtime/side-effects.ts +563 -85
  10. package/src/app-runtime/snippet-actions.ts +3 -3
  11. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  12. package/src/app-runtime/use-backend-runtime.ts +3 -3
  13. package/src/app-runtime/use-directory-search.ts +7 -5
  14. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  15. package/src/app-runtime/use-pane-size-report.ts +1 -1
  16. package/src/app-runtime/use-renderer-bindings.ts +6 -5
  17. package/src/app.tsx +12 -7
  18. package/src/auto-commit/headless-commands.ts +4 -6
  19. package/src/daemon/daemon.ts +21 -11
  20. package/src/daemon/runtime-paths.ts +1 -1
  21. package/src/daemon/session-manager.ts +1 -1
  22. package/src/daemon/session-registry.ts +10 -3
  23. package/src/diff-parser/parse-patch-files.ts +18 -18
  24. package/src/git/command-queue.ts +19 -3
  25. package/src/git/divergence.ts +46 -0
  26. package/src/git/git-diff.ts +12 -5
  27. package/src/git/git-poller.ts +33 -11
  28. package/src/git/git-status.ts +14 -3
  29. package/src/git/move-worktree.ts +96 -0
  30. package/src/git/use-repo-discovery.ts +2 -1
  31. package/src/git/worktree-divergence-poller.ts +59 -0
  32. package/src/git/worktree.ts +99 -0
  33. package/src/index.tsx +1 -1
  34. package/src/input/keymap/describe-bindings.ts +27 -9
  35. package/src/input/keymap/key-chord.ts +4 -4
  36. package/src/input/keymap/key-format.ts +2 -2
  37. package/src/input/keymap/sequence-resolver.ts +1 -1
  38. package/src/input/keymap/trie.ts +1 -1
  39. package/src/input/modes/bridge.ts +7 -0
  40. package/src/input/modes/transitions.ts +2 -1
  41. package/src/input/modes/types.ts +11 -1
  42. package/src/input/raw-input-handler.ts +1 -1
  43. package/src/input/terminal-text-extraction.ts +9 -5
  44. package/src/integrations/claude-syntax-overlay.ts +8 -8
  45. package/src/integrations/claude-theme-sync.ts +12 -12
  46. package/src/ipc/protocol.ts +3 -3
  47. package/src/platform/clipboard.ts +4 -2
  48. package/src/platform/worktree-deps.ts +22 -0
  49. package/src/platform/worktree-paths.ts +67 -0
  50. package/src/profile-paths.ts +3 -3
  51. package/src/pty/assistant-status-detection-loop.ts +2 -2
  52. package/src/pty/assistant-status-detector.ts +15 -6
  53. package/src/pty/command-registry.ts +2 -1
  54. package/src/pty/pty-manager.ts +1 -1
  55. package/src/services/ai-usage/adapters/claude.ts +5 -5
  56. package/src/services/ai-usage/adapters/codex.ts +8 -8
  57. package/src/services/ai-usage/cache.ts +2 -2
  58. package/src/services/ai-usage/pace.ts +3 -6
  59. package/src/services/ai-usage/provider.ts +1 -1
  60. package/src/session-backend/bootstrap.ts +2 -2
  61. package/src/session-backend/local-session-backend.ts +11 -11
  62. package/src/session-backend/remote-session-backend.ts +60 -45
  63. package/src/session-backend/types.ts +2 -2
  64. package/src/snippets/expand-variables.ts +7 -7
  65. package/src/snippets/run-shell-var.ts +1 -1
  66. package/src/snippets/trigger-detector.ts +1 -1
  67. package/src/state/ai-usage-store.ts +1 -1
  68. package/src/state/git-tree.ts +8 -4
  69. package/src/state/layout-tree.ts +8 -5
  70. package/src/state/reducers/git-mode-state.ts +44 -14
  71. package/src/state/reducers/git-panel-state.ts +38 -2
  72. package/src/state/reducers/modal-state.ts +355 -21
  73. package/src/state/reducers/session-state.ts +60 -1
  74. package/src/state/reducers/tab-state.ts +151 -85
  75. package/src/state/selectors.ts +6 -4
  76. package/src/state/session-catalog.ts +20 -5
  77. package/src/state/session-persistence.ts +8 -4
  78. package/src/state/session-worktrees.ts +227 -0
  79. package/src/state/store.ts +1 -0
  80. package/src/state/toast-store.ts +95 -0
  81. package/src/state/types.ts +92 -13
  82. package/src/state/validation.ts +25 -2
  83. package/src/terminal-manager/manager-client.ts +15 -13
  84. package/src/ui/components/git/diff-renderer/fold-strip.tsx +32 -24
  85. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  86. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  87. package/src/ui/components/git/diff-renderer/split-view.tsx +39 -11
  88. package/src/ui/components/git/diff-renderer/stacked-view.tsx +29 -7
  89. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +28 -11
  90. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +10 -8
  91. package/src/ui/components/git/git-panel.tsx +129 -83
  92. package/src/ui/components/git/git-view.tsx +96 -28
  93. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  94. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +14 -13
  95. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  96. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  97. package/src/ui/components/layout/session-bar.tsx +135 -117
  98. package/src/ui/components/layout/sidebar/sidebar.tsx +244 -82
  99. package/src/ui/components/layout/sidebar/tab-item.tsx +68 -41
  100. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  101. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  102. package/src/ui/components/layout/split-layout.tsx +27 -20
  103. package/src/ui/components/layout/terminal-pane.tsx +163 -120
  104. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  105. package/src/ui/components/modals/app/help-modal.tsx +29 -20
  106. package/src/ui/components/modals/git/git-commit-modal.tsx +17 -10
  107. package/src/ui/components/modals/sessions/create-session-modal.tsx +18 -10
  108. package/src/ui/components/modals/sessions/session-picker-modal.tsx +36 -28
  109. package/src/ui/components/modals/shared/form.tsx +6 -6
  110. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  111. package/src/ui/components/modals/shared/picker.tsx +71 -32
  112. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +50 -29
  113. package/src/ui/components/modals/tabs/new-tab-modal.tsx +209 -25
  114. package/src/ui/components/modals/themes/theme-picker-modal.tsx +24 -15
  115. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +111 -0
  116. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +14 -9
  117. package/src/ui/components/overlays/context-menu/context-menu-box.tsx +20 -11
  118. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +69 -34
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +23 -7
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +121 -38
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -1,12 +1,23 @@
1
- import {
2
- type BoxRenderable,
3
- type MouseEvent as OtuiMouseEvent,
4
- type ScrollBoxRenderable,
1
+ import type {
2
+ BoxRenderable,
3
+ MouseEvent as OtuiMouseEvent,
4
+ ScrollBoxRenderable,
5
5
  } from '@opentui/core'
6
- import { memo, useMemo, useRef } from 'react'
7
6
 
7
+ import { memo, type ReactNode, useCallback, useMemo, useRef } from 'react'
8
+
9
+ import type { BranchDivergence } from '../../../../state/types'
10
+
11
+ import { useWorktreeDivergencePolling } from '../../../../git/worktree-divergence-poller'
8
12
  import { useAppStore } from '../../../../state/app-store'
9
13
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
14
+ import {
15
+ getActiveWorktree,
16
+ getRenderedTabWorktreeId,
17
+ getSessionProjectPath,
18
+ getWorktreeColor,
19
+ orderTabsByWorktree,
20
+ } from '../../../../state/session-worktrees'
10
21
  import { getCurrentTheme, type ResolvedTuiTheme, useTheme } from '../../../theme'
11
22
  import { buildGitPaneContextMenu } from '../../git/pane/git-pane-context-menu'
12
23
  import { GitPaneWidget } from '../../git/pane/git-pane-widget'
@@ -32,6 +43,29 @@ const GUTTER_MIDDLE = '├'
32
43
  const GUTTER_END = '╰'
33
44
  const GUTTER_PAD = '│'
34
45
  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
+ }
35
69
 
36
70
  function getRowBackground({
37
71
  alternate,
@@ -51,12 +85,19 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
51
85
  const t = useTheme()
52
86
  const currentSessionId = useAppStore((s) => s.currentSessionId)
53
87
  const sessions = useAppStore((s) => s.sessions)
54
- const currentSession = currentSessionId
55
- ? sessions.find((s) => s.id === currentSessionId)
56
- : undefined
57
- const projectPath = currentSession?.projectPath
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)
58
94
  const branch = useSidebarBranch(projectPath)
59
95
 
96
+ const handleNewAssistant = useCallback((e: OtuiMouseEvent) => {
97
+ e.stopPropagation()
98
+ dispatchGlobal({ type: 'open-new-tab-modal' })
99
+ }, [])
100
+
60
101
  return (
61
102
  <box flexDirection="column" flexShrink={0} gap={0}>
62
103
  <text fg={t.text} selectable={false}>
@@ -65,14 +106,20 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
65
106
  <text fg={t.text} selectable={false}>
66
107
  {currentSession ? currentSession.name : 'No workspace selected'}
67
108
  </text>
68
- {branch ? (
109
+ {(branch != null && branch !== '') || activeWorktree ? (
69
110
  <box flexDirection="row">
70
111
  <text fg={t.text} selectable={false}>
71
112
  {'\u{e702}'}{' '}
72
113
  </text>
73
114
  <text fg={t.text} selectable={false}>
74
- {branch}
115
+ {branch ?? activeWorktree?.branch ?? activeWorktree?.name}
75
116
  </text>
117
+ {activeWorktree?.source === 'aimux-temp' ? (
118
+ <text fg={t.textMuted} selectable={false}>
119
+ {' '}
120
+ tmp
121
+ </text>
122
+ ) : null}
76
123
  </box>
77
124
  ) : null}
78
125
  <box
@@ -81,10 +128,7 @@ const SidebarTop = memo(function SidebarTop({ contentWidth }: { contentWidth: nu
81
128
  backgroundColor={t.backgroundPanel}
82
129
  justifyContent="center"
83
130
  marginTop={1}
84
- onMouseDown={(e) => {
85
- e.stopPropagation()
86
- dispatchGlobal({ type: 'open-new-tab-modal' })
87
- }}
131
+ onMouseDown={handleNewAssistant}
88
132
  >
89
133
  <text fg={t.text} selectable={false}>
90
134
  + New assistant
@@ -112,27 +156,81 @@ function renderGroupGutter(isGroupStart: boolean, isGroupMiddle: boolean, isGrou
112
156
  )
113
157
  }
114
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
+
115
184
  interface TabsBodyProps {
116
185
  onTabActivate?: (tabId: string) => void
186
+ contentWidth: number
117
187
  }
118
188
 
119
- const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
189
+ const TabsBody = memo(function TabsBody({ contentWidth, onTabActivate }: TabsBodyProps) {
120
190
  const t = useTheme()
121
191
  const tabs = useAppStore((s) => s.tabs)
122
192
  const activeTabId = useAppStore((s) => s.activeTabId)
123
193
  const focusMode = useAppStore((s) => s.focusMode)
124
194
  const layoutTrees = useAppStore((s) => s.layoutTrees)
125
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])
126
221
 
127
222
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
128
- const activeIndex = tabs.findIndex((tab) => tab.id === activeTabId)
129
- const tabGroupInfo = useMemo(() => buildTabGroupInfo(layoutTrees, tabs), [layoutTrees, tabs])
223
+ const activeIndex = groupedTabs.findIndex((tab) => tab.id === activeTabId)
224
+ const tabGroupInfo = useMemo(
225
+ () => buildTabGroupInfo(layoutTrees, groupedTabs),
226
+ [layoutTrees, groupedTabs]
227
+ )
130
228
 
131
229
  useSidebarAutoScroll({
132
230
  activeIndex,
133
231
  activeTabId,
134
232
  scrollRef,
135
- tabCount: tabs.length,
233
+ tabCount: groupedTabs.length,
136
234
  visible: sidebarVisible,
137
235
  })
138
236
 
@@ -143,7 +241,7 @@ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
143
241
  flexGrow={1}
144
242
  scrollY
145
243
  viewportCulling
146
- contentOptions={{ flexDirection: 'column', gap: 0 }}
244
+ contentOptions={COLUMN_CONTENT_OPTIONS}
147
245
  >
148
246
  {tabs.length === 0 ? (
149
247
  <box paddingTop={1}>
@@ -152,36 +250,88 @@ const TabsBody = memo(function TabsBody({ onTabActivate }: TabsBodyProps) {
152
250
  </text>
153
251
  </box>
154
252
  ) : (
155
- tabs.map((tab, index) => {
253
+ groupedTabs.map((tab, index) => {
156
254
  const isActive = tab.id === activeTabId
157
255
  const alternate = index % 2 === 1
158
256
  const info = tabGroupInfo.get(tab.id)
159
- const inLayout = !!info?.inLayout
257
+ const inLayout = !!(info?.inLayout === true)
160
258
  const inGroup = info ? index >= info.groupStart && index <= info.groupEnd : false
161
259
  const isGroupStart = info ? index === info.groupStart : false
162
260
  const isGroupEnd = info ? index === info.groupEnd : false
163
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)
164
296
 
165
297
  return (
166
- <box
167
- key={tab.id}
168
- backgroundColor={getRowBackground({ alternate, isActive, t })}
169
- flexDirection="row"
170
- onMouseDown={(event) => {
171
- event.stopPropagation()
172
- onTabActivate?.(tab.id)
173
- }}
174
- >
175
- {inGroup ? renderGroupGutter(isGroupStart, isGroupMiddle, isGroupEnd) : null}
176
- <box flexGrow={1}>
177
- <TabItem
178
- id={`sidebar-tab-${tab.id}`}
179
- tab={tab}
180
- active={isActive}
181
- focused={focusMode === 'navigation'}
182
- inLayout={inLayout}
183
- />
184
- </box>
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>
185
335
  </box>
186
336
  )
187
337
  })
@@ -203,6 +353,53 @@ export function Sidebar({
203
353
  const gitPane = useAppStore((s) => s.gitPane)
204
354
  const focusMode = useAppStore((s) => s.focusMode)
205
355
  const bodyRef = useRef<BoxRenderable | null>(null)
356
+ useWorktreeDivergencePolling(sidebarVisible)
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
+ )
206
403
 
207
404
  if (!sidebarVisible) {
208
405
  return null
@@ -243,21 +440,7 @@ export function Sidebar({
243
440
  </ContextMenuBox>
244
441
  ) : null
245
442
  const embeddedHandle = gitEmbedded ? (
246
- <box
247
- minHeight={1}
248
- flexShrink={0}
249
- onMouseDown={(event) => {
250
- const body = bodyRef.current
251
- if (!body) return
252
- event.preventDefault()
253
- event.stopPropagation()
254
- onEmbeddedGitResizeStart?.({
255
- containerStart: body.y,
256
- position: gitPane.position === 'top' ? 'top' : 'bottom',
257
- totalSize: Math.max(1, body.height),
258
- })
259
- }}
260
- >
443
+ <box minHeight={1} flexShrink={0} onMouseDown={handleEmbeddedHandleMouseDown}>
261
444
  <text fg={t.border} selectable={false}>
262
445
  {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
263
446
  </text>
@@ -272,31 +455,10 @@ export function Sidebar({
272
455
  backgroundColor={sidebarBg}
273
456
  gap={0}
274
457
  overflow="hidden"
275
- rightClickMenu={[
276
- ['Hide sidebar', () => dispatchGlobal({ type: 'toggle-sidebar' })],
277
- ['Toggle git pane', () => dispatchGlobal({ type: 'toggle-git-pane' })],
278
- [
279
- 'Toggle diff mode',
280
- () => {
281
- dispatchGlobal({ type: 'enter-git-mode' })
282
- dispatchGlobal({ type: 'git-mode-toggle-diff-view' })
283
- },
284
- ],
285
- ]}
286
- onMouseDown={() => {
287
- if (focusMode === 'terminal-input') {
288
- dispatchGlobal({ focusMode: 'navigation', type: 'set-focus-mode' })
289
- }
290
- }}
291
- onMouseDrag={(event) => {
292
- if (onResizeDrag?.(event)) {
293
- event.preventDefault()
294
- event.stopPropagation()
295
- }
296
- }}
297
- onMouseUp={() => {
298
- onResizeDragEnd?.()
299
- }}
458
+ rightClickMenu={sidebarMenu}
459
+ onMouseDown={handleSidebarMouseDown}
460
+ onMouseDrag={handleSidebarMouseDrag}
461
+ onMouseUp={handleSidebarMouseUp}
300
462
  >
301
463
  <box flexDirection="row" width={sidebarWidth} flexGrow={1} overflow="hidden">
302
464
  <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
@@ -315,7 +477,7 @@ export function Sidebar({
315
477
  flexBasis={0}
316
478
  overflow="hidden"
317
479
  >
318
- <TabsBody onTabActivate={onTabActivate} />
480
+ <TabsBody onTabActivate={onTabActivate} contentWidth={contentWidth} />
319
481
  </box>
320
482
  {gitOnBottom ? (
321
483
  <>
@@ -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
 
@@ -13,6 +15,8 @@ interface TabItemProps {
13
15
  active: boolean
14
16
  focused: boolean
15
17
  inLayout?: boolean
18
+ /** When set, this tab's worktree can be moved — adds a "Move worktree" entry. */
19
+ moveWorktreeId?: string
16
20
  }
17
21
 
18
22
  function getStatusColor(status: TabSession['status']): string {
@@ -106,7 +110,7 @@ function ActivityIndicator({ tab }: { tab: TabSession }) {
106
110
  )
107
111
  }
108
112
 
109
- export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
113
+ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }: TabItemProps) {
110
114
  const t = useTheme()
111
115
  const label = tab.command.split(' ')[0]
112
116
  const isInLayout = inLayout ?? false
@@ -114,6 +118,64 @@ export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
114
118
  const indicatorColor = getIndicatorColor(active, focused, isInLayout)
115
119
  const [hovered, setHovered] = useState(false)
116
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
+
117
179
  return (
118
180
  <ContextMenuBox
119
181
  id={id}
@@ -123,38 +185,9 @@ export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
123
185
  paddingBottom={0}
124
186
  flexDirection="column"
125
187
  gap={0}
126
- rightClickMenu={[
127
- [
128
- 'Rename',
129
- () => {
130
- dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
131
- dispatchGlobal({ type: 'open-rename-tab-modal' })
132
- },
133
- ],
134
- [
135
- 'Close',
136
- () => {
137
- dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
138
- runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
139
- },
140
- ],
141
- [
142
- 'Move up',
143
- () => {
144
- dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
145
- dispatchGlobal({ delta: -1, type: 'reorder-active-tab' })
146
- },
147
- ],
148
- [
149
- 'Move down',
150
- () => {
151
- dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
152
- dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
153
- },
154
- ],
155
- ]}
156
- onMouseOver={() => setHovered(true)}
157
- onMouseOut={() => setHovered(false)}
188
+ rightClickMenu={rightClickMenu}
189
+ onMouseOver={handleMouseOver}
190
+ onMouseOut={handleMouseOut}
158
191
  >
159
192
  <box flexDirection="row" alignItems="center">
160
193
  <text fg={indicatorColor} selectable={false}>
@@ -166,13 +199,7 @@ export function TabItem({ active, focused, id, inLayout, tab }: TabItemProps) {
166
199
  </text>
167
200
  </box>
168
201
  {hovered ? (
169
- <box
170
- onMouseDown={(event) => {
171
- event.stopPropagation()
172
- dispatchGlobal({ tabId: tab.id, type: 'close-tab' })
173
- runSideEffectGlobal({ tabId: tab.id, type: 'close-tab' })
174
- }}
175
- >
202
+ <box onMouseDown={handleCloseMouseDown}>
176
203
  <text fg={t.textMuted} selectable={false}>
177
204
  ×
178
205
  </text>
@@ -36,7 +36,7 @@ export function useSidebarAutoScroll({
36
36
  return
37
37
  }
38
38
 
39
- if (!previousVisibilityRef.current && activeTabId) {
39
+ if (!previousVisibilityRef.current && activeTabId != null && activeTabId !== '') {
40
40
  scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
41
41
  previousVisibilityRef.current = true
42
42
  previousActiveIndexRef.current = activeIndex
@@ -53,7 +53,7 @@ export function useSidebarAutoScroll({
53
53
  scrollbox.scrollTo({ x: 0, y: 0 })
54
54
  } else if (scrollTarget === 'bottom') {
55
55
  scrollbox.scrollTo({ x: 0, y: scrollbox.scrollHeight })
56
- } else if (scrollTarget === 'active-item' && activeTabId) {
56
+ } else if (scrollTarget === 'active-item' && activeTabId != null && activeTabId !== '') {
57
57
  scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
58
58
  }
59
59
 
@@ -8,7 +8,7 @@ export function useSidebarBranch(projectPath: string | undefined): string | null
8
8
  const [branch, setBranch] = useState<string | null>(null)
9
9
 
10
10
  useEffect(() => {
11
- if (!projectPath) {
11
+ if (!(projectPath != null && projectPath !== '')) {
12
12
  setBranch(null)
13
13
  return
14
14
  }
@@ -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) 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