@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
@@ -0,0 +1,30 @@
1
+ import type { ScrollBoxRenderable } from '@opentui/core'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ interface Options {
6
+ scrollRef: React.RefObject<ScrollBoxRenderable | null>
7
+ visible: boolean
8
+ activeTabId: string | null
9
+ idPrefix: string
10
+ }
11
+
12
+ /**
13
+ * Scroll the active tab into view whenever the active tab id changes (and on
14
+ * first reveal). The top bar is a single horizontal row, so this is just a
15
+ * thin wrapper around `scrollChildIntoView` — no wrap-around/edge heuristics.
16
+ */
17
+ export function useTopTabBarAutoScroll({
18
+ activeTabId,
19
+ idPrefix,
20
+ scrollRef,
21
+ visible,
22
+ }: Options): void {
23
+ useEffect(() => {
24
+ if (!visible) return
25
+ if (activeTabId == null || activeTabId === '') return
26
+ const scrollbox = scrollRef.current
27
+ if (!scrollbox) return
28
+ scrollbox.scrollChildIntoView(`${idPrefix}${activeTabId}`)
29
+ }, [activeTabId, idPrefix, scrollRef, visible])
30
+ }
@@ -0,0 +1,398 @@
1
+ import type {
2
+ BoxRenderable,
3
+ MouseEvent as OtuiMouseEvent,
4
+ ScrollBoxRenderable,
5
+ } from '@opentui/core'
6
+
7
+ import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
8
+
9
+ import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../state/types'
10
+
11
+ import { useAppStore } from '../../../../state/app-store'
12
+ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
13
+ import { formatDivergence, getWorktreeColor } from '../../../../state/session-worktrees'
14
+ // eslint-disable-next-line no-duplicate-imports
15
+ import { IDLE_SESSION_STATUS } from '../../../../state/types'
16
+ import { useBusySpinner } from '../../../hooks/use-busy-spinner'
17
+ import { moveIdToIdPosition, orderSessionsForDisplay } from '../../../session-ordering'
18
+ import { useTheme } from '../../../theme'
19
+ import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
20
+ import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
21
+ import { WorktreeRow } from './worktree-row'
22
+
23
+ interface WorkspaceListProps {
24
+ contentWidth: number
25
+ }
26
+
27
+ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
28
+
29
+ function arraysEqual(a: string[], b: string[]): boolean {
30
+ if (a.length !== b.length) return false
31
+ for (let i = 0; i < a.length; i++) {
32
+ if (a[i] !== b[i]) return false
33
+ }
34
+ return true
35
+ }
36
+
37
+ function truncate(label: string, max: number): string {
38
+ if (max <= 0) return ''
39
+ if (label.length <= max) return label
40
+ if (max === 1) return '…'
41
+ return `${label.slice(0, max - 1)}…`
42
+ }
43
+
44
+ export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
45
+ const t = useTheme()
46
+ const sessions = useAppStore((s) => s.sessions)
47
+ const currentSessionId = useAppStore((s) => s.currentSessionId)
48
+ const statusMap = useAppStore((s) => s.sessionStatuses)
49
+
50
+ const [draggingId, setDraggingId] = useState<string | null>(null)
51
+ const [dragOrder, setDragOrder] = useState<string[] | null>(null)
52
+ const lastSwapWithRef = useRef<string | null>(null)
53
+ const rowRefs = useRef(new Map<string, BoxRenderable>())
54
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
55
+
56
+ const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
57
+ const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
58
+
59
+ const currentSession = useMemo(
60
+ () =>
61
+ currentSessionId != null && currentSessionId !== ''
62
+ ? sessions.find((s) => s.id === currentSessionId)
63
+ : undefined,
64
+ [currentSessionId, sessions]
65
+ )
66
+ // The active row can be either a worktree row OR the workspace row
67
+ // (when the primary worktree is active). Both must scroll into view —
68
+ // otherwise the cursor visually "disappears" off-screen when crossing
69
+ // a workspace boundary on a key press.
70
+ const currentWorktrees = currentSession?.worktrees ?? []
71
+ const currentPrimary = currentWorktrees.find((w) => w.source === 'primary') ?? currentWorktrees[0]
72
+ const rawActiveWorktreeId = currentSession?.activeWorktreeId
73
+ const activeOnNonPrimary =
74
+ rawActiveWorktreeId != null &&
75
+ rawActiveWorktreeId !== '' &&
76
+ rawActiveWorktreeId !== currentPrimary?.id
77
+ let activeRowId: string | null = null
78
+ if (activeOnNonPrimary) {
79
+ activeRowId = `sidebar-wt-${rawActiveWorktreeId}`
80
+ } else if (currentSessionId != null && currentSessionId !== '') {
81
+ activeRowId = `sidebar-ws-${currentSessionId}`
82
+ }
83
+
84
+ useSidebarAutoScroll({
85
+ activeRowId,
86
+ scrollRef,
87
+ visible: true,
88
+ })
89
+
90
+ const setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
91
+ if (ref) rowRefs.current.set(id, ref)
92
+ else rowRefs.current.delete(id)
93
+ }, [])
94
+
95
+ const findRowAtY = useCallback((y: number): string | null => {
96
+ for (const [id, ref] of rowRefs.current) {
97
+ if (y >= ref.y && y < ref.y + ref.height) return id
98
+ }
99
+ return null
100
+ }, [])
101
+
102
+ const handleRowDragStart = useCallback(
103
+ (id: string) => {
104
+ setDraggingId(id)
105
+ setDragOrder(baselineOrder)
106
+ lastSwapWithRef.current = null
107
+ },
108
+ [baselineOrder]
109
+ )
110
+
111
+ const handleRowDrag = useCallback(
112
+ (event: OtuiMouseEvent) => {
113
+ if (!(draggingId != null && draggingId !== '')) return
114
+ const hit = findRowAtY(event.y)
115
+ if (hit === null) {
116
+ lastSwapWithRef.current = null
117
+ return
118
+ }
119
+ if (hit === draggingId) {
120
+ lastSwapWithRef.current = null
121
+ return
122
+ }
123
+ if (hit === lastSwapWithRef.current) return
124
+ setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
125
+ lastSwapWithRef.current = hit
126
+ },
127
+ [draggingId, findRowAtY]
128
+ )
129
+
130
+ const commitDrop = useCallback(() => {
131
+ const source = draggingId
132
+ const finalOrder = dragOrder
133
+ setDraggingId(null)
134
+ setDragOrder(null)
135
+ lastSwapWithRef.current = null
136
+
137
+ if (source == null || source === '' || !finalOrder) return
138
+
139
+ const changed = !arraysEqual(finalOrder, baselineOrder)
140
+ if (changed) {
141
+ dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
142
+ return
143
+ }
144
+
145
+ const idx = baselineOrder.indexOf(source)
146
+ if (idx >= 0) {
147
+ runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
148
+ }
149
+ }, [baselineOrder, dragOrder, draggingId])
150
+
151
+ const cancelDrag = useCallback(() => {
152
+ setDraggingId(null)
153
+ setDragOrder(null)
154
+ lastSwapWithRef.current = null
155
+ }, [])
156
+
157
+ const handleNewSession = useCallback((e: OtuiMouseEvent) => {
158
+ e.stopPropagation()
159
+ e.preventDefault()
160
+ dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
161
+ }, [])
162
+
163
+ const visibleSessions =
164
+ dragOrder !== null
165
+ ? dragOrder
166
+ .map((id) => ordered.find((s) => s.id === id))
167
+ .filter((s): s is SessionRecord => !!s)
168
+ : ordered
169
+
170
+ return (
171
+ <box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
172
+ <scrollbox
173
+ ref={scrollRef}
174
+ scrollY
175
+ flexGrow={1}
176
+ flexShrink={1}
177
+ contentOptions={COLUMN_CONTENT_OPTIONS}
178
+ >
179
+ {(() => {
180
+ // Build a single flat list of items — workspace rows interleaved
181
+ // with their non-primary worktrees. One map, one React keypath per
182
+ // visible row; transitions are a single atomic reconciliation.
183
+ const rows: ReactNode[] = []
184
+ for (const [visibleIdx, session] of visibleSessions.entries()) {
185
+ const sessionIndex = baselineOrder.indexOf(session.id) + 1
186
+ const isCurrentSession = session.id === currentSessionId
187
+ const worktrees = session.worktrees ?? []
188
+ const primaryWorktree = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
189
+ const extraWorktrees = worktrees.filter((w) => w.id !== primaryWorktree?.id)
190
+ const workspaceIsActiveItem =
191
+ isCurrentSession &&
192
+ (session.activeWorktreeId == null ||
193
+ session.activeWorktreeId === '' ||
194
+ session.activeWorktreeId === primaryWorktree?.id)
195
+ rows.push(
196
+ <WorkspaceRow
197
+ key={`ws:${session.id}`}
198
+ session={session}
199
+ isActiveItem={workspaceIsActiveItem}
200
+ inCurrentGroup={isCurrentSession}
201
+ primaryWorktree={primaryWorktree}
202
+ status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
203
+ dragging={draggingId === session.id}
204
+ contentWidth={contentWidth}
205
+ marginTop={visibleIdx > 0 ? 1 : 0}
206
+ setRowRef={setRowRef}
207
+ onDragStart={handleRowDragStart}
208
+ onDrag={handleRowDrag}
209
+ onDrop={commitDrop}
210
+ onDragCancel={cancelDrag}
211
+ />
212
+ )
213
+ for (const worktree of extraWorktrees) {
214
+ rows.push(
215
+ <WorktreeRow
216
+ key={`wt:${worktree.id}`}
217
+ session={session}
218
+ worktree={worktree}
219
+ sessionIndex={sessionIndex}
220
+ isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
221
+ inCurrentGroup={isCurrentSession}
222
+ />
223
+ )
224
+ }
225
+ }
226
+ return rows
227
+ })()}
228
+ </scrollbox>
229
+ <box
230
+ flexDirection="row"
231
+ flexShrink={0}
232
+ marginTop={1}
233
+ backgroundColor={t.backgroundPanel}
234
+ justifyContent="center"
235
+ onMouseDown={handleNewSession}
236
+ >
237
+ <text fg={t.text} selectable={false}>
238
+ + New workspace
239
+ </text>
240
+ </box>
241
+ </box>
242
+ )
243
+ }
244
+
245
+ interface WorkspaceRowProps {
246
+ session: SessionRecord
247
+ /** True when this row is the active cursor item (workspace's primary active). */
248
+ isActiveItem: boolean
249
+ /** True when this row belongs to the current workspace (selection scope). */
250
+ inCurrentGroup: boolean
251
+ /** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
252
+ primaryWorktree: WorktreeRecord | undefined
253
+ status: SessionStatus
254
+ dragging: boolean
255
+ contentWidth: number
256
+ /** Vertical spacing above this row — used to separate workspace blocks. */
257
+ marginTop: number
258
+ setRowRef: (id: string, ref: BoxRenderable | null) => void
259
+ onDragStart: (id: string) => void
260
+ onDrag: (event: OtuiMouseEvent) => void
261
+ onDrop: () => void
262
+ onDragCancel: () => void
263
+ }
264
+
265
+ const WorkspaceRow = memo(function WorkspaceRow({
266
+ contentWidth,
267
+ dragging,
268
+ inCurrentGroup,
269
+ isActiveItem,
270
+ marginTop,
271
+ onDrag,
272
+ onDragCancel,
273
+ onDragStart,
274
+ onDrop,
275
+ primaryWorktree,
276
+ session,
277
+ setRowRef,
278
+ status,
279
+ }: WorkspaceRowProps) {
280
+ const t = useTheme()
281
+ const showSpinner = status.working
282
+ const showWaiting = status.waiting
283
+ const spinner = useBusySpinner(showSpinner)
284
+ let bgColor: string | undefined
285
+ if (dragging || isActiveItem) {
286
+ bgColor = t.backgroundElement
287
+ } else if (inCurrentGroup) {
288
+ bgColor = t.backgroundPanel
289
+ }
290
+ const workingColor = t.primary
291
+ const waitingColor = t.warning
292
+ const divergence = useAppStore((s) =>
293
+ primaryWorktree ? s.worktreeDivergence[primaryWorktree.id] : undefined
294
+ )
295
+
296
+ const handleRef = useCallback(
297
+ (r: BoxRenderable | null) => setRowRef(session.id, r),
298
+ [setRowRef, session.id]
299
+ )
300
+ const handleMouseDown = useCallback(
301
+ (e: OtuiMouseEvent) => {
302
+ e.preventDefault()
303
+ e.stopPropagation()
304
+ onDragStart(session.id)
305
+ },
306
+ [onDragStart, session.id]
307
+ )
308
+ const handleMouseUp = useCallback(
309
+ (e: OtuiMouseEvent) => {
310
+ e.preventDefault()
311
+ onDrop()
312
+ },
313
+ [onDrop]
314
+ )
315
+ const rightClickMenu = useMemo<[string, () => void][]>(
316
+ () => [
317
+ [
318
+ 'Rename workspace',
319
+ () =>
320
+ dispatchGlobal({
321
+ initialName: session.name,
322
+ returnToSessionPicker: false,
323
+ sessionTargetId: session.id,
324
+ type: 'open-session-name-modal',
325
+ }),
326
+ ],
327
+ [
328
+ 'Delete workspace',
329
+ () => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
330
+ ],
331
+ ],
332
+ [session.id, session.name]
333
+ )
334
+
335
+ // Color of the left vertical bar — the workspace's stable accent (derived
336
+ // from its primary worktree id). Falls back to a tint for unhydrated sessions.
337
+ const barColor =
338
+ primaryWorktree != null
339
+ ? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
340
+ : t.textMuted
341
+ // Working/waiting indicator overrides the colored bar — the user needs to
342
+ // see assistant activity from across the room.
343
+ let leadingGlyph = '▍'
344
+ let leadingColor = barColor
345
+ if (showWaiting) {
346
+ leadingGlyph = '?'
347
+ leadingColor = waitingColor
348
+ } else if (showSpinner) {
349
+ leadingGlyph = spinner
350
+ leadingColor = workingColor
351
+ }
352
+
353
+ const branchText = primaryWorktree?.branch ?? ''
354
+ const divergenceText = formatDivergence(divergence)
355
+ const showBranch = branchText !== ''
356
+ const nameLabel = truncate(session.name, Math.max(0, contentWidth - 4))
357
+ const branchLabel = truncate(
358
+ branchText,
359
+ Math.max(0, contentWidth - 5 - (divergenceText.length + 1))
360
+ )
361
+
362
+ return (
363
+ <ContextMenuBox
364
+ ref={handleRef}
365
+ id={`sidebar-ws-${session.id}`}
366
+ flexDirection="column"
367
+ flexShrink={0}
368
+ marginTop={marginTop}
369
+ paddingLeft={1}
370
+ paddingRight={1}
371
+ backgroundColor={bgColor}
372
+ rightClickMenu={rightClickMenu}
373
+ onMouseDown={handleMouseDown}
374
+ onMouseDrag={onDrag}
375
+ onMouseUp={handleMouseUp}
376
+ onMouseDragEnd={onDragCancel}
377
+ >
378
+ <box flexDirection="row" alignItems="center">
379
+ <text fg={leadingColor} selectable={false} wrapMode="none">
380
+ {leadingGlyph}
381
+ </text>
382
+ <text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
383
+ {' '}
384
+ {nameLabel}
385
+ </text>
386
+ </box>
387
+ {showBranch ? (
388
+ <box flexDirection="row">
389
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
390
+ {' '}
391
+ {'\u{e702}'} {branchLabel}
392
+ {divergenceText !== '' ? ` ${divergenceText}` : ''}
393
+ </text>
394
+ </box>
395
+ ) : null}
396
+ </ContextMenuBox>
397
+ )
398
+ })
@@ -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>,