mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf22464a5 | ||
|
|
705cf93681 | ||
|
|
0e60432de9 | ||
|
|
0e5bcbd9f5 | ||
|
|
a7d9bb34fc | ||
|
|
32a420db1b | ||
|
|
fd65985e9d | ||
|
|
254166e7c1 | ||
|
|
f56f277606 | ||
|
|
6b325bdbcd | ||
|
|
7ba608fcb2 |
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,6 +81,8 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -92,6 +94,9 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
+35
@@ -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) } }
|
||||
|
||||
|
||||
-1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -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 {
|
||||
|
||||
+17
-21
@@ -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] {
|
||||
|
||||
@@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
resolveRelativeFiles: true,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
|
||||
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const lookup = Promise.withResolvers<{
|
||||
entries: Array<{ path: string; kind: "file" }>;
|
||||
error: null;
|
||||
}>();
|
||||
const queries: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
resolveRelativeFiles: true,
|
||||
client: {
|
||||
listDirectory: async () => {
|
||||
throw new Error("Not a directory");
|
||||
},
|
||||
getDirectorySuggestions: async ({ query }: { query: string }) => {
|
||||
queries.push(query);
|
||||
return lookup.promise;
|
||||
},
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const earlier = opener.open(
|
||||
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
|
||||
"side",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(queries).toEqual(["schema/x.md"]);
|
||||
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
|
||||
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
|
||||
await earlier;
|
||||
expect(opened).toEqual(["guide.md"]);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: PathOpenInput["client"];
|
||||
client: {
|
||||
listDirectory(root: string, path: string): Promise<unknown>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
} | null;
|
||||
resolveRelativeFiles?: boolean;
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const resolvedTarget =
|
||||
input.resolveRelativeFiles && input.client
|
||||
? await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
|
||||
})
|
||||
: target;
|
||||
if (ownGeneration !== generation) return;
|
||||
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
|
||||
if (!resolved?.file) return;
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
path: resolved.file,
|
||||
lineStart: resolvedTarget.lineStart,
|
||||
lineEnd: resolvedTarget.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
resolveMissingNestedRelativeFile,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
@@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissingNestedRelativeFile", () => {
|
||||
const target = {
|
||||
raw: "schema/x.md:12-14",
|
||||
path: "/Users/test/project/schema/x.md",
|
||||
lineStart: 12,
|
||||
lineEnd: 14,
|
||||
};
|
||||
|
||||
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "/Users/test/project",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
...target,
|
||||
path: "/Users/test/project/repo-a/schema/x.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the exact workspace path when suggestions return that relative path", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when suggestions are empty", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when the daemon throws", async () => {
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
|
||||
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
|
||||
["a home-relative path", { ...target, path: "~/schema/x.md" }],
|
||||
])("does not search %s", async (_label, candidate) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: candidate,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(candidate);
|
||||
});
|
||||
|
||||
it("does not search when the workspace root is blank", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: " ",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["C:/repo/schema/x.md", "C:/repo"],
|
||||
["\\\\server\\share\\schema\\x.md", "//server/share"],
|
||||
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: absoluteTarget,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(absoluteTarget);
|
||||
expect(searches).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
|
||||
"keeps folder href %s direct",
|
||||
async (raw) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
|
||||
});
|
||||
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: folderTarget,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(folderTarget);
|
||||
expect(searches).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes a Windows workspace root before the suffix lookup", async () => {
|
||||
const windowsTarget = {
|
||||
raw: "schema/x.md",
|
||||
path: "C:\\repo\\schema\\x.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
};
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: windowsTarget,
|
||||
workspaceRoot: "C:\\repo\\",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "C:\\repo\\",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -105,6 +106,62 @@ export async function fetchDaemonResolution({
|
||||
};
|
||||
}
|
||||
|
||||
/** Only file-looking paths nested inside the workspace need suffix search. */
|
||||
function nestedRelativeLookupQuery(
|
||||
targetPath: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const root = workspaceRoot?.trim();
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = targetPath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (!normalizedPath.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const relative = normalizedPath.slice(prefix.length);
|
||||
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
|
||||
* missing, ask the same suffix lookup bare names already use. An exact hit
|
||||
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
|
||||
* Lookup failure keeps the original target.
|
||||
*/
|
||||
export async function resolveMissingNestedRelativeFile(input: {
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}): Promise<InlinePathTarget> {
|
||||
if (
|
||||
isAbsoluteInlineCodeToken(input.target.raw) ||
|
||||
input.target.raw.startsWith("~/") ||
|
||||
input.target.raw.endsWith("/")
|
||||
) {
|
||||
return input.target;
|
||||
}
|
||||
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
|
||||
if (!query) {
|
||||
return input.target;
|
||||
}
|
||||
try {
|
||||
return await fetchDaemonResolution({
|
||||
ambiguousQuery: query,
|
||||
token: input.target.raw,
|
||||
target: input.target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.getDirectorySuggestions,
|
||||
});
|
||||
} catch {
|
||||
return input.target;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -126,17 +126,13 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./input-draft"));
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
const { useAgentInputDraft } = await import("./input-draft");
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
@@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
}
|
||||
).__DEV__ = false;
|
||||
|
||||
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
|
||||
});
|
||||
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
|
||||
await import("./use-agent-history");
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -3505,6 +3505,10 @@ export class AgentManager {
|
||||
"agent.rewind.complete",
|
||||
);
|
||||
} catch (error) {
|
||||
// Native rewind can commit a fork before history refresh fails. Recovery
|
||||
// must resume that latest handle rather than the abandoned conversation.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.failed",
|
||||
|
||||
@@ -53,6 +53,7 @@ const CAPABILITIES = [
|
||||
interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
closeAcknowledgementOnly?: boolean;
|
||||
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
}
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "session.closed", sessionId: input.sessionId });
|
||||
if (options.closeAcknowledgementOnly) return;
|
||||
}
|
||||
if ("requestId" in input) {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
function createRewindProviderHarness(
|
||||
options: {
|
||||
reuseAnswerId?: boolean;
|
||||
forkOnRevert?: boolean;
|
||||
handleInput?: ProviderHarnessOptions["handleInput"];
|
||||
} = {},
|
||||
) {
|
||||
const nativeMessages: string[] = [];
|
||||
let originalMessages: string[] | null = null;
|
||||
const harness = createProviderHarness({
|
||||
closeAcknowledgementOnly: true,
|
||||
capabilities: [
|
||||
...CAPABILITIES,
|
||||
"session.revert.conversation",
|
||||
"session.revert.files",
|
||||
"session.revert.both",
|
||||
],
|
||||
async handleInput(input, emit) {
|
||||
if (await options.handleInput?.(input, emit)) return true;
|
||||
if (input.type === "session.open" && input.history === "replay") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [
|
||||
...CAPABILITIES,
|
||||
"session.revert.conversation",
|
||||
"session.revert.files",
|
||||
"session.revert.both",
|
||||
],
|
||||
restoration: "core",
|
||||
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
const messages =
|
||||
options.forkOnRevert &&
|
||||
(input.persistence?.data as { token?: string })?.token === "root" &&
|
||||
originalMessages
|
||||
? originalMessages
|
||||
: nativeMessages;
|
||||
for (const [index, text] of messages.entries()) {
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
|
||||
text: `Reply ${text}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.prompt") {
|
||||
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
|
||||
const text = input.prompt.input.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("");
|
||||
nativeMessages.push(text);
|
||||
const turnId = `turn-${text}`;
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: `user-${text}`,
|
||||
text,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
revertToken: text,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
|
||||
text: `Reply ${text}`,
|
||||
},
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.revert") {
|
||||
if (options.forkOnRevert && input.scope !== "files") {
|
||||
originalMessages = [...nativeMessages];
|
||||
emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
});
|
||||
}
|
||||
if (input.scope !== "files")
|
||||
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
return { ...harness, nativeMessages };
|
||||
}
|
||||
|
||||
async function withRewindAgent(
|
||||
run: (context: {
|
||||
manager: AgentManager;
|
||||
agentId: string;
|
||||
nativeMessages: string[];
|
||||
messages: () => string[];
|
||||
}) => Promise<void>,
|
||||
options: Parameters<typeof createRewindProviderHarness>[0] = {},
|
||||
) {
|
||||
const harness = createRewindProviderHarness(options);
|
||||
const logger = createTestLogger();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
clients: registry.clients(),
|
||||
providerDefinitions: registry.definitions(),
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir() },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const messages = () =>
|
||||
manager
|
||||
.getTimeline(agent.id)
|
||||
.flatMap((item) =>
|
||||
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
|
||||
);
|
||||
try {
|
||||
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
|
||||
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
|
||||
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
|
||||
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
|
||||
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
|
||||
await manager.rewind(agentId, "user-B", "conversation");
|
||||
const afterRewind = messages();
|
||||
expect(nativeMessages).toEqual(["A"]);
|
||||
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
|
||||
const afterReplacement = messages();
|
||||
expect(nativeMessages).toEqual(["A", "D"]);
|
||||
expect({ afterRewind, afterReplacement }).toEqual({
|
||||
afterRewind: ["A", "Reply A"],
|
||||
afterReplacement: ["A", "Reply A", "D", "Reply D"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
|
||||
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
|
||||
await manager.rewind(agentId, "user-B", "files");
|
||||
expect(nativeMessages).toEqual(["A", "B", "C"]);
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
|
||||
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
|
||||
const harness = createRewindProviderHarness({ reuseAnswerId: true });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
|
||||
const live: AgentStreamEvent[] = [];
|
||||
try {
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
session.subscribe((event) => live.push(event));
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
live.length = 0;
|
||||
await session.run("B revised");
|
||||
const assistant = live.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
|
||||
expect(assistant).toEqual(["Reply B revised"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
async function withPluginRewindSession(
|
||||
options: Parameters<typeof createRewindProviderHarness>[0],
|
||||
run: (
|
||||
session: Awaited<ReturnType<AgentClient["createSession"]>>,
|
||||
harness: ReturnType<typeof createRewindProviderHarness>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const harness = createRewindProviderHarness(options);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
|
||||
try {
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
await session.run("C");
|
||||
await run(session, harness);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
|
||||
const events = await Array.fromAsync(session.streamHistory());
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
(event.item.type === "user_message" || event.item.type === "assistant_message")
|
||||
? [event.item.text]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
test("combined plugin rewind replays the retained native branch", async () => {
|
||||
await withPluginRewindSession({}, async (session) => {
|
||||
await session.revertBoth!({ messageId: "user-B" });
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
expect(await pluginMessages(session)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.revert")
|
||||
emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
});
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
history: "replay",
|
||||
});
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("failed native plugin rewind preserves history and does not reopen", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.revert") return false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "native rewind failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"native rewind failed",
|
||||
);
|
||||
expect(await pluginMessages(session)).toEqual([
|
||||
"A",
|
||||
"Reply A",
|
||||
"B",
|
||||
"Reply B",
|
||||
"C",
|
||||
"Reply C",
|
||||
]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
|
||||
let failReplay = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
await session.run("D");
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
|
||||
let failReplay = true;
|
||||
await withRewindAgent(
|
||||
async ({ manager, agentId, messages }) => {
|
||||
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
|
||||
await manager.rewind(agentId, "user-B", "conversation");
|
||||
expect(messages()).toEqual(["A", "Reply A"]);
|
||||
await manager.runAgent(agentId, "D");
|
||||
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
|
||||
},
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
|
||||
let failReplay = true;
|
||||
await withRewindAgent(
|
||||
async ({ manager, agentId, messages }) => {
|
||||
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
await manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
expect(messages()).toEqual(["A", "Reply A"]);
|
||||
},
|
||||
{
|
||||
forkOnRevert: true,
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("closing during plugin rewind replay closes each native binding once", async () => {
|
||||
let opened!: () => void;
|
||||
let release!: () => void;
|
||||
const replayStarted = new Promise<void>((resolve) => {
|
||||
opened = resolve;
|
||||
});
|
||||
const replayAllowed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input) => {
|
||||
if (input.type === "session.open" && input.history === "replay") {
|
||||
opened();
|
||||
await replayAllowed;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const rewind = session.revertConversation!({ messageId: "user-B" });
|
||||
await replayStarted;
|
||||
const closing = session.close();
|
||||
release();
|
||||
await Promise.all([rewind, closing]);
|
||||
const closes = harness.inputs.flatMap((input) =>
|
||||
input.type === "session.close" ? [input.sessionId] : [],
|
||||
);
|
||||
expect(closes).toHaveLength(2);
|
||||
expect(new Set(closes).size).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind retries a failed close before reopening the native writer", async () => {
|
||||
let failClose = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.close" || !failClose) return false;
|
||||
failClose = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "close failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"close failed",
|
||||
);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind retries permission settlement before retiring its session", async () => {
|
||||
let failPermission = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input) => {
|
||||
if (input.type !== "session.permission" || !failPermission) return false;
|
||||
failPermission = false;
|
||||
throw new Error("permission delivery failed");
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
if (opened.type !== "session.open") throw new Error("Missing root");
|
||||
harness.emit({
|
||||
type: "session.permission",
|
||||
sessionId: opened.sessionId,
|
||||
request: { id: "pending", name: "tool", kind: "tool" },
|
||||
});
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"permission delivery failed",
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
|
||||
await withPluginRewindSession({}, async (session, harness) => {
|
||||
const root = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
if (root.type !== "session.open") throw new Error("Missing root");
|
||||
harness.emit({
|
||||
type: "session.permission",
|
||||
sessionId: root.sessionId,
|
||||
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
|
||||
});
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(harness.inputs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.permission",
|
||||
permissionId: "old-permission",
|
||||
response: { behavior: "deny", message: "Session replaced after rewind" },
|
||||
}),
|
||||
);
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
|
||||
});
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
|
||||
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "child-1",
|
||||
parentSessionId: reopened.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
|
||||
});
|
||||
const next = await Array.fromAsync(session.streamHistory());
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({ text: "New child reply" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open") return false;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
|
||||
restoration: "core",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
expect(session.capabilities.supportsRewindConversation).toBe(false);
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"requires session persistence",
|
||||
);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
|
||||
await session.revertFiles!({ messageId: "user-B" });
|
||||
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
|
||||
for (const [sessionId, parentSessionId] of [
|
||||
["a", rootSessionId],
|
||||
|
||||
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
|
||||
>();
|
||||
private readonly activeTurnIds = new Set<string>();
|
||||
private terminal = false;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||
persistence: ProviderPersistence | null = null;
|
||||
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
return;
|
||||
}
|
||||
this.closePromise ??= this.closeNativeSession().catch((error) => {
|
||||
this.closePromise = null;
|
||||
throw error;
|
||||
});
|
||||
await this.closePromise;
|
||||
}
|
||||
|
||||
private async closeNativeSession(): Promise<void> {
|
||||
try {
|
||||
await this.runtime.complete({
|
||||
type: "session.close",
|
||||
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.runtime.isClosed) throw error;
|
||||
} finally {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
// A failed close still needs its routing for a subsequent session.closed ack.
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
|
||||
beginOpen(requestId: string): Promise<void> {
|
||||
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
|
||||
persistence: input.persistence,
|
||||
history: input.history,
|
||||
});
|
||||
const session = new PluginAgentSession(this.provider, bridge, () => {
|
||||
this.rootsBySession.delete(bridge.id);
|
||||
});
|
||||
this.rootsBySession.set(bridge.id, session);
|
||||
this.attachPendingChildren();
|
||||
const forget = () => {
|
||||
for (const [id, owner] of this.rootsBySession) {
|
||||
if (owner === session) this.rootsBySession.delete(id);
|
||||
}
|
||||
};
|
||||
const bind = (next: ProviderRuntimeSession) => {
|
||||
this.rootsBySession.set(next.id, session);
|
||||
this.attachPendingChildren();
|
||||
};
|
||||
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
|
||||
const session = new PluginAgentSession(
|
||||
this.provider,
|
||||
bridge,
|
||||
forget,
|
||||
async (persistence, state) => {
|
||||
forget();
|
||||
return await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
config: {
|
||||
...config,
|
||||
model: state.model,
|
||||
mode: state.mode,
|
||||
thinkingOption: state.thinkingOption,
|
||||
settings: Object.fromEntries(
|
||||
state.settings.map((setting) => [setting.id, setting.value]),
|
||||
),
|
||||
},
|
||||
persistence,
|
||||
history: "replay",
|
||||
});
|
||||
},
|
||||
bind,
|
||||
);
|
||||
bind(bridge);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
|
||||
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
|
||||
private readonly subagentIdsBySession = new Map<string, string | null>();
|
||||
private readonly childUnsubscribes = new Map<string, () => void>();
|
||||
private readonly children = new Map<
|
||||
string,
|
||||
{ session: ProviderRuntimeSession; unsubscribe: () => void }
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private closed = false;
|
||||
private replay: {
|
||||
persistence: ProviderPersistence;
|
||||
config: ProviderConfigState;
|
||||
messageId: string;
|
||||
scope: "conversation" | "both";
|
||||
closed: boolean;
|
||||
} | null = null;
|
||||
private replaying: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private readonly bridge: ProviderRuntimeSession,
|
||||
private bridge: ProviderRuntimeSession,
|
||||
private readonly onClose: () => void,
|
||||
private readonly reopen: (
|
||||
persistence: ProviderPersistence,
|
||||
state: ProviderConfigState,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
get capabilities(): AgentCapabilityFlags {
|
||||
return agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
const resumable = this.canReplayHistory;
|
||||
return {
|
||||
...capabilities,
|
||||
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
|
||||
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
|
||||
};
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions = {},
|
||||
): Promise<{ turnId: string }> {
|
||||
this.assertReplayComplete();
|
||||
const clientMessageId = options.clientMessageId ?? randomUUID();
|
||||
const result = await this.bridge.prompt({
|
||||
clientMessageId,
|
||||
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
this.assertReplayComplete();
|
||||
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.ensureReplay();
|
||||
for (const event of this.history.read(this.provider)) yield event;
|
||||
}
|
||||
|
||||
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ mode: modeId });
|
||||
}
|
||||
|
||||
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
|
||||
this.childUnsubscribes.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.listeners.clear();
|
||||
await this.replaying?.catch(() => undefined);
|
||||
await this.clearBinding();
|
||||
this.replay = null;
|
||||
this.listeners.clear();
|
||||
this.onClose();
|
||||
await this.bridge.close();
|
||||
@@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ model: modelId });
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({
|
||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
||||
});
|
||||
@@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession {
|
||||
const snapshots = new Map<string, TimelineTextSnapshot>();
|
||||
this.childSnapshots.set(childId, snapshots);
|
||||
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
|
||||
this.childUnsubscribes.set(
|
||||
child.id,
|
||||
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
|
||||
);
|
||||
this.children.set(child.id, {
|
||||
session: child,
|
||||
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
|
||||
});
|
||||
}
|
||||
|
||||
private accept(event: ProviderEvent, live: boolean): void {
|
||||
@@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async clearBinding(): Promise<void> {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const child of this.children.values()) {
|
||||
child.unsubscribe();
|
||||
await child.session.close();
|
||||
}
|
||||
this.children.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.currentTurnId = null;
|
||||
}
|
||||
|
||||
private assertReplayComplete(): void {
|
||||
if (this.replay) {
|
||||
throw new Error(
|
||||
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A native rewind has already committed. Until replay succeeds, neither history
|
||||
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
|
||||
private async ensureReplay(): Promise<void> {
|
||||
if (!this.replay) return;
|
||||
if (this.closed) throw new StaleProviderSessionError(this.id);
|
||||
if (!this.replaying) {
|
||||
this.replaying = this.replayBinding().finally(() => {
|
||||
this.replaying = null;
|
||||
});
|
||||
}
|
||||
await this.replaying;
|
||||
}
|
||||
|
||||
private async replayBinding(): Promise<void> {
|
||||
const replay = this.replay!;
|
||||
if (!replay.closed) {
|
||||
for (const request of this.pendingPermissions.values()) {
|
||||
await this.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message: "Session replaced after rewind",
|
||||
});
|
||||
this.pendingPermissions.delete(request.id);
|
||||
this.permissionResponses.delete(request.id);
|
||||
}
|
||||
await this.clearBinding();
|
||||
await this.bridge.close();
|
||||
replay.closed = true;
|
||||
}
|
||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
||||
this.bridge = bridge;
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
|
||||
this.onBind(bridge);
|
||||
this.replay = null;
|
||||
}
|
||||
|
||||
private get canReplayHistory(): boolean {
|
||||
return (
|
||||
this.bridge.restoration === "core" &&
|
||||
this.bridge.persistence !== null &&
|
||||
this.bridge.negotiatedCapabilities.includes("session.persistence")
|
||||
);
|
||||
}
|
||||
|
||||
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
|
||||
const pending = this.replay;
|
||||
await this.ensureReplay();
|
||||
if (pending?.messageId === messageId && pending.scope === scope) return;
|
||||
if (scope !== "files" && !this.canReplayHistory) {
|
||||
throw new Error("Plugin conversation rewind requires session persistence for history replay");
|
||||
}
|
||||
const token = this.revertTokens.get(messageId);
|
||||
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
|
||||
await this.bridge.revert({
|
||||
@@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession {
|
||||
token,
|
||||
scope,
|
||||
});
|
||||
if (scope !== "files") {
|
||||
const persistence = this.bridge.persistence;
|
||||
if (!persistence) throw new Error("Plugin rewind removed session persistence");
|
||||
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
|
||||
await this.ensureReplay();
|
||||
}
|
||||
}
|
||||
|
||||
private publish(event: AgentStreamEvent): void {
|
||||
|
||||
@@ -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();
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -32,11 +32,13 @@ describe("zai usage source", () => {
|
||||
process.env["USERPROFILE"] = homeDir;
|
||||
for (const key of [
|
||||
"APPDATA",
|
||||
"XDG_CONFIG_HOME",
|
||||
"COPILOT_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_PAT",
|
||||
"CURSOR_ACCESS_TOKEN",
|
||||
"CURSOR_TOKEN",
|
||||
"Z_AI_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"GROK_API_KEY",
|
||||
@@ -116,11 +118,61 @@ describe("zai usage source", () => {
|
||||
details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("discovery returns a locator when fetch finds zai credentials", async () => {
|
||||
const previous = process.env["ZAI_API_KEY"];
|
||||
try {
|
||||
it("fetches Z.ai usage from Z_AI_API_KEY, the name the glm-acp-agent CLI uses", async () => {
|
||||
process.env["Z_AI_API_KEY"] = "zai_cli_token";
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(authorization).toBe("Bearer zai_cli_token");
|
||||
expect(zai).toMatchObject({ status: "available", planLabel: "GLM Coding Max" });
|
||||
});
|
||||
|
||||
it("reads Z.ai usage from the credentials file the glm-acp-agent CLI writes", async () => {
|
||||
const configDir = join(homeDir, ".config", "glm-acp-agent");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, "credentials.json"),
|
||||
JSON.stringify({ z_ai_api_key: "zai_file_token" }),
|
||||
);
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(authorization).toBe("Bearer zai_file_token");
|
||||
expect(zai).toMatchObject({ status: "available" });
|
||||
});
|
||||
|
||||
it("honours XDG_CONFIG_HOME for the credentials file", async () => {
|
||||
const configDir = join(homeDir, "xdg", "glm-acp-agent");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, "credentials.json"),
|
||||
JSON.stringify({ z_ai_api_key: "zai_xdg_token" }),
|
||||
);
|
||||
process.env["XDG_CONFIG_HOME"] = join(homeDir, "xdg");
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(authorization).toBe("Bearer zai_xdg_token");
|
||||
expect(zai).toMatchObject({ status: "available" });
|
||||
});
|
||||
|
||||
it("discovery returns a locator when fetch finds zai credentials", async () => {
|
||||
process.env["ZAI_API_KEY"] = "fixture-token";
|
||||
let requested = false;
|
||||
await fetchFirst(async () => {
|
||||
@@ -131,10 +183,7 @@ it("discovery returns a locator when fetch finds zai credentials", async () => {
|
||||
expect(await discover()).toEqual([
|
||||
{ key: "default", input: { store: "env", locator: "ZAI_API_KEY" } },
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["ZAI_API_KEY"];
|
||||
else process.env["ZAI_API_KEY"] = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("account discovery", () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
unavailable,
|
||||
@@ -25,11 +28,16 @@ const ZaiUsageResponseSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** The credentials file the `glm-acp-agent` CLI writes. */
|
||||
const ZaiCredentialsSchema = z.object({
|
||||
z_ai_api_key: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
const token = process.env[input.locator];
|
||||
const token = await readToken(input);
|
||||
if (!token) throw new Error("Z.ai login store no longer exists");
|
||||
|
||||
const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", {
|
||||
@@ -64,9 +72,36 @@ export async function fetchUsage(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Z.AI API key comes from the environment or from the credentials file the
|
||||
* `glm-acp-agent` CLI writes. The CLI names the variable `Z_AI_API_KEY`; the
|
||||
* older `ZAI_API_KEY` spelling is still accepted.
|
||||
*/
|
||||
async function readToken(input: UsageInput): Promise<string | undefined> {
|
||||
if (input.store === "env") {
|
||||
return process.env[input.locator];
|
||||
}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8"));
|
||||
return ZaiCredentialsSchema.parse(raw).z_ai_api_key;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function discover(): Promise<UsageAccount[]> {
|
||||
for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) {
|
||||
for (const locator of ["Z_AI_API_KEY", "ZAI_API_KEY", "GLM_API_KEY"]) {
|
||||
if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }];
|
||||
}
|
||||
|
||||
const credentialsPath = join(
|
||||
process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
|
||||
"glm-acp-agent",
|
||||
"credentials.json",
|
||||
);
|
||||
if (await readToken({ store: "file", locator: credentialsPath })) {
|
||||
return [{ key: "default", input: { store: "file", locator: credentialsPath } }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z
|
||||
.object({
|
||||
store: z.literal("env"),
|
||||
store: z.enum(["env", "file"]),
|
||||
locator: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it
|
||||
configuration and persistence. Re-read credentials, environment, global configuration, and MCP
|
||||
servers during `session.open`. There is no separate reload operation.
|
||||
|
||||
Conversation and combined rewind require a core-restored session with persistence. After a
|
||||
successful rewind, Paseo closes the session and reopens its latest persistence value with
|
||||
`history: "replay"`. Publish the retained native branch before `session.ready`, including its
|
||||
children. File-only rewind keeps the current session and conversation history.
|
||||
|
||||
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
|
||||
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
|
||||
recreate it while restoring the parent.
|
||||
|
||||
@@ -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+",
|
||||
|
||||
Reference in New Issue
Block a user