@brimveyn/aimux 1.12.0 → 1.12.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 +2 -2
- package/src/app-runtime/multi-click-clipboard-guard.ts +21 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app-runtime/use-pane-size-report.ts +95 -0
- package/src/app-runtime/use-renderer-bindings.ts +11 -0
- package/src/app-runtime/use-terminal-resize.ts +45 -2
- package/src/app.tsx +1 -0
- package/src/pty/pty-manager.ts +51 -21
- package/src/pty/terminal-snapshot.ts +8 -1
- package/src/ui/components/layout/split-layout.tsx +6 -0
- package/src/ui/components/layout/status-bar.tsx +3 -1
- package/src/ui/components/layout/terminal-pane.tsx +17 -3
- package/src/ui/root.tsx +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"bump": "bun run scripts/bump.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@brimveyn/aimux-config": "0.5.
|
|
63
|
+
"@brimveyn/aimux-config": "0.5.13",
|
|
64
64
|
"@opentui/core": "^0.1.90",
|
|
65
65
|
"@opentui/react": "^0.1.90",
|
|
66
66
|
"@resvg/resvg-wasm": "^2.6.2",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// On multi-click selection mouseUp opentui's finishSelection() fires the
|
|
2
|
+
// 'selection' event *after* our handleTerminalMouseUp has already written
|
|
3
|
+
// the clipboard from drag.capturedLines (anchored at click time). The event
|
|
4
|
+
// handler would then call computeStreamSelectedText against the *current*
|
|
5
|
+
// tab.viewport.lines and overwrite our write with a value that can diverge
|
|
6
|
+
// by a few rows when output landed between mouseDown and mouseUp.
|
|
7
|
+
//
|
|
8
|
+
// To avoid that, handleTerminalMouseUp pings the guard right after it
|
|
9
|
+
// copies; handleSelection consults the guard and skips its own copy for a
|
|
10
|
+
// short window.
|
|
11
|
+
|
|
12
|
+
const SUPPRESS_WINDOW_MS = 100
|
|
13
|
+
let lastMultiClickWriteAt = 0
|
|
14
|
+
|
|
15
|
+
export function recordMultiClickClipboardWrite(): void {
|
|
16
|
+
lastMultiClickWriteAt = Date.now()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shouldSuppressSelectionCopy(): boolean {
|
|
20
|
+
return Date.now() - lastMultiClickWriteAt < SUPPRESS_WINDOW_MS
|
|
21
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type MultiClickMode,
|
|
20
20
|
resolveClickSelection,
|
|
21
21
|
} from './click-selection-resolver'
|
|
22
|
+
import { recordMultiClickClipboardWrite } from './multi-click-clipboard-guard'
|
|
22
23
|
import { requestRenderUpTree } from './render-invalidation'
|
|
23
24
|
import {
|
|
24
25
|
type AnchoredRatioDragState,
|
|
@@ -540,6 +541,7 @@ export function useMouseHandlers({
|
|
|
540
541
|
const text = extractStreamText(lines, anchorIdx, anchorCol, focusIdx, drag.focusCol)
|
|
541
542
|
if (text.length > 0) {
|
|
542
543
|
copyToSystemClipboard(text)
|
|
544
|
+
recordMultiClickClipboardWrite()
|
|
543
545
|
}
|
|
544
546
|
|
|
545
547
|
multiClickDragRef.current = null
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { BoxRenderable } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
/** Geometry of the rendered terminal content box, in absolute screen cells. */
|
|
6
|
+
export interface MeasuredPaneRect {
|
|
7
|
+
/** 0-based screen column of the first content cell */
|
|
8
|
+
x: number
|
|
9
|
+
/** 0-based screen row of the first content cell */
|
|
10
|
+
y: number
|
|
11
|
+
/** content width in cells == required PTY/xterm cols */
|
|
12
|
+
cols: number
|
|
13
|
+
/** content height in cells == required PTY/xterm rows */
|
|
14
|
+
rows: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// opentui recomputes layout on its own render tick after React commits
|
|
18
|
+
// (resetAfterCommit → requestRender). Re-reading the renderable two frames
|
|
19
|
+
// later guarantees we observe the settled layout even when the terminal is
|
|
20
|
+
// otherwise idle (a sidebar toggle on a static shell would not produce
|
|
21
|
+
// another React commit on its own). overflow:hidden on the content box keeps
|
|
22
|
+
// the box size independent of terminal content, so this loop provably
|
|
23
|
+
// converges and cannot oscillate.
|
|
24
|
+
const SETTLE_DELAY_MS = 32
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Closed measurement loop: observes the *actual* rendered terminal content
|
|
28
|
+
* box and reports its geometry whenever it changes. The reported size is the
|
|
29
|
+
* single source of truth for sizing the PTY + xterm emulator, replacing the
|
|
30
|
+
* open-loop "terminal height minus a hardcoded chrome model" estimate that
|
|
31
|
+
* drifted (status-bar wrap, disconnected/error line, split chrome) and left
|
|
32
|
+
* dead rows / shifted content.
|
|
33
|
+
*
|
|
34
|
+
* Returns a ref callback to attach to the content box renderable.
|
|
35
|
+
*/
|
|
36
|
+
export function usePaneSizeReport(
|
|
37
|
+
tabId: string | undefined,
|
|
38
|
+
enabled: boolean,
|
|
39
|
+
onMeasure: ((tabId: string, rect: MeasuredPaneRect) => void) | undefined
|
|
40
|
+
): (node: BoxRenderable | null) => void {
|
|
41
|
+
const boxRef = useRef<BoxRenderable | null>(null)
|
|
42
|
+
const lastRef = useRef<MeasuredPaneRect | null>(null)
|
|
43
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
44
|
+
|
|
45
|
+
const measure = useCallback(() => {
|
|
46
|
+
const box = boxRef.current
|
|
47
|
+
if (!box || !tabId || !enabled || !onMeasure) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
const cols = Math.round(box.width)
|
|
51
|
+
const rows = Math.round(box.height)
|
|
52
|
+
const x = Math.round(box.x)
|
|
53
|
+
const y = Math.round(box.y)
|
|
54
|
+
if (cols < 1 || rows < 1) {
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
const prev = lastRef.current
|
|
58
|
+
if (prev && prev.cols === cols && prev.rows === rows && prev.x === x && prev.y === y) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const next: MeasuredPaneRect = { cols, rows, x, y }
|
|
62
|
+
lastRef.current = next
|
|
63
|
+
onMeasure(tabId, next)
|
|
64
|
+
}, [enabled, onMeasure, tabId])
|
|
65
|
+
|
|
66
|
+
// Runs after every commit: measure now (covers the steady-state case where
|
|
67
|
+
// layout was already settled on a prior frame) and once more after
|
|
68
|
+
// opentui's render/layout tick.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
measure()
|
|
71
|
+
if (timerRef.current) {
|
|
72
|
+
clearTimeout(timerRef.current)
|
|
73
|
+
}
|
|
74
|
+
timerRef.current = setTimeout(() => {
|
|
75
|
+
timerRef.current = null
|
|
76
|
+
measure()
|
|
77
|
+
}, SETTLE_DELAY_MS)
|
|
78
|
+
return () => {
|
|
79
|
+
if (timerRef.current) {
|
|
80
|
+
clearTimeout(timerRef.current)
|
|
81
|
+
timerRef.current = null
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return useCallback(
|
|
87
|
+
(node: BoxRenderable | null) => {
|
|
88
|
+
boxRef.current = node
|
|
89
|
+
if (node) {
|
|
90
|
+
measure()
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
[measure]
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { AppAction, FocusMode, TabSession } from '../state/types'
|
|
|
8
8
|
import { INPUT_DEBUG_LOG_PATH, logInputDebug } from '../debug/input-log'
|
|
9
9
|
import { createRawInputHandler } from '../input/raw-input-handler'
|
|
10
10
|
import { copyToSystemClipboard } from '../platform/clipboard'
|
|
11
|
+
import { shouldSuppressSelectionCopy } from './multi-click-clipboard-guard'
|
|
11
12
|
import { writePasteToTab, writeToTab } from './pty-write'
|
|
12
13
|
import { type OtuiSelection, resolveSelectionClipboardText } from './selection-clipboard'
|
|
13
14
|
import { applyViewportObservation, type ViewportObservation } from './selection-scroll'
|
|
@@ -118,6 +119,16 @@ export function useRendererBindings({
|
|
|
118
119
|
return
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// After a multi-click drag, handleTerminalMouseUp has just copied the
|
|
123
|
+
// authoritative text built from drag.capturedLines (anchored at click
|
|
124
|
+
// time). opentui's finishSelection() then re-fires this 'selection'
|
|
125
|
+
// event, but the recomputed text can drift by a few rows when output
|
|
126
|
+
// landed between mouseDown and mouseUp. Skip the redundant write.
|
|
127
|
+
if (shouldSuppressSelectionCopy()) {
|
|
128
|
+
logInputDebug('app.selection.suppressed', { textLength: selectedText.length })
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
renderer.copyToClipboardOSC52(selectedText)
|
|
122
133
|
copyToSystemClipboard(selectedText)
|
|
123
134
|
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { flushSync } from '@opentui/react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type MutableRefObject,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
} from 'react'
|
|
3
10
|
|
|
4
11
|
import type { TerminalContentOrigin } from '../input/raw-input-handler'
|
|
5
12
|
import type { SessionBackend } from '../session-backend/types'
|
|
6
13
|
import type { AppAction, AppState, ScrollIntent } from '../state/types'
|
|
14
|
+
import type { MeasuredPaneRect } from './use-pane-size-report'
|
|
7
15
|
|
|
8
16
|
import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
|
|
9
17
|
import {
|
|
@@ -20,6 +28,12 @@ const MIN_TERMINAL_ROWS = 1
|
|
|
20
28
|
const MIN_TERMINAL_COLS = 20
|
|
21
29
|
const RESIZE_ACTIVITY_SETTLE_MS = 500
|
|
22
30
|
|
|
31
|
+
// Must match the clamps PtyManager applies in resizeSession/resizeAll, so the
|
|
32
|
+
// size we record as "applied" is the size the PTY/xterm actually adopt — a
|
|
33
|
+
// mismatch here would make the dedupe never settle and resize every frame.
|
|
34
|
+
const PTY_MIN_COLS = 20
|
|
35
|
+
const PTY_MIN_ROWS = 8
|
|
36
|
+
|
|
23
37
|
function getTerminalBounds(cols: number, rows: number) {
|
|
24
38
|
return createTerminalBounds(cols, rows)
|
|
25
39
|
}
|
|
@@ -138,6 +152,35 @@ export function useTerminalResize({
|
|
|
138
152
|
.map((t) => [t.id, t.scrollIntent])
|
|
139
153
|
)
|
|
140
154
|
|
|
155
|
+
const activeTabIdRef = useRef(state.activeTabId)
|
|
156
|
+
activeTabIdRef.current = state.activeTabId
|
|
157
|
+
// Last size we pushed to the backend per tab, used to dedupe the measurement
|
|
158
|
+
// loop so an unchanged box never re-triggers a resize.
|
|
159
|
+
const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
|
|
160
|
+
|
|
161
|
+
// Closed measurement loop: the rendered terminal content box reports its
|
|
162
|
+
// real geometry; that — not the hardcoded chrome model below — is the
|
|
163
|
+
// authority for the PTY/xterm size and the mouse-mapping origin. The model
|
|
164
|
+
// cascade still runs for bootstrap and for tabs whose pane isn't mounted yet;
|
|
165
|
+
// this corrects any residual divergence (status-bar wrap, split rounding, …).
|
|
166
|
+
const handleMeasure = useCallback(
|
|
167
|
+
(tabId: string, rect: MeasuredPaneRect): void => {
|
|
168
|
+
const cols = Math.max(PTY_MIN_COLS, rect.cols)
|
|
169
|
+
const rows = Math.max(PTY_MIN_ROWS, rect.rows)
|
|
170
|
+
const isActive = tabId === activeTabIdRef.current
|
|
171
|
+
if (isActive) {
|
|
172
|
+
contentOriginRef.current = { cols, rows, x: rect.x, y: rect.y }
|
|
173
|
+
}
|
|
174
|
+
const prev = measuredRef.current.get(tabId)
|
|
175
|
+
if (prev && prev.cols === cols && prev.rows === rows) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
measuredRef.current.set(tabId, { cols, rows })
|
|
179
|
+
backend.resizeTab(tabId, cols, rows, intentsRef.current.get(tabId))
|
|
180
|
+
},
|
|
181
|
+
[backend, contentOriginRef]
|
|
182
|
+
)
|
|
183
|
+
|
|
141
184
|
const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
|
|
142
185
|
const terminalSize = useMemo(() => {
|
|
143
186
|
const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
|
|
@@ -234,5 +277,5 @@ export function useTerminalResize({
|
|
|
234
277
|
stableTabIds,
|
|
235
278
|
])
|
|
236
279
|
|
|
237
|
-
return terminalSize
|
|
280
|
+
return { cols: terminalSize.cols, onMeasure: handleMeasure, rows: terminalSize.rows }
|
|
238
281
|
}
|
package/src/app.tsx
CHANGED
|
@@ -478,6 +478,7 @@ export function App({
|
|
|
478
478
|
onSeparatorDrag={handleSeparatorDrag}
|
|
479
479
|
onSeparatorDragEnd={handleSeparatorDragEnd}
|
|
480
480
|
onSidebarResizeStart={handleSidebarResizeStart}
|
|
481
|
+
onMeasure={terminalSize.onMeasure}
|
|
481
482
|
terminalCols={terminalSize.cols}
|
|
482
483
|
terminalRows={terminalSize.rows}
|
|
483
484
|
/>
|
package/src/pty/pty-manager.ts
CHANGED
|
@@ -24,6 +24,12 @@ interface SessionHandle {
|
|
|
24
24
|
pendingModeSequence: string
|
|
25
25
|
pendingWrites: number
|
|
26
26
|
pendingExitCode: number | null
|
|
27
|
+
/** Scroll intent from the most recent resize, re-applied after the
|
|
28
|
+
* parser drains the data that was queued across the resize. */
|
|
29
|
+
lastScrollIntent: ScrollIntent | undefined
|
|
30
|
+
/** Set when a resize landed while writes were in flight; the viewport is
|
|
31
|
+
* re-anchored once pendingWrites reaches 0. */
|
|
32
|
+
reanchorAfterDrain: boolean
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
const ESC = '\x1b'
|
|
@@ -255,12 +261,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
255
261
|
alternateScrollMode: false,
|
|
256
262
|
cursorVisible: true,
|
|
257
263
|
emulator,
|
|
264
|
+
lastScrollIntent: undefined,
|
|
258
265
|
lastSnapshot: undefined,
|
|
259
266
|
lastTerminalModes: undefined,
|
|
260
267
|
pendingExitCode: null,
|
|
261
268
|
pendingModeSequence: '',
|
|
262
269
|
pendingWrites: 0,
|
|
263
270
|
pty,
|
|
271
|
+
reanchorAfterDrain: false,
|
|
264
272
|
tabId: options.tabId,
|
|
265
273
|
}
|
|
266
274
|
|
|
@@ -283,6 +291,15 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
283
291
|
this.scheduleDataRender(session)
|
|
284
292
|
emulator.write(data, () => {
|
|
285
293
|
session.pendingWrites -= 1
|
|
294
|
+
|
|
295
|
+
if (session.pendingWrites === 0 && session.reanchorAfterDrain) {
|
|
296
|
+
// The data queued across a resize has now been parsed into the
|
|
297
|
+
// reflowed buffer. Re-anchor the viewport before it is snapshotted
|
|
298
|
+
// so the active screen — not stale scrollback — is what renders.
|
|
299
|
+
session.reanchorAfterDrain = false
|
|
300
|
+
this.applyScrollIntent(session, session.lastScrollIntent)
|
|
301
|
+
}
|
|
302
|
+
|
|
286
303
|
this.scheduleDataRender(session)
|
|
287
304
|
|
|
288
305
|
if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
|
|
@@ -357,24 +374,46 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
357
374
|
session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
|
|
358
375
|
}
|
|
359
376
|
|
|
360
|
-
|
|
377
|
+
private applyResize(
|
|
378
|
+
session: SessionHandle,
|
|
361
379
|
cols: number,
|
|
362
380
|
rows: number,
|
|
363
|
-
|
|
364
|
-
|
|
381
|
+
intent: ScrollIntent | undefined,
|
|
382
|
+
sync: boolean
|
|
365
383
|
): void {
|
|
366
384
|
const safeCols = Math.max(20, cols)
|
|
367
385
|
const safeRows = Math.max(8, rows)
|
|
386
|
+
session.pty.resize(safeCols, safeRows)
|
|
387
|
+
session.emulator.resize(safeCols, safeRows)
|
|
388
|
+
session.lastScrollIntent = intent
|
|
389
|
+
this.applyScrollIntent(session, intent)
|
|
390
|
+
|
|
391
|
+
if (session.pendingWrites > 0) {
|
|
392
|
+
// Output produced by the child for the pre-resize size is still queued
|
|
393
|
+
// in the xterm parser. Snapshotting now would capture a torn buffer
|
|
394
|
+
// (reflowed but not yet redrawn); a plain shell never issues a full
|
|
395
|
+
// repaint, so the shifted content + dead rows would stick. Defer the
|
|
396
|
+
// snapshot to the drain path, which re-anchors the viewport first.
|
|
397
|
+
session.reanchorAfterDrain = true
|
|
398
|
+
this.scheduleDataRender(session)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (sync) {
|
|
403
|
+
this.flushRenderNow(session)
|
|
404
|
+
} else {
|
|
405
|
+
this.scheduleRender(session)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
368
408
|
|
|
409
|
+
resizeAll(
|
|
410
|
+
cols: number,
|
|
411
|
+
rows: number,
|
|
412
|
+
intents?: Map<string, ScrollIntent>,
|
|
413
|
+
options?: { sync?: boolean }
|
|
414
|
+
): void {
|
|
369
415
|
for (const session of this.sessions.values()) {
|
|
370
|
-
|
|
371
|
-
session.emulator.resize(safeCols, safeRows)
|
|
372
|
-
this.applyScrollIntent(session, intents?.get(session.tabId))
|
|
373
|
-
if (options?.sync) {
|
|
374
|
-
this.flushRenderNow(session)
|
|
375
|
-
} else {
|
|
376
|
-
this.scheduleRender(session)
|
|
377
|
-
}
|
|
416
|
+
this.applyResize(session, cols, rows, intents?.get(session.tabId), options?.sync ?? false)
|
|
378
417
|
}
|
|
379
418
|
}
|
|
380
419
|
|
|
@@ -389,16 +428,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
|
|
389
428
|
if (!session) {
|
|
390
429
|
return
|
|
391
430
|
}
|
|
392
|
-
|
|
393
|
-
const safeRows = Math.max(8, rows)
|
|
394
|
-
session.pty.resize(safeCols, safeRows)
|
|
395
|
-
session.emulator.resize(safeCols, safeRows)
|
|
396
|
-
this.applyScrollIntent(session, intent)
|
|
397
|
-
if (options?.sync) {
|
|
398
|
-
this.flushRenderNow(session)
|
|
399
|
-
} else {
|
|
400
|
-
this.scheduleRender(session)
|
|
401
|
-
}
|
|
431
|
+
this.applyResize(session, cols, rows, intent, options?.sync ?? false)
|
|
402
432
|
}
|
|
403
433
|
|
|
404
434
|
reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
|
|
@@ -152,9 +152,16 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
|
|
|
152
152
|
const lines: TerminalLine[] = []
|
|
153
153
|
const tailLines: TerminalLine[] = []
|
|
154
154
|
|
|
155
|
+
// The window starts at viewportY, but cursorY is relative to baseY. The
|
|
156
|
+
// cursor belongs on a rendered row only when its absolute buffer line
|
|
157
|
+
// (baseY + cursorY) equals that row's buffer line (startLine + row).
|
|
158
|
+
// Comparing row === cursorY was correct only while scrolled to the bottom
|
|
159
|
+
// (viewportY === baseY) and misplaced the cursor otherwise.
|
|
160
|
+
const cursorLine = buffer.baseY + cursorRow
|
|
155
161
|
for (let row = 0; row < terminal.rows; row += 1) {
|
|
162
|
+
const lineIndex = startLine + row
|
|
156
163
|
lines.push(
|
|
157
|
-
buildLine(terminal,
|
|
164
|
+
buildLine(terminal, lineIndex, lineIndex === cursorLine ? cursorColumn : null, cursorVisible)
|
|
158
165
|
)
|
|
159
166
|
}
|
|
160
167
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
2
|
|
|
3
|
+
import type { MeasuredPaneRect } from '../../../app-runtime/use-pane-size-report'
|
|
3
4
|
import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
|
|
4
5
|
import type { FocusMode, TabSession } from '../../../state/types'
|
|
5
6
|
|
|
@@ -39,6 +40,7 @@ interface SplitLayoutProps {
|
|
|
39
40
|
onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
|
|
40
41
|
onSeparatorDragEnd?: () => void
|
|
41
42
|
onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
|
|
43
|
+
onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
|
|
42
44
|
contentOrigin: TerminalContentOrigin
|
|
43
45
|
bounds: PaneRect
|
|
44
46
|
}
|
|
@@ -52,6 +54,7 @@ export function SplitLayout({
|
|
|
52
54
|
mouseForwardingEnabled,
|
|
53
55
|
node,
|
|
54
56
|
onLeftEdgeMouseDown,
|
|
57
|
+
onMeasure,
|
|
55
58
|
onPaneActivate,
|
|
56
59
|
onSeparatorDrag,
|
|
57
60
|
onSeparatorDragEnd,
|
|
@@ -104,6 +107,7 @@ export function SplitLayout({
|
|
|
104
107
|
onSeparatorDrag={onSeparatorDrag}
|
|
105
108
|
onSeparatorDragEnd={onSeparatorDragEnd}
|
|
106
109
|
onLeftEdgeMouseDown={onLeftEdgeMouseDown}
|
|
110
|
+
onMeasure={onMeasure}
|
|
107
111
|
/>
|
|
108
112
|
)
|
|
109
113
|
}
|
|
@@ -144,6 +148,7 @@ export function SplitLayout({
|
|
|
144
148
|
onSeparatorDrag={onSeparatorDrag}
|
|
145
149
|
onSeparatorDragEnd={onSeparatorDragEnd}
|
|
146
150
|
onLeftEdgeMouseDown={onLeftEdgeMouseDown}
|
|
151
|
+
onMeasure={onMeasure}
|
|
147
152
|
contentOrigin={contentOrigin}
|
|
148
153
|
bounds={firstBounds}
|
|
149
154
|
/>
|
|
@@ -187,6 +192,7 @@ export function SplitLayout({
|
|
|
187
192
|
onSeparatorDrag={onSeparatorDrag}
|
|
188
193
|
onSeparatorDragEnd={onSeparatorDragEnd}
|
|
189
194
|
onLeftEdgeMouseDown={secondLeftEdgeMouseDown}
|
|
195
|
+
onMeasure={onMeasure}
|
|
190
196
|
contentOrigin={contentOrigin}
|
|
191
197
|
bounds={secondBounds}
|
|
192
198
|
/>
|
|
@@ -5,6 +5,7 @@ import { memo, type ReactNode } from 'react'
|
|
|
5
5
|
import type { TerminalContentOrigin } from '../../../input/raw-input-handler'
|
|
6
6
|
import type { TabSession, TerminalSnapshot, TerminalSpan } from '../../../state/types'
|
|
7
7
|
|
|
8
|
+
import { type MeasuredPaneRect, usePaneSizeReport } from '../../../app-runtime/use-pane-size-report'
|
|
8
9
|
import { logInputDebug } from '../../../debug/input-log'
|
|
9
10
|
import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
|
|
10
11
|
import { type ContextMenuItem, openContextMenu } from '../../context-menu/controller'
|
|
@@ -28,6 +29,7 @@ interface TerminalPaneProps {
|
|
|
28
29
|
onSeparatorDrag?: (event: OtuiMouseEvent) => boolean
|
|
29
30
|
onSeparatorDragEnd?: () => void
|
|
30
31
|
onLeftEdgeMouseDown?: (event: OtuiMouseEvent) => boolean
|
|
32
|
+
onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
function getTitle(
|
|
@@ -112,6 +114,7 @@ export function TerminalPane({
|
|
|
112
114
|
localScrollbackEnabled,
|
|
113
115
|
mouseForwardingEnabled,
|
|
114
116
|
onLeftEdgeMouseDown,
|
|
117
|
+
onMeasure,
|
|
115
118
|
onPaneActivate,
|
|
116
119
|
onSeparatorDrag,
|
|
117
120
|
onSeparatorDragEnd,
|
|
@@ -124,6 +127,7 @@ export function TerminalPane({
|
|
|
124
127
|
tabId,
|
|
125
128
|
}: TerminalPaneProps) {
|
|
126
129
|
const t = useTheme()
|
|
130
|
+
const setContentBox = usePaneSizeReport(tabId, !!tab, onMeasure)
|
|
127
131
|
const editorBg = t.background
|
|
128
132
|
const paneIsActive = isActive ?? true
|
|
129
133
|
const canForwardMouse = focusMode === 'terminal-input' && !!tab && mouseForwardingEnabled
|
|
@@ -287,9 +291,11 @@ export function TerminalPane({
|
|
|
287
291
|
</box>
|
|
288
292
|
) : (
|
|
289
293
|
<box
|
|
294
|
+
ref={setContentBox}
|
|
290
295
|
flexDirection="column"
|
|
291
296
|
flexGrow={1}
|
|
292
297
|
width="100%"
|
|
298
|
+
overflow="hidden"
|
|
293
299
|
onMouseDown={(e) => {
|
|
294
300
|
e.stopPropagation()
|
|
295
301
|
forwardMouseEvent(e)
|
|
@@ -302,10 +308,18 @@ export function TerminalPane({
|
|
|
302
308
|
</box>
|
|
303
309
|
)}
|
|
304
310
|
</ContextMenuBox>
|
|
305
|
-
{tab?.status === 'disconnected' ? (
|
|
306
|
-
|
|
311
|
+
{tab?.status === 'disconnected' || tab?.errorMessage ? (
|
|
312
|
+
// Absolutely positioned so it overlays the bordered box instead of
|
|
313
|
+
// consuming a flex row. If it took a row, the rendered terminal area
|
|
314
|
+
// would be one line shorter than the size sent to the PTY/xterm,
|
|
315
|
+
// re-introducing the shifted-content / dead-row bug.
|
|
316
|
+
<box position="absolute" bottom={0} left={0} backgroundColor={editorBg}>
|
|
317
|
+
{tab?.status === 'disconnected' ? (
|
|
318
|
+
<text fg={t.warning}>Restored snapshot. Press Ctrl+r to restart this workspace.</text>
|
|
319
|
+
) : null}
|
|
320
|
+
{tab?.errorMessage ? <text fg={t.error}>{tab.errorMessage}</text> : null}
|
|
321
|
+
</box>
|
|
307
322
|
) : null}
|
|
308
|
-
{tab?.errorMessage ? <text fg={t.error}>{tab.errorMessage}</text> : null}
|
|
309
323
|
</box>
|
|
310
324
|
)
|
|
311
325
|
}
|
package/src/ui/root.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MouseEvent } from '@opentui/core'
|
|
2
2
|
|
|
3
|
+
import type { MeasuredPaneRect } from '../app-runtime/use-pane-size-report'
|
|
3
4
|
import type { TerminalContentOrigin } from '../input/raw-input-handler'
|
|
4
5
|
import type { FocusMode, ModalState, SessionRecord, SnippetRecord } from '../state/types'
|
|
5
6
|
import type { ThemeId } from './themes'
|
|
@@ -229,6 +230,7 @@ interface RootViewProps {
|
|
|
229
230
|
}) => void
|
|
230
231
|
onSeparatorDrag?: (event: MouseEvent) => boolean
|
|
231
232
|
onSeparatorDragEnd?: () => void
|
|
233
|
+
onMeasure?: (tabId: string, rect: MeasuredPaneRect) => void
|
|
232
234
|
terminalCols: number
|
|
233
235
|
terminalRows: number
|
|
234
236
|
}
|
|
@@ -239,6 +241,7 @@ export function RootView({
|
|
|
239
241
|
mouseForwardingEnabled,
|
|
240
242
|
onEmbeddedGitResizeStart,
|
|
241
243
|
onGitPaneResizeStart,
|
|
244
|
+
onMeasure,
|
|
242
245
|
onPaneActivate,
|
|
243
246
|
onSeparatorDrag,
|
|
244
247
|
onSeparatorDragEnd,
|
|
@@ -375,6 +378,7 @@ export function RootView({
|
|
|
375
378
|
onSeparatorDrag={onSeparatorDrag}
|
|
376
379
|
onSeparatorDragEnd={onSeparatorDragEnd}
|
|
377
380
|
onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
|
|
381
|
+
onMeasure={onMeasure}
|
|
378
382
|
bounds={{
|
|
379
383
|
cols: terminalCols + splitChrome,
|
|
380
384
|
rows: terminalRows + splitChrome,
|
|
@@ -398,6 +402,7 @@ export function RootView({
|
|
|
398
402
|
onTerminalMouseUp={onTerminalMouseUp}
|
|
399
403
|
onPaneActivate={onPaneActivate}
|
|
400
404
|
onLeftEdgeMouseDown={handleTerminalLeftEdgeMouseDown}
|
|
405
|
+
onMeasure={onMeasure}
|
|
401
406
|
/>
|
|
402
407
|
)}
|
|
403
408
|
{gitPaneMode === 'pane' && gitPaneVisible && gitPanePosition === 'right' ? (
|