@brimveyn/aimux 1.10.0 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -13,6 +13,8 @@ interface PositionedNode {
13
13
  y: number
14
14
  }
15
15
 
16
+ export type MultiClickMode = 'word' | 'line'
17
+
16
18
  export interface ClickSelectionResult {
17
19
  selectedText: string
18
20
  startCol: number
@@ -20,6 +22,22 @@ export interface ClickSelectionResult {
20
22
  baseX: number
21
23
  eventY: number
22
24
  target: unknown
25
+ row: number
26
+ mode: MultiClickMode
27
+ }
28
+
29
+ export interface ViewportAnchor {
30
+ target: PositionedNode
31
+ baseX: number
32
+ baseY: number
33
+ col: number
34
+ row: number
35
+ }
36
+
37
+ export interface MultiClickRange {
38
+ startCol: number
39
+ endCol: number
40
+ lineLength: number
23
41
  }
24
42
 
25
43
  export function isPositionedNode(value: unknown): value is PositionedNode {
@@ -31,100 +49,116 @@ export function isPositionedNode(value: unknown): value is PositionedNode {
31
49
  )
32
50
  }
33
51
 
52
+ export function getViewportAnchor(event: OtuiMouseEvent): ViewportAnchor | null {
53
+ if (!event.target || !isPositionedNode(event.target)) return null
54
+ const baseX = event.target.x
55
+ const baseY = event.target.y
56
+ return {
57
+ baseX,
58
+ baseY,
59
+ col: event.x - baseX,
60
+ row: event.y - baseY,
61
+ target: event.target,
62
+ }
63
+ }
64
+
65
+ export function computeRangeFromLineText(
66
+ lineText: string,
67
+ col: number,
68
+ mode: MultiClickMode
69
+ ): MultiClickRange | null {
70
+ if (mode === 'line') {
71
+ return { endCol: lineText.length, lineLength: lineText.length, startCol: 0 }
72
+ }
73
+ const word = getWordAtColumn(lineText, col)
74
+ if (word.text.length === 0) return null
75
+ return { endCol: word.endCol, lineLength: lineText.length, startCol: word.startCol }
76
+ }
77
+
78
+ export function computeMultiClickRange(
79
+ tab: TabSession | undefined,
80
+ row: number,
81
+ col: number,
82
+ mode: MultiClickMode
83
+ ): MultiClickRange | null {
84
+ const line = tab?.viewport?.lines[row]
85
+ if (!line) return null
86
+ return computeRangeFromLineText(getLineText(line), col, mode)
87
+ }
88
+
34
89
  export function resolveClickSelection(
35
90
  event: OtuiMouseEvent,
36
91
  targetTabId: string,
37
92
  tab: TabSession | undefined,
38
93
  clickCount: number
39
94
  ): ClickSelectionResult | null {
40
- if (!event.target) {
41
- return null
42
- }
43
-
44
- const viewportText = event.target
45
- if (!isPositionedNode(viewportText)) {
46
- return null
47
- }
95
+ const anchor = getViewportAnchor(event)
96
+ if (!anchor) return null
48
97
 
49
- const col = event.x - viewportText.x
50
- const row = event.y - viewportText.y
51
- const baseX = viewportText.x
98
+ const mode: MultiClickMode = clickCount === 2 ? 'word' : 'line'
52
99
 
53
100
  logInputDebug('click.detect', {
54
101
  clickCount,
55
- col,
102
+ col: anchor.col,
56
103
  eventX: event.x,
57
104
  eventY: event.y,
58
- row,
59
- targetId: event.target.id,
60
- viewportX: viewportText.x,
61
- viewportY: viewportText.y,
105
+ row: anchor.row,
106
+ targetId: anchor.target.id,
107
+ viewportX: anchor.baseX,
108
+ viewportY: anchor.baseY,
62
109
  })
63
110
 
64
- if (!tab?.viewport?.lines[row]) {
111
+ if (!tab?.viewport?.lines[anchor.row]) {
65
112
  logInputDebug('click.noViewportLine', {
66
113
  hasViewport: !!tab?.viewport,
67
114
  lineCount: tab?.viewport?.lines.length ?? 0,
68
- row,
115
+ row: anchor.row,
69
116
  tabFound: !!tab,
70
117
  targetTabId,
71
118
  })
72
119
  return null
73
120
  }
74
121
 
75
- const line = tab.viewport.lines[row]
76
- const lineText = getLineText(line)
77
-
78
- let selectedText: string
79
- let startCol: number
80
- let endCol: number
81
-
82
- if (clickCount === 2) {
83
- const word = getWordAtColumn(lineText, col)
84
- if (word.text.length === 0) {
122
+ const range = computeMultiClickRange(tab, anchor.row, anchor.col, mode)
123
+ if (!range) {
124
+ if (mode === 'word') {
125
+ const line = tab.viewport.lines[anchor.row]
126
+ const lineText = line ? getLineText(line) : ''
85
127
  logInputDebug('click.emptyWord', {
86
- charAtCol: lineText[col] ?? 'OOB',
87
- col,
128
+ charAtCol: lineText[anchor.col] ?? 'OOB',
129
+ col: anchor.col,
88
130
  lineText,
89
- row,
131
+ row: anchor.row,
90
132
  })
91
- return null
92
133
  }
93
-
94
- selectedText = word.text
95
- startCol = word.startCol
96
- endCol = word.endCol
97
- } else {
98
- selectedText = lineText
99
- startCol = 0
100
- endCol = lineText.length
134
+ return null
101
135
  }
102
136
 
137
+ const line = tab.viewport.lines[anchor.row]
138
+ const lineText = line ? getLineText(line) : ''
139
+ const selectedText = lineText.slice(range.startCol, range.endCol)
140
+
103
141
  logInputDebug('click.selection', {
104
- baseX,
142
+ baseX: anchor.baseX,
105
143
  clickCount,
106
- endCol,
107
- endX: baseX + endCol,
144
+ endCol: range.endCol,
145
+ endX: anchor.baseX + range.endCol,
108
146
  lineText,
147
+ mode,
109
148
  selectedText,
110
- spanCount: line.spans.length,
111
- spanStyles: line.spans.map((span) => ({
112
- bold: span.bold,
113
- italic: span.italic,
114
- underline: span.underline,
115
- })),
116
- spanTexts: line.spans.map((span) => span.text),
117
- startCol,
118
- startX: baseX + startCol,
149
+ startCol: range.startCol,
150
+ startX: anchor.baseX + range.startCol,
119
151
  y: event.y,
120
152
  })
121
153
 
122
154
  return {
123
- baseX,
124
- endCol,
155
+ baseX: anchor.baseX,
156
+ endCol: range.endCol,
125
157
  eventY: event.y,
158
+ mode,
159
+ row: anchor.row,
126
160
  selectedText,
127
- startCol,
128
- target: event.target,
161
+ startCol: range.startCol,
162
+ target: anchor.target,
129
163
  }
130
164
  }
@@ -1,18 +1,22 @@
1
1
  import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
2
 
3
- import { useRef } from 'react'
3
+ import { useEffect, useRef } from 'react'
4
4
 
5
5
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
6
6
  import type { SessionBackend } from '../session-backend/types'
7
7
  import type { SplitDirection } from '../state/layout-tree'
8
- import type { AppAction, AppState, TabSession } from '../state/types'
8
+ import type { AppAction, AppState, TabSession, TerminalLine } from '../state/types'
9
9
 
10
10
  import { logInputDebug } from '../debug/input-log'
11
11
  import { MultiClickDetector } from '../input/multi-click-detector'
12
+ import { extractStreamText, getLineText } from '../input/terminal-text-extraction'
12
13
  import { copyToSystemClipboard } from '../platform/clipboard'
13
14
  import {
14
15
  type ClickSelectionResult,
16
+ computeRangeFromLineText,
17
+ getViewportAnchor,
15
18
  isPositionedNode,
19
+ type MultiClickMode,
16
20
  resolveClickSelection,
17
21
  } from './click-selection-resolver'
18
22
  import { requestRenderUpTree } from './render-invalidation'
@@ -32,6 +36,31 @@ type ResizeDragState =
32
36
  | ({ initialWidth: number; kind: 'git-pane'; side: 'left' | 'right' } & AxisDragState)
33
37
  | ({ kind: 'embedded-git'; position: 'top' | 'bottom' } & AnchoredRatioDragState)
34
38
 
39
+ interface MultiClickDragState {
40
+ mode: MultiClickMode
41
+ tabId: string
42
+ target: unknown
43
+ baseX: number
44
+ baseY: number
45
+ // Absolute buffer rows (viewportY + viewportRow). Stable across scroll so
46
+ // auto-scroll can move the viewport without losing the anchor.
47
+ anchorAbsRow: number
48
+ anchorStartCol: number
49
+ anchorEndCol: number
50
+ focusAbsRow: number
51
+ focusCol: number
52
+ // Last raw screen position so the viewport-change effect can re-extend the
53
+ // selection even when no drag event has fired (mouse held still at the edge).
54
+ lastEventScreenY: number
55
+ lastEventScreenX: number
56
+ // Cache of every viewport row seen during the drag, indexed by absolute row.
57
+ // Source of truth for clipboard text when the selection spans rows that have
58
+ // scrolled out of view.
59
+ capturedLines: Map<number, TerminalLine>
60
+ autoScrollTimer: ReturnType<typeof setInterval> | null
61
+ autoScrollDir: -1 | 0 | 1
62
+ }
63
+
35
64
  interface UseMouseHandlersOptions {
36
65
  state: AppState
37
66
  dispatch: (action: AppAction) => void
@@ -47,6 +76,7 @@ interface UseMouseHandlersOptions {
47
76
  }
48
77
 
49
78
  const MIN_MULTI_CLICK_SELECTION_COUNT = 2
79
+ const AUTO_SCROLL_INTERVAL_MS = 40
50
80
 
51
81
  function getTargetTerminalTabId(
52
82
  focusMode: AppState['focusMode'],
@@ -60,14 +90,49 @@ function getTargetTerminalTabId(
60
90
  return activeTabId
61
91
  }
62
92
 
63
- function applyResolvedSelection(
93
+ function captureViewportLines(drag: MultiClickDragState, tab: TabSession): void {
94
+ if (!tab.viewport) return
95
+ const startAbs = tab.viewport.viewportY
96
+ for (let i = 0; i < tab.viewport.lines.length; i++) {
97
+ const line = tab.viewport.lines[i]
98
+ if (line) drag.capturedLines.set(startAbs + i, line)
99
+ }
100
+ }
101
+
102
+ function isForwardSelection(drag: MultiClickDragState): boolean {
103
+ return (
104
+ drag.focusAbsRow > drag.anchorAbsRow ||
105
+ (drag.focusAbsRow === drag.anchorAbsRow && drag.focusCol >= drag.anchorEndCol)
106
+ )
107
+ }
108
+
109
+ function renderMultiClickSelection(
110
+ renderer: UseMouseHandlersOptions['renderer'],
111
+ drag: MultiClickDragState,
112
+ viewportY: number,
113
+ finishDragging: boolean
114
+ ): void {
115
+ const forward = isForwardSelection(drag)
116
+ const anchorCol = forward ? drag.anchorStartCol : drag.anchorEndCol
117
+ const anchorScreenY = drag.baseY + (drag.anchorAbsRow - viewportY)
118
+ const focusScreenY = drag.baseY + (drag.focusAbsRow - viewportY)
119
+ renderer.startSelection(drag.target, drag.baseX + anchorCol, anchorScreenY)
120
+ renderer.updateSelection(drag.target, drag.baseX + drag.focusCol, focusScreenY, {
121
+ finishDragging,
122
+ })
123
+ requestRenderUpTree(drag.target)
124
+ }
125
+
126
+ function applyMultiClickInitialSelection(
64
127
  renderer: UseMouseHandlersOptions['renderer'],
65
128
  selection: ClickSelectionResult
66
129
  ): void {
67
130
  renderer.clearSelection()
68
131
  renderer.startSelection(selection.target, selection.baseX + selection.startCol, selection.eventY)
132
+ // finishDragging:false keeps the renderer in drag mode so subsequent
133
+ // mouse-drag events can extend the selection through our drag handler.
69
134
  renderer.updateSelection(selection.target, selection.baseX + selection.endCol, selection.eventY, {
70
- finishDragging: true,
135
+ finishDragging: false,
71
136
  })
72
137
  requestRenderUpTree(selection.target)
73
138
  copyToSystemClipboard(selection.selectedText)
@@ -83,6 +148,98 @@ export function useMouseHandlers({
83
148
  }: UseMouseHandlersOptions) {
84
149
  const separatorDragRef = useRef<ResizeDragState | null>(null)
85
150
  const multiClickRef = useRef(new MultiClickDetector())
151
+ const multiClickDragRef = useRef<MultiClickDragState | null>(null)
152
+ // State is captured by reference in the React closure; we need a ref so
153
+ // drag/up handlers and the auto-scroll timer see the freshest tab viewport.
154
+ const stateRef = useRef(state)
155
+ stateRef.current = state
156
+
157
+ const clearAutoScroll = (drag: MultiClickDragState) => {
158
+ if (drag.autoScrollTimer) {
159
+ clearInterval(drag.autoScrollTimer)
160
+ }
161
+ drag.autoScrollTimer = null
162
+ drag.autoScrollDir = 0
163
+ }
164
+
165
+ const canScroll = (tab: TabSession | undefined, dir: -1 | 1): boolean => {
166
+ if (!tab?.viewport) return false
167
+ if (dir < 0) return tab.viewport.viewportY > 0
168
+ return tab.viewport.viewportY < tab.viewport.baseY
169
+ }
170
+
171
+ const startAutoScroll = (drag: MultiClickDragState, dir: -1 | 1) => {
172
+ if (drag.autoScrollDir === dir && drag.autoScrollTimer) return
173
+ clearAutoScroll(drag)
174
+ drag.autoScrollDir = dir
175
+ drag.autoScrollTimer = setInterval(() => {
176
+ const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
177
+ if (!canScroll(tab, dir)) {
178
+ clearAutoScroll(drag)
179
+ return
180
+ }
181
+ backend.scrollViewport(drag.tabId, dir)
182
+ // The snapshot dispatch will trigger the viewport-change effect below,
183
+ // which recaptures lines and re-extends the selection focus.
184
+ }, AUTO_SCROLL_INTERVAL_MS)
185
+ }
186
+
187
+ // Unmount safety: never leave a setInterval running past the hook's life.
188
+ useEffect(
189
+ () => () => {
190
+ const drag = multiClickDragRef.current
191
+ if (drag) clearAutoScroll(drag)
192
+ multiClickDragRef.current = null
193
+ },
194
+ []
195
+ )
196
+
197
+ // Abort the drag (and its auto-scroll loop) when the user switches to a
198
+ // different tab mid-drag: the original tab is no longer visible and we'd
199
+ // otherwise keep pumping scroll commands into a background buffer.
200
+ useEffect(() => {
201
+ const drag = multiClickDragRef.current
202
+ if (!drag) return
203
+ if (drag.tabId !== state.activeTabId) {
204
+ clearAutoScroll(drag)
205
+ multiClickDragRef.current = null
206
+ }
207
+ }, [state.activeTabId])
208
+
209
+ // Re-extend selection and recapture viewport lines on every snapshot change
210
+ // while a multi-click drag is in progress. This is what lets auto-scroll
211
+ // grow the selection even when the mouse hasn't moved.
212
+ useEffect(() => {
213
+ const drag = multiClickDragRef.current
214
+ if (!drag) return
215
+ const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
216
+ if (!tab?.viewport) return
217
+
218
+ captureViewportLines(drag, tab)
219
+
220
+ const viewportRows = tab.viewport.lines.length
221
+ if (viewportRows === 0) return
222
+
223
+ const dragViewportRow = Math.max(
224
+ 0,
225
+ Math.min(viewportRows - 1, drag.lastEventScreenY - drag.baseY)
226
+ )
227
+ const newFocusAbs = tab.viewport.viewportY + dragViewportRow
228
+ const focusLine = drag.capturedLines.get(newFocusAbs)
229
+ const focusLineText = focusLine ? getLineText(focusLine) : ''
230
+ const focusDragCol = drag.lastEventScreenX - drag.baseX
231
+ const range = computeRangeFromLineText(focusLineText, focusDragCol, drag.mode)
232
+ const focusStart = range?.startCol ?? Math.max(0, focusDragCol)
233
+ const focusEnd = range?.endCol ?? Math.max(0, focusDragCol)
234
+
235
+ drag.focusAbsRow = newFocusAbs
236
+ const forward =
237
+ newFocusAbs > drag.anchorAbsRow ||
238
+ (newFocusAbs === drag.anchorAbsRow && focusEnd >= drag.anchorEndCol)
239
+ drag.focusCol = forward ? focusEnd : focusStart
240
+
241
+ renderMultiClickSelection(renderer, drag, tab.viewport.viewportY, false)
242
+ }, [state.tabs, renderer])
86
243
 
87
244
  const handleTerminalMouseEvent = (event: OtuiMouseEvent, origin: TerminalContentOrigin) => {
88
245
  const targetTabId = getTargetTerminalTabId(
@@ -237,24 +394,158 @@ export function useMouseHandlers({
237
394
 
238
395
  const clickCount = multiClickRef.current.track(event.x, event.y)
239
396
  if (clickCount < MIN_MULTI_CLICK_SELECTION_COUNT) {
397
+ // Single-click: clear any stale multi-click drag state so a fresh
398
+ // char-level drag from opentui's default handler isn't extended by us.
399
+ const stale = multiClickDragRef.current
400
+ if (stale) clearAutoScroll(stale)
401
+ multiClickDragRef.current = null
240
402
  return
241
403
  }
242
404
 
243
405
  const tab = state.tabs.find((t: TabSession) => t.id === targetTabId)
244
406
  const selection = resolveClickSelection(event, targetTabId, tab, clickCount)
245
- if (!selection) {
407
+ if (!selection || !tab?.viewport) {
246
408
  return
247
409
  }
248
410
 
249
411
  event.preventDefault()
250
- applyResolvedSelection(renderer, selection)
412
+ applyMultiClickInitialSelection(renderer, selection)
413
+
414
+ const anchor = getViewportAnchor(event)
415
+ if (anchor) {
416
+ const anchorAbsRow = tab.viewport.viewportY + selection.row
417
+ const drag: MultiClickDragState = {
418
+ anchorAbsRow,
419
+ anchorEndCol: selection.endCol,
420
+ anchorStartCol: selection.startCol,
421
+ autoScrollDir: 0,
422
+ autoScrollTimer: null,
423
+ baseX: anchor.baseX,
424
+ baseY: anchor.baseY,
425
+ capturedLines: new Map(),
426
+ focusAbsRow: anchorAbsRow,
427
+ focusCol: selection.endCol,
428
+ lastEventScreenX: event.x,
429
+ lastEventScreenY: event.y,
430
+ mode: selection.mode,
431
+ tabId: targetTabId,
432
+ target: selection.target,
433
+ }
434
+ captureViewportLines(drag, tab)
435
+ multiClickDragRef.current = drag
436
+ }
251
437
 
252
438
  logInputDebug('click.done', {
253
439
  hasSelection: !!renderer.hasSelection,
440
+ mode: selection.mode,
254
441
  targetSelectable: isPositionedNode(event.target) ? !!event.target.selectable : false,
255
442
  })
256
443
  }
257
444
 
445
+ const handleTerminalDrag = (
446
+ event: OtuiMouseEvent,
447
+ _origin: TerminalContentOrigin,
448
+ _tabId?: string
449
+ ): boolean => {
450
+ const drag = multiClickDragRef.current
451
+ if (!drag) return false
452
+
453
+ const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
454
+ if (!tab?.viewport) return true
455
+
456
+ drag.lastEventScreenX = event.x
457
+ drag.lastEventScreenY = event.y
458
+ captureViewportLines(drag, tab)
459
+
460
+ const viewportRows = tab.viewport.lines.length
461
+ if (viewportRows === 0) return true
462
+
463
+ const rawRow = event.y - drag.baseY
464
+ const dragViewportRow = Math.max(0, Math.min(viewportRows - 1, rawRow))
465
+ const dragAbsRow = tab.viewport.viewportY + dragViewportRow
466
+ const dragCol = event.x - drag.baseX
467
+
468
+ const focusLine = drag.capturedLines.get(dragAbsRow)
469
+ const focusLineText = focusLine ? getLineText(focusLine) : ''
470
+ const range = computeRangeFromLineText(focusLineText, dragCol, drag.mode)
471
+ const focusStart = range?.startCol ?? Math.max(0, dragCol)
472
+ const focusEnd = range?.endCol ?? Math.max(0, dragCol)
473
+
474
+ drag.focusAbsRow = dragAbsRow
475
+ const forward =
476
+ dragAbsRow > drag.anchorAbsRow ||
477
+ (dragAbsRow === drag.anchorAbsRow && focusEnd >= drag.anchorEndCol)
478
+ drag.focusCol = forward ? focusEnd : focusStart
479
+
480
+ renderMultiClickSelection(renderer, drag, tab.viewport.viewportY, false)
481
+
482
+ // Edge detection: trigger auto-scroll when the pointer sits at (or beyond)
483
+ // the top/bottom row of the viewport.
484
+ let dir: -1 | 0 | 1 = 0
485
+ if (rawRow <= 0) dir = -1
486
+ else if (rawRow >= viewportRows - 1) dir = 1
487
+
488
+ if (dir === 0) {
489
+ clearAutoScroll(drag)
490
+ } else if (canScroll(tab, dir)) {
491
+ startAutoScroll(drag, dir)
492
+ } else {
493
+ clearAutoScroll(drag)
494
+ }
495
+
496
+ return true
497
+ }
498
+
499
+ const handleTerminalMouseUp = (_event: OtuiMouseEvent): boolean => {
500
+ const drag = multiClickDragRef.current
501
+ if (!drag) return false
502
+
503
+ clearAutoScroll(drag)
504
+
505
+ const tab = stateRef.current.tabs.find((t: TabSession) => t.id === drag.tabId)
506
+ if (tab?.viewport) {
507
+ captureViewportLines(drag, tab)
508
+ renderMultiClickSelection(renderer, drag, tab.viewport.viewportY, true)
509
+ }
510
+
511
+ const forward = isForwardSelection(drag)
512
+ const anchorCol = forward ? drag.anchorStartCol : drag.anchorEndCol
513
+
514
+ const startAbs = Math.min(drag.anchorAbsRow, drag.focusAbsRow)
515
+ const endAbs = Math.max(drag.anchorAbsRow, drag.focusAbsRow)
516
+ const lines: TerminalLine[] = []
517
+ let missingRows = 0
518
+ for (let abs = startAbs; abs <= endAbs; abs++) {
519
+ const line = drag.capturedLines.get(abs)
520
+ if (line) {
521
+ lines.push(line)
522
+ } else {
523
+ missingRows += 1
524
+ lines.push({ spans: [] })
525
+ }
526
+ }
527
+ if (missingRows > 0) {
528
+ // Snapshot coalescing (remote backend, fast scroll bursts) can let the
529
+ // selection cover rows we never captured. The output gets blank lines
530
+ // here — surface it for debugging instead of silently corrupting paste.
531
+ logInputDebug('multiclick.clipboardGap', {
532
+ endAbs,
533
+ missingRows,
534
+ startAbs,
535
+ totalRows: endAbs - startAbs + 1,
536
+ })
537
+ }
538
+ const anchorIdx = drag.anchorAbsRow - startAbs
539
+ const focusIdx = drag.focusAbsRow - startAbs
540
+ const text = extractStreamText(lines, anchorIdx, anchorCol, focusIdx, drag.focusCol)
541
+ if (text.length > 0) {
542
+ copyToSystemClipboard(text)
543
+ }
544
+
545
+ multiClickDragRef.current = null
546
+ return true
547
+ }
548
+
258
549
  return {
259
550
  handleEmbeddedGitResizeStart,
260
551
  handleGitPaneResizeStart,
@@ -265,7 +556,9 @@ export function useMouseHandlers({
265
556
  handleSidebarResizeStart,
266
557
  handleSplitResize,
267
558
  handleTerminalClick,
559
+ handleTerminalDrag,
268
560
  handleTerminalMouseEvent,
561
+ handleTerminalMouseUp,
269
562
  handleTerminalScrollEvent,
270
563
  }
271
564
  }
package/src/app.tsx CHANGED
@@ -32,7 +32,7 @@ import { startAIUsageService } from './services/ai-usage/provider'
32
32
  import { aiUsageStore } from './state/ai-usage-store'
33
33
  import { appStore, useAppStore } from './state/app-store'
34
34
  import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
35
- import { loadSessionCatalog } from './state/session-catalog'
35
+ import { findMostRecentSession, loadSessionCatalog } from './state/session-catalog'
36
36
  import { loadSnippetCatalog } from './state/snippet-catalog'
37
37
  import { createInitialState } from './state/store'
38
38
  import { KeymapContext } from './ui/keymap-context'
@@ -134,11 +134,12 @@ export function App({
134
134
  ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
135
135
  }
136
136
 
137
+ const sessionCatalog = loadSessionCatalog()
137
138
  const initial = createInitialState(
138
139
  json.customCommands,
139
- loadSessionCatalog(),
140
+ sessionCatalog,
140
141
  loadSnippetCatalog(),
141
- true,
142
+ sessionCatalog.length === 0,
142
143
  {
143
144
  gitPane: gitPaneOverrides,
144
145
  sessionBarPosition,
@@ -149,6 +150,14 @@ export function App({
149
150
  // Replace the module-level default with the fully-resolved initial state.
150
151
  // Preserves the dispatch baked into the store by app-store.ts.
151
152
  appStore.setState(initial)
153
+ const mostRecent = findMostRecentSession(sessionCatalog)
154
+ if (mostRecent) {
155
+ appStore.getState().dispatch({
156
+ sessionId: mostRecent.id,
157
+ type: 'load-session',
158
+ workspaceSnapshot: mostRecent.workspaceSnapshot,
159
+ })
160
+ }
152
161
  return null
153
162
  })
154
163
 
@@ -323,7 +332,9 @@ export function App({
323
332
  handleSidebarResizeStart,
324
333
  handleSplitResize,
325
334
  handleTerminalClick,
335
+ handleTerminalDrag,
326
336
  handleTerminalMouseEvent,
337
+ handleTerminalMouseUp,
327
338
  handleTerminalScrollEvent,
328
339
  } = useMouseHandlers({
329
340
  activeLocalScrollbackEnabled,
@@ -455,6 +466,8 @@ export function App({
455
466
  onTerminalMouseEvent={handleTerminalMouseEvent}
456
467
  onTerminalScrollEvent={handleTerminalScrollEvent}
457
468
  onTerminalClick={handleTerminalClick}
469
+ onTerminalDrag={handleTerminalDrag}
470
+ onTerminalMouseUp={handleTerminalMouseUp}
458
471
  onPaneActivate={handlePaneActivate}
459
472
  onSplitResize={handleSplitResize}
460
473
  onEmbeddedGitResizeStart={handleEmbeddedGitResizeStart}
@@ -96,6 +96,16 @@ export function getSessionCatalogPath(): string {
96
96
  return SESSIONS_PATH
97
97
  }
98
98
 
99
+ export function findMostRecentSession(sessions: SessionRecord[]): SessionRecord | undefined {
100
+ let best: SessionRecord | undefined
101
+ for (const candidate of sessions) {
102
+ if (!best || candidate.lastOpenedAt.localeCompare(best.lastOpenedAt) > 0) {
103
+ best = candidate
104
+ }
105
+ }
106
+ return best
107
+ }
108
+
99
109
  /**
100
110
  * Assign a stable `order` to every session. Records with an existing numeric
101
111
  * `order` keep their slot (sorted ascending); the rest are appended by
@@ -26,6 +26,8 @@ interface SplitLayoutProps {
26
26
  onTerminalMouseEvent: (event: OtuiMouseEvent, origin: TerminalContentOrigin) => void
27
27
  onTerminalScrollEvent: (event: OtuiMouseEvent) => void
28
28
  onTerminalClick?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
29
+ onTerminalDrag?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => boolean
30
+ onTerminalMouseUp?: (event: OtuiMouseEvent) => boolean
29
31
  onPaneActivate?: (tabId: string) => void
30
32
  onSplitResize?: (tabId: string, ratio: number, axis: SplitDirection) => void
31
33
  onSeparatorDragStart?: (info: {
@@ -54,7 +56,9 @@ export function SplitLayout({
54
56
  onSeparatorDragStart,
55
57
  onSplitResize,
56
58
  onTerminalClick,
59
+ onTerminalDrag,
57
60
  onTerminalMouseEvent,
61
+ onTerminalMouseUp,
58
62
  onTerminalScrollEvent,
59
63
  tabs,
60
64
  }: SplitLayoutProps) {
@@ -92,6 +96,8 @@ export function SplitLayout({
92
96
  onTerminalMouseEvent={onTerminalMouseEvent}
93
97
  onTerminalScrollEvent={onTerminalScrollEvent}
94
98
  onTerminalClick={onTerminalClick}
99
+ onTerminalDrag={onTerminalDrag}
100
+ onTerminalMouseUp={onTerminalMouseUp}
95
101
  onPaneActivate={onPaneActivate}
96
102
  onSeparatorDrag={onSeparatorDrag}
97
103
  onSeparatorDragEnd={onSeparatorDragEnd}
@@ -123,6 +129,8 @@ export function SplitLayout({
123
129
  onTerminalMouseEvent={onTerminalMouseEvent}
124
130
  onTerminalScrollEvent={onTerminalScrollEvent}
125
131
  onTerminalClick={onTerminalClick}
132
+ onTerminalDrag={onTerminalDrag}
133
+ onTerminalMouseUp={onTerminalMouseUp}
126
134
  onPaneActivate={onPaneActivate}
127
135
  onSplitResize={onSplitResize}
128
136
  onSeparatorDragStart={onSeparatorDragStart}
@@ -163,6 +171,8 @@ export function SplitLayout({
163
171
  onTerminalMouseEvent={onTerminalMouseEvent}
164
172
  onTerminalScrollEvent={onTerminalScrollEvent}
165
173
  onTerminalClick={onTerminalClick}
174
+ onTerminalDrag={onTerminalDrag}
175
+ onTerminalMouseUp={onTerminalMouseUp}
166
176
  onPaneActivate={onPaneActivate}
167
177
  onSplitResize={onSplitResize}
168
178
  onSeparatorDragStart={onSeparatorDragStart}
@@ -22,6 +22,8 @@ interface TerminalPaneProps {
22
22
  onTerminalMouseEvent: (event: OtuiMouseEvent, origin: TerminalContentOrigin) => void
23
23
  onTerminalScrollEvent: (event: OtuiMouseEvent) => void
24
24
  onTerminalClick?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
25
+ onTerminalDrag?: (event: OtuiMouseEvent, origin: TerminalContentOrigin, tabId?: string) => boolean
26
+ onTerminalMouseUp?: (event: OtuiMouseEvent) => boolean
25
27
  onPaneActivate?: (tabId: string) => void
26
28
  onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
27
29
  onSeparatorDragEnd?: () => void
@@ -112,7 +114,9 @@ export function TerminalPane({
112
114
  onSeparatorDrag,
113
115
  onSeparatorDragEnd,
114
116
  onTerminalClick,
117
+ onTerminalDrag,
115
118
  onTerminalMouseEvent,
119
+ onTerminalMouseUp,
116
120
  onTerminalScrollEvent,
117
121
  tab,
118
122
  tabId,
@@ -169,11 +173,20 @@ export function TerminalPane({
169
173
  y: event.y,
170
174
  })
171
175
  }
172
- if (event.type === 'drag' && onSeparatorDrag?.(event)) {
173
- event.preventDefault()
174
- return
176
+ if (event.type === 'drag') {
177
+ if (onTerminalDrag?.(event, contentOrigin, tabId)) {
178
+ event.preventDefault()
179
+ return
180
+ }
181
+ if (onSeparatorDrag?.(event)) {
182
+ event.preventDefault()
183
+ return
184
+ }
175
185
  }
176
186
  if (event.type === 'up') {
187
+ if (onTerminalMouseUp?.(event)) {
188
+ event.preventDefault()
189
+ }
177
190
  onSeparatorDragEnd?.()
178
191
  }
179
192
  if (tabId && onPaneActivate && event.type === 'down') {
package/src/ui/root.tsx CHANGED
@@ -206,6 +206,8 @@ interface RootViewProps {
206
206
  onTerminalMouseEvent: (event: MouseEvent, origin: TerminalContentOrigin) => void
207
207
  onTerminalScrollEvent: (event: MouseEvent) => void
208
208
  onTerminalClick?: (event: MouseEvent, origin: TerminalContentOrigin, tabId?: string) => void
209
+ onTerminalDrag?: (event: MouseEvent, origin: TerminalContentOrigin, tabId?: string) => boolean
210
+ onTerminalMouseUp?: (event: MouseEvent) => boolean
209
211
  onPaneActivate?: (tabId: string) => void
210
212
  onSplitResize?: (tabId: string, ratio: number, axis: SplitDirection) => void
211
213
  onSidebarResizeStart?: (info: { initialWidth: number; screenStart: number }) => void
@@ -244,7 +246,9 @@ export function RootView({
244
246
  onSidebarResizeStart,
245
247
  onSplitResize,
246
248
  onTerminalClick,
249
+ onTerminalDrag,
247
250
  onTerminalMouseEvent,
251
+ onTerminalMouseUp,
248
252
  onTerminalScrollEvent,
249
253
  terminalCols,
250
254
  terminalRows,
@@ -312,7 +316,11 @@ export function RootView({
312
316
  event.stopPropagation()
313
317
  }
314
318
  }}
315
- onMouseUp={() => {
319
+ onMouseUp={(event) => {
320
+ // Catch releases that land outside any TerminalPane (sidebar, gap,
321
+ // status bar, …) so an in-flight multi-click drag — and its
322
+ // auto-scroll interval — is always finalised.
323
+ onTerminalMouseUp?.(event)
316
324
  onSeparatorDragEnd?.()
317
325
  }}
318
326
  >
@@ -349,6 +357,8 @@ export function RootView({
349
357
  onTerminalMouseEvent={onTerminalMouseEvent}
350
358
  onTerminalScrollEvent={onTerminalScrollEvent}
351
359
  onTerminalClick={onTerminalClick}
360
+ onTerminalDrag={onTerminalDrag}
361
+ onTerminalMouseUp={onTerminalMouseUp}
352
362
  onPaneActivate={onPaneActivate}
353
363
  onSplitResize={onSplitResize}
354
364
  onSeparatorDragStart={onSeparatorDragStart}
@@ -373,6 +383,8 @@ export function RootView({
373
383
  onTerminalMouseEvent={onTerminalMouseEvent}
374
384
  onTerminalScrollEvent={onTerminalScrollEvent}
375
385
  onTerminalClick={onTerminalClick}
386
+ onTerminalDrag={onTerminalDrag}
387
+ onTerminalMouseUp={onTerminalMouseUp}
376
388
  onPaneActivate={onPaneActivate}
377
389
  />
378
390
  )}