@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 +1 -1
- package/src/app-runtime/click-selection-resolver.ts +90 -56
- package/src/app-runtime/use-mouse-handlers.ts +299 -6
- package/src/app.tsx +16 -3
- package/src/state/session-catalog.ts +10 -0
- package/src/ui/components/layout/split-layout.tsx +10 -0
- package/src/ui/components/layout/terminal-pane.tsx +16 -3
- package/src/ui/root.tsx +13 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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
|
|
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:
|
|
60
|
-
viewportX:
|
|
61
|
-
viewportY:
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
140
|
+
sessionCatalog,
|
|
140
141
|
loadSnippetCatalog(),
|
|
141
|
-
|
|
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'
|
|
173
|
-
event
|
|
174
|
-
|
|
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
|
)}
|