@brimveyn/aimux 1.14.0 → 1.14.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 (35) 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 +2 -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/status-bar.tsx +168 -33
  26. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  27. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  28. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  29. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +22 -93
  30. package/src/ui/root.tsx +68 -63
  31. package/src/ui/status-bar-model.ts +36 -37
  32. package/src/ui/components/layout/session-bar.tsx +0 -296
  33. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  34. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  35. 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>,
@@ -1,76 +1,211 @@
1
+ import {
2
+ getStatusBarSeparator,
3
+ type ResolvedTuiTheme,
4
+ type StatusBarSeparator,
5
+ } from '@brimveyn/aimux-config'
6
+
1
7
  import type { AppState } from '../../../state/types'
2
8
 
3
9
  import { version as APP_VERSION } from '../../../../package.json'
10
+ import { useAIUsageStore } from '../../../state/ai-usage-store'
4
11
  import { useAppStore } from '../../../state/app-store'
5
12
  import { useKeymap } from '../../keymap-context'
6
- import { getStatusBarModel } from '../../status-bar-model'
7
- import { getCurrentTheme, useTheme } from '../../theme'
13
+ import { getStatusBarModel, type IdentitySegment } from '../../status-bar-model'
14
+ import { useTheme } from '../../theme'
8
15
  import { AIUsageIndicator } from '../overlays/ai-usage/ai-usage-indicator'
9
16
 
10
- function getModeColor(focusMode: AppState['focusMode']): string {
11
- const t = getCurrentTheme()
17
+ // Powerline-style separator glyph pairs.
18
+ // `right` is rendered between left-anchored tiles (A→B, B→filler).
19
+ // `left` is rendered between right-anchored tiles (filler→Y, X→Y).
20
+ // `none` uses empty strings so bare background transitions remain visible.
21
+ const SEPARATOR_GLYPHS: Record<StatusBarSeparator, { left: string; right: string }> = {
22
+ arrow: { left: '\u{E0B2}', right: '\u{E0B0}' },
23
+ flame: { left: '\u{E0C2}', right: '\u{E0C0}' },
24
+ none: { left: '', right: '' },
25
+ round: { left: '\u{E0B6}', right: '\u{E0B4}' },
26
+ slant: { left: '\u{E0BA}', right: '\u{E0BC}' },
27
+ }
28
+
29
+ // Mode label is padded to a fixed width so the A tile never resizes
30
+ // when switching modes — keeps the rest of the bar visually stable.
31
+ const MODE_LABEL_WIDTH = 6
32
+ // A tile width = padded label (6) + paddingLeft (1) + paddingRight (1).
33
+ // Row 2 indents past A + separator glyph + B's paddingLeft so the
34
+ // hints line up with the start of B's content.
35
+ const ROW2_INDENT = MODE_LABEL_WIDTH + 2 + 1 + 1
36
+
37
+ function getModeLabel(focusMode: AppState['focusMode']): string {
12
38
  switch (focusMode) {
13
39
  case 'terminal-input':
14
- return t.primary
40
+ return 'INSERT'
15
41
  case 'modal':
42
+ return 'MODAL'
16
43
  case 'command-edit':
17
- return t.warning
44
+ return 'EDIT'
18
45
  case 'git':
19
- return t.success
46
+ return 'GIT'
20
47
  case 'navigation':
21
48
  default:
22
- return t.text
49
+ return 'NORMAL'
23
50
  }
24
51
  }
25
52
 
26
- function getModeLabel(focusMode: AppState['focusMode']): string {
53
+ function getModeBadge(focusMode: AppState['focusMode']): string {
54
+ const label = getModeLabel(focusMode)
55
+ const totalPad = Math.max(0, MODE_LABEL_WIDTH - label.length)
56
+ const left = Math.floor(totalPad / 2)
57
+ const right = totalPad - left
58
+ return ' '.repeat(left) + label + ' '.repeat(right)
59
+ }
60
+
61
+ function getModeColor(focusMode: AppState['focusMode'], t: ResolvedTuiTheme): string {
27
62
  switch (focusMode) {
28
63
  case 'terminal-input':
29
- return 'input'
64
+ return t.primary
30
65
  case 'modal':
31
- return 'modal'
32
66
  case 'command-edit':
33
- return 'edit'
67
+ return t.warning
34
68
  case 'git':
35
- return 'git'
69
+ return t.success
36
70
  case 'navigation':
37
71
  default:
38
- return 'nav'
72
+ return t.text
39
73
  }
40
74
  }
41
75
 
76
+ function composeAmbient(right: string, help: string): string {
77
+ if (right === '' && help === '') return ''
78
+ if (right === '') return help
79
+ if (help === '') return right
80
+ return `${right} · ${help}`
81
+ }
82
+
83
+ function Segments({ segments, t }: { segments: IdentitySegment[]; t: ResolvedTuiTheme }) {
84
+ return (
85
+ <>
86
+ {segments.map((seg) => (
87
+ <text
88
+ key={seg.id}
89
+ fg={seg.tone === 'primary' ? t.text : t.textMuted}
90
+ wrapMode="none"
91
+ selectable={false}
92
+ >
93
+ {seg.text}
94
+ </text>
95
+ ))}
96
+ </>
97
+ )
98
+ }
99
+
100
+ function Separator({ bg, fg, glyph }: { bg: string; fg: string; glyph: string }) {
101
+ if (glyph === '') return null
102
+ return (
103
+ <box backgroundColor={bg}>
104
+ <text fg={fg} selectable={false}>
105
+ {glyph}
106
+ </text>
107
+ </box>
108
+ )
109
+ }
110
+
42
111
  export function StatusBar() {
43
112
  const t = useTheme()
44
- const headerBg = t.backgroundPanel
45
113
  const state = useAppStore((s) => s)
46
- const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
47
114
  const config = useKeymap()
48
- const model = getStatusBarModel(state, activeTab, config)
115
+ const model = getStatusBarModel(state, config)
116
+ const modeColor = getModeColor(state.focusMode, t)
117
+ const ambient = composeAmbient(model.right, model.help)
118
+ const aiEnabled = useAIUsageStore((s) => s.enabled)
119
+
120
+ const glyphs = SEPARATOR_GLYPHS[getStatusBarSeparator()]
121
+
122
+ const tileB = t.backgroundElement
123
+ const tileFiller = t.backgroundPanel
124
+ const tileX = t.backgroundElement
125
+ const tileY = modeColor
126
+
127
+ const hasB = model.sessionSegments.length > 0
128
+ const hasX = aiEnabled
49
129
 
50
130
  return (
51
131
  <box
52
132
  height={2}
53
133
  flexShrink={0}
54
134
  overflow="hidden"
55
- paddingLeft={1}
56
- paddingRight={1}
57
- paddingTop={0}
58
- paddingBottom={0}
59
135
  flexDirection="column"
60
- backgroundColor={headerBg}
136
+ backgroundColor={tileFiller}
61
137
  >
62
- <box width="100%" flexDirection="row">
63
- <text fg={getModeColor(state.focusMode)}>[{getModeLabel(state.focusMode)}]</text>
64
- <text> </text>
65
- <text fg={t.text}>{model.left}</text>
66
- </box>
67
- <box width="100%" flexDirection="row" justifyContent="space-between">
68
- <text fg={t.textMuted}>{model.right}</text>
69
- <box flexDirection="row" gap={2}>
70
- {model.help ? <text fg={t.textMuted}>{model.help}</text> : null}
71
- <AIUsageIndicator />
72
- <text fg={t.textMuted}>v{APP_VERSION}</text>
138
+ {/* Row 1 — lualine tiles */}
139
+ <box height={1} flexShrink={0} flexDirection="row" overflow="hidden">
140
+ {/* A: mode */}
141
+ <box backgroundColor={modeColor} paddingLeft={1} paddingRight={1}>
142
+ <text fg={t.background} selectable={false}>
143
+ {getModeBadge(state.focusMode)}
144
+ </text>
73
145
  </box>
146
+
147
+ {/* A → B (or A → filler if B empty) */}
148
+ <Separator glyph={glyphs.right} bg={hasB ? tileB : tileFiller} fg={modeColor} />
149
+
150
+ {hasB ? (
151
+ <>
152
+ <box
153
+ backgroundColor={tileB}
154
+ paddingLeft={1}
155
+ paddingRight={1}
156
+ flexDirection="row"
157
+ flexShrink={1}
158
+ overflow="hidden"
159
+ >
160
+ <Segments segments={model.sessionSegments} t={t} />
161
+ </box>
162
+ <Separator glyph={glyphs.right} bg={tileFiller} fg={tileB} />
163
+ </>
164
+ ) : null}
165
+
166
+ {/* Filler */}
167
+ <box flexGrow={1} flexShrink={1} backgroundColor={tileFiller} />
168
+
169
+ {hasX ? (
170
+ <>
171
+ <Separator glyph={glyphs.left} bg={tileFiller} fg={tileX} />
172
+ <box
173
+ backgroundColor={tileX}
174
+ paddingLeft={1}
175
+ paddingRight={1}
176
+ flexDirection="row"
177
+ flexShrink={0}
178
+ >
179
+ <AIUsageIndicator />
180
+ </box>
181
+ <Separator glyph={glyphs.left} bg={tileX} fg={tileY} />
182
+ </>
183
+ ) : (
184
+ <Separator glyph={glyphs.left} bg={tileFiller} fg={tileY} />
185
+ )}
186
+
187
+ {/* Y: version */}
188
+ <box backgroundColor={tileY} paddingLeft={1} paddingRight={1} flexShrink={0}>
189
+ <text fg={t.background} selectable={false}>
190
+ v{APP_VERSION}
191
+ </text>
192
+ </box>
193
+ </box>
194
+
195
+ {/* Row 2 — ambient hints */}
196
+ <box
197
+ height={1}
198
+ flexShrink={0}
199
+ flexDirection="row"
200
+ paddingLeft={ROW2_INDENT}
201
+ paddingRight={1}
202
+ overflow="hidden"
203
+ >
204
+ {ambient !== '' ? (
205
+ <text fg={t.textMuted} wrapMode="none" selectable={false}>
206
+ {ambient}
207
+ </text>
208
+ ) : null}
74
209
  </box>
75
210
  </box>
76
211
  )
@@ -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,