Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] c9e56e1eb9 fix(server): preserve each refresh caller workspace recovery 2026-10-31 10:36:00 +00:00
paseo-bot[bot] 3274d67959 test(server): run concurrent refresh coverage in CI 2026-10-31 10:17:22 +00:00
paseo-bot[bot] 2fbc5f4b59 fix(server): capture reload reply before publishing updates 2026-10-31 10:14:00 +00:00
paseo-bot[bot]andtieo 833b4ef80a fix(server): keep concurrent refreshes within their daemon
Hold reload and history hydration together across client connections, and scope
pending work to the agent manager that owns it. Exercise the real daemon with a
slow-history provider, separate managers, shared failures and subsequent retries.

Co-authored-by: tieo <65707274+tieo@users.noreply.github.com>
2026-10-31 10:11:02 +00:00
tieo 39f1058005 fix(server): share concurrent agent refresh results
Keep a refresh in flight until provider history has been hydrated so concurrent
requests receive the same reload outcome across connections.
2026-10-31 10:10:21 +00:00
Mohamed Boudra fd65985e9d fix(android): resize header fades after rotation (#8349)
Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression.
2026-10-31 14:45:13 +07:00
Mohamed Boudra 254166e7c1 feat(terminal): add native selection handles (#8348)
* feat(terminal): add native selection handles

* fix(terminal): preserve selection at row boundaries

* fix(terminal): preserve selection boundary when handles cross
2026-10-31 14:23:10 +07:00
23 changed files with 1270 additions and 818 deletions
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
@@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -55,6 +56,40 @@ class TerminalRowLayoutTest {
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
@@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
paints.selectionForeground = argb(value.selectionForeground)
invalidate()
}
@@ -27,7 +27,6 @@ class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
@Field val selectionForeground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
canvas.drawRenderNode(recorded)
}
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
/** Draws backgrounds, the selection overlay, then text with its original colors. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints)
}
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val background =
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
if (background ushr 24 == 0) continue
fill.color = background
canvas.drawRect(
cellCol[cell] * cellWidth,
0f,
(cellCol[cell] + cellSpan[cell]) * cellWidth,
cellHeight,
fill,
)
val left = cellCol[cell] * cellWidth
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
val background = cellBackground[cell]
if (background ushr 24 != 0) {
fill.color = background
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
if (selected(row, selection, cell)) {
fill.color = paints.selectionBackground
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
}
val baseline = font.baselinePx.toFloat()
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = foreground(row, selection, first, paints)
val color = cellForeground[first]
var last = first
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell
canvas.save()
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = foreground(row, selection, cell, paints)
val foreground = cellForeground[cell]
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
@@ -291,9 +291,6 @@ internal class TerminalRowLayout(
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
@@ -301,6 +298,5 @@ internal class TerminalRowLayout(
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0xFFFFFF00.toInt()
var selectionForeground: Int = 0xFF000000.toInt()
var selectionBackground: Int = 0x472684FF
}
@@ -43,7 +43,6 @@ export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
selectionForeground: number;
}
export interface PaseoTerminalCellMetrics {
@@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView {
private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0xffff_ff00)),
selectionForeground: TerminalColor(Double(0xff00_0000)))
selectionBackground: TerminalColor(Double(0x4726_84ff)))
private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) {
@@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView {
background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground),
selectionForeground: TerminalColor(record.selectionForeground))
selectionBackground: TerminalColor(record.selectionBackground))
backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay()
}
@@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record {
@Field var background: Double = 0
@Field var cursor: Double = 0
@Field var selectionBackground: Double = 0
@Field var selectionForeground: Double = 0
}
struct TerminalCustomGlyphRecord: Record {
@@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
/// Colors a row is painted with besides its own styles.
struct TerminalRowPaint {
let selectionBackground: TerminalColor
let selectionForeground: TerminalColor
}
/// One row shaped once and placed on the terminal grid.
@@ -267,31 +266,33 @@ final class TerminalRowLayout {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
}
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
}
/// Draws the row with its top edge at `top`, selected cells in selection colors.
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
let cellWidth = font.cellWidth
let cellHeight = font.cellHeight
for cell in 0..<cellCount {
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
guard background.isPainted else { continue }
context.setFillColor(background.cgColor())
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
let background = cellBackground[cell]
if background.isPainted {
context.setFillColor(background.cgColor())
context.fill(rect)
}
if isSelected(cell, row: row, selection: selection) {
context.setFillColor(paint.selectionBackground.cgColor())
context.fill(rect)
}
}
let centerY = top + cellHeight / 2
let baselineInTextSpace = cellHeight / 2 - font.baseline
// A group whose cells differ in color or selection is drawn once per color, clipped to those
// A group whose cells differ in color is drawn once per color, clipped to those
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
for group in groups {
var first = group.firstCell
while first <= group.lastCell {
let color = foreground(first, row: row, selection: selection, paint: paint)
let color = cellForeground[first]
var last = first
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
while last < group.lastCell && cellForeground[last + 1] == color {
last += 1
}
context.saveGState()
@@ -315,7 +316,7 @@ final class TerminalRowLayout {
let baseline = top + font.baseline
for cell in 0..<cellCount {
let color = foreground(cell, row: row, selection: selection, paint: paint)
let color = cellForeground[cell]
let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] {
@@ -1,14 +1,13 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import {
Keyboard,
type LayoutChangeEvent,
PanResponder,
type PanResponderGestureState,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -47,13 +46,10 @@ import {
type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
createTerminalSelectionController,
terminalSelectionHandles,
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
@@ -87,38 +83,6 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6",
cursor: "#e6e6e6",
};
interface NativeTerminalPanState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
}
interface NativeTerminalSelectionPress {
startX: number;
startY: number;
startedAt: number;
startedWithSelection: boolean;
status: "pressing" | "selecting";
}
function resolvePanResponderPoint(
press: NativeTerminalSelectionPress,
event: GestureResponderEvent,
gesture: PanResponderGestureState,
): { x: number; y: number } {
const eventDx = event.nativeEvent.locationX - press.startX;
const eventDy = event.nativeEvent.locationY - press.startY;
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
return {
x: press.startX + dx,
y: press.startY + dy,
};
}
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
}
@@ -163,19 +127,14 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
const selectionControllerRef = useRef<ReturnType<
typeof createTerminalSelectionController
> | null>(null);
const gestureStartRef = useRef({ x: 0, y: 0 });
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
const paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder());
const panStateRef = useRef<NativeTerminalPanState>({
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(),
@@ -245,6 +204,7 @@ function NativeTerminalEmulator({
}, []);
const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range);
}, [commitSelectionRange]);
@@ -255,20 +215,14 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range);
},
[commitSelectionRange],
);
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return (
resolveMeasuredNativeTerminalSize({
@@ -523,8 +477,7 @@ function NativeTerminalEmulator({
emitMeasuredSize({ source: "measure" });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
selectionControllerRef.current?.cancel();
if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null;
@@ -534,7 +487,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]);
}, [emitMeasuredSize, resetTerminal, streamKey]);
useEffect(() => {
if (focusRequestToken <= 0) return;
@@ -591,6 +544,7 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" });
},
[emitMeasuredSize],
@@ -663,14 +617,6 @@ function NativeTerminalEmulator({
if (!coordinate) {
return;
}
const existingPress = selectionPressRef.current;
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: existingPress?.startedAt ?? Date.now(),
startedWithSelection: existingPress?.startedWithSelection ?? false,
status: "selecting",
};
const bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({
@@ -685,222 +631,61 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate],
);
const updateSelectionAtPoint = useCallback(
(point: { x: number; y: number }) => {
const terminal = terminalRef.current;
if (!terminal || selectionPressRef.current?.status !== "selecting") {
return;
}
const coordinate = resolveSelectionCoordinate(point);
if (!coordinate) {
return;
}
const snapshot = selectionModelRef.current.update({
coordinate,
bounds: terminal.getBufferBounds(),
});
commitSelectionRange(snapshot.range);
},
[commitSelectionRange, resolveSelectionCoordinate],
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
const selectionController = useMemo(
() =>
createTerminalSelectionController({
getGeometry: () => {
const viewport = resolveSelectionViewport();
const metrics = metricsRef.current;
return viewport && metrics
? { range: selectionRangeRef.current, viewport, metrics }
: null;
},
onBeginHandle: (endpoint, cols) =>
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
const terminal = terminalRef.current;
if (!terminal) return;
commitSelectionRange(
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
.range,
);
},
onLongPress: beginSelectionAtPoint,
onTap: focusTerminalFromTap,
onScroll: applyScroll,
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
cancel: (timer) => clearTimeout(timer),
}),
[
applyScroll,
beginSelectionAtPoint,
commitSelectionRange,
focusTerminalFromTap,
resolveSelectionViewport,
],
);
selectionControllerRef.current = selectionController;
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: Date.now(),
startedWithSelection: pressStatus === "selecting",
status: pressStatus,
};
clearSelectionLongPressTimeout();
if (pressStatus === "selecting") {
return;
}
selectionLongPressTimeoutRef.current = setTimeout(() => {
selectionLongPressTimeoutRef.current = null;
const press = selectionPressRef.current;
if (press?.status !== "pressing") {
return;
}
beginSelectionAtPoint({ x: press.startX, y: press.startY });
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
onPanResponderMove: (event, gesture) => {
const selectionPress = selectionPressRef.current;
const panState = panStateRef.current;
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
return;
}
if (panState.didNavigate) {
return;
}
if (selectionPress?.status === "pressing") {
const point = resolvePanResponderPoint(selectionPress, event, gesture);
const dx = point.x - selectionPress.startX;
const dy = point.y - selectionPress.startY;
const intent = classifyTerminalGestureIntent({
status: "pressing",
dx,
dy,
vx: gesture.vx,
vy: gesture.vy,
elapsedMs: Date.now() - selectionPress.startedAt,
});
if (intent === "tap" || intent === "pending") {
return;
}
clearSelectionLongPressTimeout();
panState.intent = intent;
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
return;
}
if (intent === "select") {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
updateSelectionAtPoint(point);
return;
}
}
},
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const releaseAction = resolveTerminalGestureReleaseAction({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
didNavigate,
movedBeyondTapTolerance,
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
},
onPanResponderTerminate: () => {
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
gestureStartRef.current = point;
selectionController.press(point);
},
onPanResponderMove: (_event, gesture) =>
selectionController.move({
x: gestureStartRef.current.x + gesture.dx,
y: gestureStartRef.current.y + gesture.dy,
}),
onPanResponderRelease: () => selectionController.release(),
onPanResponderTerminate: () => selectionController.cancel(),
}),
[
beginSelectionAtPoint,
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
updateSelectionAtPoint,
],
[selectionController],
);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -911,6 +696,11 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? (
<TerminalGridView
state={terminalState}
@@ -927,6 +717,41 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{handles.map((handle) => (
<View key={handle.endpoint}>
<View
style={[
styles.selectionStem,
{
left: Math.max(
0,
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
),
top: handle.stemTop,
height: handle.stemHeight,
},
]}
/>
<Svg
width={TERMINAL_SELECTION_GRIP_SIZE}
height={TERMINAL_SELECTION_GRIP_SIZE}
viewBox="0 0 18 18"
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
>
<Path
fill="#2684ff"
d={
handle.gripDirection === "left"
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
}
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
/>
</Svg>
</View>
))}
</View>
<TerminalInput
ref={inputRef}
isKeyboardVisible={isKeyboardVisible}
@@ -969,6 +794,8 @@ const styles = StyleSheet.create({
nativeGrid: {
flex: 1,
},
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: {
position: "absolute",
right: 12,
@@ -48,8 +48,7 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: 0xffffff00,
foreground: 0xff000000,
background: 0x472684ff,
} as const;
function build256Palette(theme: ITheme): number[] {
@@ -77,7 +77,6 @@ export function TerminalGridView({
background: resolver.backgroundColor,
cursor: resolver.cursorColor,
selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background,
selectionForeground: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}),
[resolver],
);
@@ -0,0 +1,411 @@
import { describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import {
createTerminalSelectionModel,
extractTerminalSelectedText,
resolveTerminalSelectionRects,
} from "./terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
} from "./terminal-selection-controller";
describe("selection handle controller", () => {
it("preserves the grab offset while moving a handle", () => {
const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 };
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 23, y: 15 });
controller.move({ x: 63, y: 15 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 5 }]);
});
});
it("selects on hold, preserves a handle tap, and cancels an active drag", () => {
const actions: string[] = [];
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint) => actions.push(endpoint),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: () => actions.push("scroll"),
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 90, y: 80 });
(timer as (() => void) | null)?.();
controller.release();
controller.press({ x: 20, y: 25 });
controller.release();
controller.press({ x: 20, y: 25 });
controller.cancel();
controller.move({ x: 50, y: 25 });
expect(actions).toEqual(["long", "start", "start"]);
});
it("scrolls selection into history while holding a handle at the edge", () => {
let firstRow = 10;
let timer: (() => void) | null = null;
const coordinates: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (point) => coordinates.push(point),
onLongPress: () => {},
onTap: () => {},
onScroll: (direction) => {
if (direction === "up") firstRow--;
},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 20, y: 25 });
controller.move({ x: 20, y: 1 });
(timer as (() => void) | null)?.();
(timer as (() => void) | null)?.();
controller.release();
expect(coordinates).toEqual([
{ row: 10, col: 2 },
{ row: 9, col: 2 },
{ row: 8, col: 2 },
]);
expect(timer).toBeNull();
});
it("scrolls outside handles without clearing selection and clears on an outside tap", () => {
const actions: string[] = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => actions.push("handle"),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: (direction, rows) => actions.push(`${direction}:${rows}`),
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 95, y: 70 });
controller.move({ x: 95, y: 90 });
controller.release();
controller.press({ x: 95, y: 70 });
controller.release();
expect(actions).toEqual(["up:2", "tap"]);
});
it("extends the selection after a long press and drag", () => {
let timer: (() => void) | null = null;
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: null,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 25, y: 25 });
(timer as (() => void) | null)?.();
controller.move({ x: 65, y: 35 });
controller.release();
expect(moves).toEqual([{ row: 3, col: 6 }]);
});
it.each([
{
endpoint: "start" as const,
startX: 20,
crossedX: 80,
crossedText: "gh",
crossedStart: 6,
crossedEnd: 7,
backX: 10,
backText: "bcdef",
backStart: 1,
backEnd: 5,
},
{
endpoint: "end" as const,
startX: 60,
crossedX: 10,
crossedText: "b",
crossedStart: 1,
crossedEnd: 1,
backX: 80,
backText: "cdefgh",
backStart: 2,
backEnd: 7,
},
])("tracks the $endpoint handle through crossing and back", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
const check = (start: number, end: number, text: string) => {
const range = model.getSnapshot().range;
expect(range).toEqual({
start: { row: 0, col: start },
end: { row: 0, col: end },
coordinateEpoch: bounds.coordinateEpoch,
});
expect(terminalSelectionHandles(geometry())).toMatchObject([
{ endpoint: "start", x: start * 10, y: 5 },
{ endpoint: "end", x: (end + 1) * 10, y: 5 },
]);
expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([
{
key: `0:${start}:${end}`,
x: start * 10,
y: 0,
width: (end - start + 1) * 10,
height: 10,
},
]);
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text);
};
controller.press({ x: scenario.startX, y: 5 });
controller.move({ x: scenario.crossedX, y: 5 });
check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText);
controller.move({ x: scenario.backX, y: 5 });
check(scenario.backStart, scenario.backEnd, scenario.backText);
controller.release();
} finally {
terminal.dispose();
}
});
it.each([
{
endpoint: "end" as const,
initialRow: 0,
targetRow: 1,
targetX: 0,
expectedRow: 0,
expectedCol: 9,
text: "cdefghij",
},
{
endpoint: "start" as const,
initialRow: 1,
targetRow: 0,
targetX: 100,
expectedRow: 1,
expectedCol: 0,
text: "KLMNOP",
},
])("preserves a $endpoint boundary at a row edge", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij\r\nKLMNOPQRST");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds });
model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 });
controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 });
const range = model.getSnapshot().range;
expect(range?.[scenario.endpoint]).toEqual({
row: scenario.expectedRow,
col: scenario.expectedCol,
});
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text);
expect(
terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint),
).toMatchObject({
x: scenario.endpoint === "end" ? 100 : 0,
y: scenario.expectedRow * 10 + 5,
});
controller.release();
} finally {
terminal.dispose();
}
});
it("cancels a drag when buffer coordinates are invalidated", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
model.begin({ coordinate: { row: 2, col: 2 }, bounds });
model.update({ coordinate: { row: 2, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const controller = createTerminalSelectionController({
getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 20, y: 25 });
const previous = model.getSnapshot().range;
const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } });
if (previous && !snapshot.range) controller.cancel();
controller.move({ x: 80, y: 25 });
controller.release();
expect(model.getSnapshot().range).toBeNull();
});
it("preserves a newly selected word through small movement at the viewport edge", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: model.getSnapshot().range,
viewport: { firstRow: 0, rows: 10, cols: 20 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 6 }, bounds });
},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 45, y: 5 });
const fire = timer as (() => void) | null;
timer = null;
fire?.();
controller.move({ x: 44, y: 4 });
controller.release();
expect(model.getSnapshot().range).toEqual({
start: { row: 0, col: 2 },
end: { row: 0, col: 6 },
coordinateEpoch: 0,
});
expect(timer).toBeNull();
});
it("places mirrored teardrop grips below the selected row", () => {
const handles = terminalSelectionHandles({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
});
expect(handles).toMatchObject([
{ endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" },
{ endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" },
]);
});
it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => {
const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 };
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const handles = terminalSelectionHandles({ range, viewport, metrics });
expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([
{ gripX: 9, gripY: 19 },
{ gripX: 91, gripY: 81 },
]);
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({ range, viewport, metrics }),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 9, y: 19 });
controller.move({ x: 19, y: 29 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 1 }]);
});
@@ -0,0 +1,208 @@
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
import type {
TerminalBufferCoordinate,
TerminalSelectionPoint,
TerminalSelectionRange,
TerminalSelectionViewport,
} from "./terminal-selection";
export const TERMINAL_SELECTION_GRIP_SIZE = 18;
const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
type Endpoint = "start" | "end";
interface Geometry {
range: TerminalSelectionRange | null;
viewport: TerminalSelectionViewport;
metrics: TerminalGridCellMetrics;
}
interface Ports {
getGeometry(): Geometry | null;
onBeginHandle(endpoint: Endpoint, cols: number): void;
onMoveHandle(coordinate: TerminalBufferCoordinate): void;
onLongPress(point: TerminalSelectionPoint): void;
onTap(): void;
onScroll(direction: "up" | "down", rows: number): void;
schedule(callback: () => void, delay: number): number;
cancel(timer: number): void;
}
export function terminalSelectionHandles(geometry: Geometry): Array<{
endpoint: Endpoint;
x: number;
y: number;
gripX: number;
gripY: number;
gripDirection: "left" | "right";
gripAbove: boolean;
stemTop: number;
stemHeight: number;
}> {
const { range, viewport, metrics } = geometry;
if (!range) return [];
return (["start", "end"] as const).flatMap((endpoint) => {
const cell = range[endpoint];
if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return [];
const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth;
const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight;
const size = TERMINAL_SELECTION_GRIP_SIZE;
const width = viewport.cols * metrics.cellWidth;
const height = viewport.rows * metrics.cellHeight;
const wantsLeft = endpoint === "start";
const gripDirection =
(wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right";
const gripAbove = stemTop + metrics.cellHeight + size > height;
const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight;
return [
{
endpoint,
x,
y: stemTop + metrics.cellHeight / 2,
gripX: x + (gripDirection === "left" ? -size / 2 : size / 2),
gripY: gripTop + size / 2,
gripDirection,
gripAbove,
stemTop,
stemHeight: metrics.cellHeight,
},
];
});
}
export function createTerminalSelectionController(ports: Ports) {
let press: {
start: TerminalSelectionPoint;
point: TerminalSelectionPoint;
endpoint: Endpoint | null;
offset: TerminalSelectionPoint;
fixed: TerminalBufferCoordinate | null;
moved: boolean;
long: boolean;
scrolling: boolean;
remainder: number;
} | null = null;
let timer: number | null = null;
function stopTimer() {
if (timer !== null) ports.cancel(timer);
timer = null;
}
function updateHandle() {
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const { viewport, metrics } = geometry;
const x = press.point.x - press.offset.x;
const y = press.point.y - press.offset.y;
let row =
viewport.firstRow +
Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight)));
let col = Math.floor(x / metrics.cellWidth);
if (press.endpoint && press.fixed) {
const boundary = Math.round(x / metrics.cellWidth);
const isEnd =
row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col);
col = boundary - (isEnd ? 1 : 0);
if (col < 0 && isEnd) {
row -= 1;
col = viewport.cols - 1;
} else if (col >= viewport.cols && !isEnd) {
row += 1;
col = 0;
}
}
col = Math.max(0, Math.min(viewport.cols - 1, col));
ports.onMoveHandle({ row, col });
}
function edgeTick() {
timer = null;
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const y = press.point.y - press.offset.y;
const height = geometry.viewport.rows * geometry.metrics.cellHeight;
let direction: "up" | "down" | null = null;
if (y < geometry.metrics.cellHeight) direction = "up";
else if (y > height - geometry.metrics.cellHeight) direction = "down";
if (!direction) return;
ports.onScroll(direction, 1);
if (!press) return;
updateHandle();
if (press) timer = ports.schedule(edgeTick, 100);
}
return {
press(point: TerminalSelectionPoint) {
stopTimer();
const geometry = ports.getGeometry();
const handles = geometry ? terminalSelectionHandles(geometry) : [];
const handle = handles
.map((item) => ({
endpoint: item.endpoint,
x: item.x,
y: item.y,
distance: Math.hypot(point.x - item.gripX, point.y - item.gripY),
}))
.filter((item) => item.distance <= 28)
.sort((a, b) => a.distance - b.distance)[0];
press = {
start: point,
point,
endpoint: handle?.endpoint ?? null,
offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 },
fixed:
handle && geometry?.range
? geometry.range[handle.endpoint === "start" ? "end" : "start"]
: null,
moved: false,
long: false,
scrolling: false,
remainder: 0,
};
if (handle && geometry) {
ports.onBeginHandle(handle.endpoint, geometry.viewport.cols);
return;
}
timer = ports.schedule(() => {
timer = null;
if (press && !press.moved) {
press.long = true;
ports.onLongPress(press.start);
}
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
move(point: TerminalSelectionPoint) {
if (!press) return;
const previous = press.point;
press.point = point;
const dx = point.x - press.start.x,
dy = point.y - press.start.y;
if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
press.moved = true;
if (!press.endpoint) stopTimer();
}
if (press.endpoint || press.long) {
if (press.endpoint || press.moved) {
updateHandle();
stopTimer();
if (press) timer = ports.schedule(edgeTick, 100);
}
} else if (!press.long) {
if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
press.scrolling = true;
press.remainder = dy;
} else if (press.scrolling) press.remainder += point.y - previous.y;
const height = ports.getGeometry()?.metrics.cellHeight ?? 0;
if (press.scrolling && height > 0) {
const rows = Math.trunc(press.remainder / height);
press.remainder -= rows * height;
if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows));
}
}
},
release() {
stopTimer();
if (press && !press.endpoint && !press.long && !press.moved) ports.onTap();
press = null;
},
cancel() {
stopTimer();
press = null;
},
};
}
@@ -1,354 +0,0 @@
import { describe, expect, it } from "vitest";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
} from "./terminal-selection-gesture";
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const rowDeltas: number[] = [];
for (const currentDy of [13, 15, 18, 22, 28]) {
const update = advanceTerminalScrollGesture({
state,
currentDy,
cellHeight: 14,
});
state = update.state;
rowDeltas.push(update.rowDelta);
}
expect({ rowDeltas, state }).toEqual({
rowDeltas: [0, 1, 0, 0, 1],
state: { lastDy: 28, rowRemainder: 0, didScroll: true },
});
});
});
describe("classifyTerminalGestureIntent", () => {
it("treats a motionless press as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 0,
dy: 0,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("treats a tiny jitter within tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("leaves horizontal movement available to terminal selection", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not infer selection from slow horizontal drag dwell", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 3,
vx: 0,
vy: 0,
elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
}),
).toEqual("pending");
});
it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 1,
vx: 2,
vy: 0,
elapsedMs: 20,
}),
).toEqual("tap");
});
it("keeps horizontal movement below navigation distance pending", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10,
dy: 2,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("treats a clearly vertical drag as scroll", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 4,
dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5,
vx: 0,
vy: 0.2,
}),
).toEqual("scroll");
});
it("does not navigate on a fast rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a slow rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a fast leftward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40),
dy: -4,
vx: -2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate while movement stays inside tap tolerance", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 2,
vx: 2,
vy: 0,
}),
).toEqual("tap");
});
it("keeps selection intent once selection is active", () => {
expect(
classifyTerminalGestureIntent({
status: "selecting",
dx: 100,
dy: 50,
vx: 2,
vy: 1,
}),
).toEqual("select");
});
});
describe("resolveTerminalGestureReleaseAction", () => {
it("focuses the terminal after a normal tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("focuses the terminal after a normal tap while scrolled", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("focus");
});
it("starts selection after a long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("starts selection after a 500ms hold", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 500,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("keeps a 400ms press as a tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 400,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("does nothing after a scroll gesture", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: true,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("none");
});
it("does nothing after a navigation swipe", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: true,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing while actively selecting", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("clears an existing selection after a short tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: true,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("clear");
});
it("preserves a word selection created by the current long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does not focus after an undecided fast drag", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing from idle", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "idle",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
});
});
@@ -1,108 +0,0 @@
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
export interface TerminalGestureMoveInput {
status: TerminalGestureStatus;
dx: number;
dy: number;
vx: number;
vy: number;
elapsedMs?: number;
}
export interface TerminalGestureReleaseInput {
status: TerminalGestureStatus;
startedWithSelection?: boolean;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
pressDurationMs: number;
longPressMs: number;
scrollMode: TerminalGestureScrollMode;
}
export interface TerminalScrollGestureState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
}
export interface TerminalScrollGestureUpdate {
state: TerminalScrollGestureState;
rowDelta: number;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
export function classifyTerminalGestureIntent(
input: TerminalGestureMoveInput,
): TerminalGestureIntent {
if (input.status === "selecting") {
return "select";
}
const absDx = Math.abs(input.dx);
const absDy = Math.abs(input.dy);
if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) {
return "scroll";
}
if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
return "pending";
}
return "tap";
}
export function advanceTerminalScrollGesture(input: {
state: TerminalScrollGestureState;
currentDy: number;
cellHeight: number;
}): TerminalScrollGestureUpdate {
const deltaY = input.currentDy - input.state.lastDy;
const accumulatedRows = input.state.rowRemainder + deltaY;
const rowDelta = Math.trunc(accumulatedRows / input.cellHeight);
return {
state: {
lastDy: input.currentDy,
rowRemainder: accumulatedRows - rowDelta * input.cellHeight,
didScroll: input.state.didScroll || rowDelta !== 0,
},
rowDelta,
};
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
if (input.didScroll || input.didNavigate) {
return "none";
}
if (input.status === "selecting") {
if (!input.startedWithSelection) {
return "none";
}
return input.movedBeyondTapTolerance ? "none" : "clear";
}
if (input.status !== "pressing") {
return "none";
}
if (input.pressDurationMs >= input.longPressMs) {
return "select";
}
if (input.movedBeyondTapTolerance) {
return "none";
}
return "focus";
}
@@ -483,3 +483,51 @@ describe("native terminal selection", () => {
expect(copied).toEqual([]);
});
});
describe("endpoint dragging", () => {
it.each([
{
endpoint: "start" as const,
fixed: { row: 2, col: 9 },
target: { row: 3, col: 2 },
expected: { row: 3, col: 0 },
},
{
endpoint: "end" as const,
fixed: { row: 2, col: 0 },
target: { row: 1, col: 7 },
expected: { row: 1, col: 9 },
},
])(
"preserves the fixed boundary across a row edge for $endpoint",
({ endpoint, fixed, target, expected }) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: fixed, bounds });
selection.update({ coordinate: fixed, bounds });
selection.beginEndpointDrag(endpoint, 10);
selection.update({ coordinate: target, bounds });
expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual(
expected,
);
},
);
it.each(["start", "end"] as const)(
"keeps the opposite endpoint fixed as %s crosses it",
(endpoint) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: { row: 2, col: 2 }, bounds });
selection.update({ coordinate: { row: 3, col: 4 }, bounds });
selection.beginEndpointDrag(endpoint, 10);
const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 };
selection.update({ coordinate, bounds });
expect(selection.getSnapshot().range).toEqual(
endpoint === "start"
? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 }
: { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 },
);
},
);
});
@@ -25,6 +25,7 @@ export interface TerminalSelectionBounds {
export interface TerminalSelectionModel {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
beginEndpointDrag(endpoint: "start" | "end", cols: number): void;
clear(): TerminalSelectionSnapshot;
sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot;
getSnapshot(): TerminalSelectionSnapshot;
@@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects(
export function createTerminalSelectionModel(): TerminalSelectionModel {
let state: TerminalSelectionState = { anchor: null, range: null };
let drag: { endpoint: "start" | "end"; cols: number } | null = null;
function snapshot(): TerminalSelectionSnapshot {
return { range: state.range };
@@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
const coordinate = clampCoordinate(input.coordinate, input.bounds);
const anchor = state.anchor ?? coordinate;
const fixed = state.anchor ?? coordinate;
let anchor = fixed;
if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) {
anchor =
fixed.col + 1 < drag.cols
? { row: fixed.row, col: fixed.col + 1 }
: { row: fixed.row + 1, col: 0 };
} else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) {
anchor =
fixed.col > 0
? { row: fixed.row, col: fixed.col - 1 }
: { row: fixed.row - 1, col: drag.cols - 1 };
}
const range = normalizeTerminalSelection({
anchor,
focus: coordinate,
coordinateEpoch: input.bounds.coordinateEpoch,
});
state = { anchor, range };
state = { anchor: fixed, range };
return snapshot();
}
return {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
drag = null;
const coordinate = clampCoordinate(input.coordinate, input.bounds);
state = {
anchor: coordinate,
@@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
return setSelection(input);
},
beginEndpointDrag(endpoint, cols) {
if (state.range) {
drag = { endpoint, cols };
state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] };
}
},
clear(): TerminalSelectionSnapshot {
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
@@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
if (!state.range || rangeIsSafe(state.range, input.bounds)) {
return snapshot();
}
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -0,0 +1,207 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import type { AgentClient, AgentSession } from "../agent/agent-sdk-types.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve };
}
const artifactRoot = path.resolve(".artifacts");
mkdirSync(artifactRoot, { recursive: true });
const resources: { daemon: TestPaseoDaemon; clients: DaemonClient[]; cwd: string }[] = [];
const releases: (() => void)[] = [];
afterEach(async () => {
releases.splice(0).forEach((release) => release());
for (const { daemon, clients, cwd } of resources.splice(0)) {
await Promise.all(clients.map((client) => client.close()));
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
});
function heldHistoryProvider() {
const historyStarted = deferred();
const releaseHistory = deferred();
releases.push(releaseHistory.resolve);
const base = createTestAgentClient("claude");
let resumes = 0;
const provider: AgentClient = {
provider: base.provider,
capabilities: base.capabilities,
createSession: (...args) => base.createSession(...args),
fetchCatalog: (...args) => base.fetchCatalog(...args),
isAvailable: (...args) => base.isAvailable(...args),
async resumeSession(...args) {
resumes += 1;
const session = await base.resumeSession(...args);
// Delay provider history through its public interface, keeping the manager real.
return new Proxy(session, {
get(target, key: keyof AgentSession) {
if (key === "streamHistory")
return async function* () {
historyStarted.resolve();
await releaseHistory.promise;
yield* target.streamHistory();
};
const value = Reflect.get(target, key);
return typeof value === "function" ? value.bind(target) : value;
},
});
},
};
return { provider, historyStarted, releaseHistory, resumes: () => resumes };
}
async function setup(provider: AgentClient, agentId?: string, appVersions = ["0.14.0", "0.14.0"]) {
const cwd = mkdtempSync(path.join(artifactRoot, "concurrent-refresh-"));
const daemon = await createTestPaseoDaemon({ agentClients: { claude: provider } });
const clients = appVersions.map(
(appVersion) => new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion }),
);
resources.push({ daemon, clients, cwd });
await Promise.all(clients.map((client) => client.connect()));
await Promise.all(clients.map((client) => client.fetchAgents({ subscribe: {} })));
const agent = await daemon.daemon.agentManager.createAgent({ provider: "claude", cwd }, agentId, {
workspaceId: undefined,
});
return { clients, agent, cwd };
}
test("refreshes from separate connections share the reload until history is ready", async () => {
const provider = heldHistoryProvider();
const { clients, agent } = await setup(provider.provider);
const first = clients[0].refreshAgent(agent.id);
await provider.historyStarted.promise;
const second = clients[1].refreshAgent(agent.id);
// The following RPC traverses the same connection after the second refresh request.
await clients[1].fetchAgents();
provider.releaseHistory.resolve();
const results = await Promise.all([first, second]);
expect(results.map((result) => result.agentId)).toEqual([agent.id, agent.id]);
expect(provider.resumes()).toBe(1);
await clients[0].refreshAgent(agent.id);
expect(provider.resumes()).toBe(2);
});
test("refreshing the same id in separate daemons does not share a provider reload", async () => {
const provider = heldHistoryProvider();
const firstDaemon = await setup(provider.provider);
const secondDaemon = await setup(createTestAgentClient("claude"), firstDaemon.agent.id);
const first = firstDaemon.clients[0].refreshAgent(firstDaemon.agent.id);
await provider.historyStarted.promise;
const second = secondDaemon.clients[0].refreshAgent(secondDaemon.agent.id);
await secondDaemon.clients[0].fetchAgents();
// The other daemon must finish without releasing the first one's provider history.
let deadline: ReturnType<typeof setTimeout> | undefined;
let independent: boolean;
try {
independent = await Promise.race([
second.then(() => true),
new Promise<boolean>((resolve) => {
deadline = setTimeout(() => resolve(false), 5000);
}),
]);
} finally {
clearTimeout(deadline);
}
provider.releaseHistory.resolve();
await Promise.all([first, second]);
expect(independent).toBe(true);
});
test("concurrent refreshes share a failure and a later retry starts a new reload", async () => {
const resumeStarted = deferred();
const releaseResume = deferred();
releases.push(releaseResume.resolve);
let resumes = 0;
const provider = createTestAgentClient("claude", {
beforeResumeSession: async () => {
resumes += 1;
if (resumes === 1) {
resumeStarted.resolve();
await releaseResume.promise;
throw new Error("provider resume failed");
}
},
});
const { clients, agent } = await setup(provider);
const first = clients[0].refreshAgent(agent.id);
// Attach handlers before releasing the rejected provider operation.
const outcome = Promise.allSettled([first]);
await resumeStarted.promise;
const second = clients[1].refreshAgent(agent.id);
const retryOutcome = Promise.allSettled([second]);
await clients[1].fetchAgents();
releaseResume.resolve();
const results = [...(await outcome), ...(await retryOutcome)];
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
for (const result of results) {
if (result.status === "rejected")
expect(String(result.reason)).toContain("provider resume failed");
}
expect(resumes).toBe(1);
await expect(clients[0].refreshAgent(agent.id)).resolves.toMatchObject({ agentId: agent.id });
expect(resumes).toBe(2);
});
test("a legacy caller restores its workspace when joining a modern refresh", async () => {
const provider = heldHistoryProvider();
const {
clients: [modern, legacy],
cwd,
} = await setup(provider.provider, undefined, ["0.1.105", "0.1.104"]);
// An independent repository prevents archive from owning the enclosing test checkout.
execFileSync("git", ["init", "-b", "main", cwd], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"fixture",
],
{ cwd, stdio: "pipe" },
);
const created = await modern.createWorkspace({ source: { kind: "directory", path: cwd } });
expect(created.error).toBeNull();
const workspace = created.workspace!;
const agent = await modern.createAgent({ provider: "claude", cwd, workspaceId: workspace.id });
await modern.archiveWorkspace(workspace.id);
expect((await modern.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id)).toBe(
false,
);
const first = modern.refreshAgent(agent.id);
await provider.historyStarted.promise;
expect((await modern.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id)).toBe(
false,
);
const joining = legacy.refreshAgent(agent.id);
const outcomes = Promise.allSettled([first, joining]);
try {
await expect
.poll(
async () =>
(await legacy.fetchWorkspaces()).entries.some((entry) => entry.id === workspace.id),
{ timeout: 5000 },
)
.toBe(true);
} finally {
provider.releaseHistory.resolve();
}
expect((await outcomes).map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]);
});
+56 -31
View File
@@ -682,6 +682,9 @@ interface ClientActivity {
appVisibilityChangedAt: Date;
}
// Connections on one daemon share refreshes; independent managers own independent agents.
const pendingAgentRefreshes = new WeakMap<AgentManager, Map<string, Promise<ManagedAgent>>>();
export class Session {
private readonly connectedHostSession: ConnectedHostSession | null;
private routed = false;
@@ -4759,39 +4762,26 @@ export class Session {
try {
await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId);
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
await this.interruptAgentIfRunning(agentId);
snapshot = await this.agentManager.reloadAgentSession(agentId, undefined, {
rehydrateFromDisk: true,
});
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
// Share the loader's per-agent in-flight operation with timeline fetches.
// Unarchiving publishes the record before provider resume finishes, so
// the agent pane can otherwise race this request and resume it twice.
snapshot = await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
broadcastTimeline: true,
logger: this.sessionLogger,
});
// Runtime replacement is serialized, but the reply also needs provider history.
// Another replacement during hydration can remove the agent before that reply.
// Share the whole reload and hydration across connections, including failures.
const refreshes =
pendingAgentRefreshes.get(this.agentManager) ?? new Map<string, Promise<ManagedAgent>>();
pendingAgentRefreshes.set(this.agentManager, refreshes);
let refresh = refreshes.get(agentId);
if (!refresh) {
const started = this.refreshAgentFromPersistence(agentId);
refresh = started;
refreshes.set(agentId, started);
void started
.catch(() => undefined)
.finally(() => {
if (refreshes.get(agentId) === started) refreshes.delete(agentId);
});
}
await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
await this.agentUpdates.forwardLiveAgent(snapshot);
const snapshot = await refresh;
const timelineSize = this.agentManager.getTimeline(agentId).length;
await this.agentUpdates.forwardLiveAgent(snapshot);
if (requestId) {
this.emit({
type: "status",
@@ -4829,6 +4819,41 @@ export class Session {
}
}
private async refreshAgentFromPersistence(agentId: string): Promise<ManagedAgent> {
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
await this.interruptAgentIfRunning(agentId);
snapshot = await this.agentManager.reloadAgentSession(agentId, undefined, {
rehydrateFromDisk: true,
});
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const registeredProviderIds = this.providerSnapshotManager.listRegisteredProviderIds();
if (!isStoredAgentProviderAvailable(record, registeredProviderIds)) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
if (!toAgentPersistenceHandle(registeredProviderIds, record.persistence)) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
// Share the loader's per-agent in-flight operation with timeline fetches.
// Unarchiving publishes the record before provider resume finishes, so
// the agent pane can otherwise race this request and resume it twice.
snapshot = await ensureAgentLoaded(agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
broadcastTimeline: true,
logger: this.sessionLogger,
});
}
await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
return snapshot;
}
private async handleCancelAgentRequest(agentId: string, requestId?: string): Promise<void> {
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`);
+127
View File
@@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644
}
return props.transform as TransformsStyle['transform'];
}
diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle
--- a/node_modules/react-native-svg/android/build.gradle
+++ b/node_modules/react-native-svg/android/build.gradle
@@ -103,6 +103,7 @@
}
defaultConfig {
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
minSdkVersion safeExtGet('minSdkVersion', 16)
//noinspection OldTargetApi
targetSdkVersion safeExtGet('targetSdkVersion', 28)
@@ -136,6 +137,8 @@
}
dependencies {
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
implementation 'com.facebook.react:react-native:+'
implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") {
exclude group: 'com.facebook.soloader'
diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
+++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
@@ -159,6 +159,8 @@
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
+ // Percentage lengths and paths depend on the canvas size, not just SVG props.
+ clearChildCache();
this.invalidate();
}
diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
new file mode 100644
--- /dev/null
+++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
@@ -0,0 +1,90 @@
+package com.horcrux.svg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.view.View;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.facebook.react.bridge.BridgeReactContext;
+import com.facebook.react.bridge.DynamicFromObject;
+import com.facebook.react.bridge.JavaOnlyArray;
+import com.facebook.react.bridge.JavaOnlyMap;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.react.uimanager.DisplayMetricsHolder;
+import com.facebook.soloader.SoLoader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SvgResizeTest {
+ @Test
+ public void percentageFadeFollowsCanvasGrowthAndShrinkage() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app);
+ try {
+ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
+ } catch (java.io.IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ BridgeReactContext context = new DrawingContext(app);
+ SvgView svg = new SvgView(context);
+ LinearGradientView gradient = new LinearGradientView(context);
+ gradient.setName("fade");
+ gradient.setX1(new DynamicFromObject("0%"));
+ gradient.setY1(new DynamicFromObject("0%"));
+ gradient.setX2(new DynamicFromObject("0%"));
+ gradient.setY2(new DynamicFromObject("100%"));
+ gradient.setGradientUnits(0);
+ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT));
+ svg.addView(gradient);
+ // Exercise recursive invalidation as well as the direct child case.
+ GroupView group = new GroupView(context);
+ RectView rectangle = new RectView(context);
+ rectangle.setX(new DynamicFromObject(0.0));
+ rectangle.setY(new DynamicFromObject(0.0));
+ rectangle.setWidth(new DynamicFromObject("100%"));
+ rectangle.setHeight(new DynamicFromObject("100%"));
+ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade"));
+ group.addView(rectangle);
+ svg.addView(group);
+
+ assertFade(svg, 80, 60);
+ assertFade(svg, 160, 120);
+ assertFade(svg, 60, 40);
+ assertFade(svg, 160, 120);
+ });
+ }
+
+ // Pixel rendering needs a React context but does not send layout events to JavaScript.
+ private static final class DrawingContext extends BridgeReactContext
+ implements com.facebook.react.uimanager.events.EventDispatcherProvider {
+ DrawingContext(android.content.Context context) { super(context); }
+
+ @Override
+ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; }
+ }
+
+ private static void assertFade(SvgView svg, int width, int height) {
+ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ svg.layout(0, 0, width, height);
+ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ svg.draw(new Canvas(image));
+ try {
+ int middle = Color.alpha(image.getPixel(width / 2, height / 2));
+ int right = Color.alpha(image.getPixel(width - 2, height / 2));
+ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height
+ + "; alpha=" + middle, middle >= 120 && middle <= 135);
+ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right);
+ assertTrue("Fade must end at the new bottom edge",
+ Color.alpha(image.getPixel(width / 2, height - 2)) < 20);
+ } finally {
+ image.recycle();
+ }
+ }
+}
+2
View File
@@ -59,6 +59,8 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+",
},
// Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes.
// The native pixel regression is included in the patch; preserve the separate transform hunks.
{
nodeModulesPath: "packages/app/node_modules/react-native-svg",
patchPrefix: "react-native-svg+",