Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] caf22464a5 test(usage): isolate the legacy login locator from inherited credentials 2026-10-31 12:04:32 +00:00
paseo-bot[bot] 705cf93681 Merge remote-tracking branch 'origin/main' into bot/diagnose-8309-7be72288 2026-10-31 11:57:29 +00:00
paseo-bot[bot]andpeanut_hzm 0e60432de9 fix(usage): narrow credential discovery repair to the verified GLM path
Keep the independently reproduced GLM discovery fix and isolate its file test from inherited XDG_CONFIG_HOME. Leave MiniMax quota routing for separate verification before changing existing API-key requests.

Co-authored-by: peanut_hzm <peanut_hzm@163.com>
2026-10-31 11:56:21 +00:00
paseo-bot[bot]andOuyang Xingyuan 0e5bcbd9f5 fix(app): open relative chat links inside nested repositories (#8361)
* fix(app): open a nested-repo file when the workspace-root path is missing

A workspace that contains another git repo often gets paths written
relative to that nested repo. `schema/x.md` is really
`repo-a/schema/x.md`. A markdown link was joined onto the workspace
root and opened a missing file. Inline code already asked the daemon
for a suffix match.

Resolved workspace-relative paths that contain a slash now use that
same lookup before opening. An exact hit stays on the original path.
A suffix hit replaces it and keeps the line range. A failed lookup
still opens the original path. Bare names, absolute paths, and `~/`
paths are unchanged.

Example:
- Input: workspace `/repo`, link `schema/x.md`, file at `repo-a/schema/x.md`
- Before: open `/repo/schema/x.md` (missing)
- After: open `/repo/repo-a/schema/x.md`

* fix(app): preserve explicit destinations when finding nested files

Relative chat links can name a file inside a nested repository. Retain the suffix lookup while using the original href and document base to preserve absolute paths, preview links, and folder destinations.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>

* fix(app): keep network share links on their absolute destination

Use the shared absolute-path classifier so network share hrefs retain their destination alongside drive and POSIX paths.

* fix(app): keep nested-file lookup inside ordered path opening

Resolve the original directory before considering suffix matches, and allocate click ordering before asynchronous lookup. The existing opener now owns the complete operation so a slow earlier click cannot replace a later selection.

* test(app): load UI modules before test lifecycle hooks

Cold module transformation exceeded the lifecycle hook deadline before these suites could run. Complete module initialization before registering the tests, retaining their assertions and timeout settings.

---------

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-31 11:05:06 +00:00
a7d9bb34fc fix(plugins): keep rewound conversations on their retained branch (#8368)
Refresh direct plugin history from authoritative native persistence after conversation/combined rewind, matching the built-in providers' history refresh strategy. Reopen/replay rebuilds translation baselines, rewind tokens and child bindings while preserving outward subscriptions; file-only rewind stays unchanged.

Keep replay internal and replace the manager timeline after it completes. Preserve the committed native handle and close routing across refresh failures so retry and reload recover the retained branch.

The full native session reload is intentionally inefficient, pending a smarter timeline cut that restores translation state, reused IDs, children and permissions without reopening.

Closes #4667
Supersedes #4668 and the rewind-history proposal in #4754.

Verified with 72 targeted tests, green CI, and real OMP browser UI A/B/C -> rewind B -> replacement D with native history/timeline equality and a clean browser reload.

Co-authored-by: Matt Cowger <matt@cowger.us>
Co-authored-by: Omer Cohen <639682+omercnet@users.noreply.github.com>
2026-10-31 17:48:42 +07:00
peanut_hzm 32a420db1b Merge remote-tracking branch 'origin/main' into fix/usage-source-cli-auth-locations 2026-10-31 15:47:12 +08:00
Mohamed Boudra fd65985e9d fix(android): resize header fades after rotation (#8349)
Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression.
2026-10-31 14:45:13 +07:00
Mohamed Boudra 254166e7c1 feat(terminal): add native selection handles (#8348)
* feat(terminal): add native selection handles

* fix(terminal): preserve selection at row boundaries

* fix(terminal): preserve selection boundary when handles cross
2026-10-31 14:23:10 +07:00
peanut_hzm f56f277606 Merge remote-tracking branch 'origin/main' into fix/usage-source-cli-auth-locations 2026-10-31 10:38:40 +08:00
peanut_hzm 6b325bdbcd Merge remote-tracking branch 'origin/main' into fix/usage-source-cli-auth-locations 2026-10-31 10:20:41 +08:00
peanut_hzm 7ba608fcb2 fix(usage): follow the CLIs' current auth locations for MiniMax and Z.ai
The usage sources look for credentials where the CLIs used to keep them, so
their plan quota never appears in Settings → Usage.

MiniMax Code (`mcode`) moved its OAuth login to
`~/.minimax/auth/<buildEnv>/<region>/<clientId>/auth.json`, with the token
inside a `records` map and the region in a sibling `auth-state.json`. The
source still looked in `~/.mmx/` for the older `access_token` shape. It also
called `/v1/token_plan/remains`, which the agent backend answers with
`status_code 1004` ("Please carry the API secret key in the 'Authorization'
field"); the coding-plan endpoint the CLI itself uses is
`/v1/api/openplatform/coding_plan/remains`.

Z.ai's `glm-acp-agent` reads `Z_AI_API_KEY` and persists it to
`~/.config/glm-acp-agent/credentials.json` as `z_ai_api_key`. The source
looked for `ZAI_API_KEY` and `GLM_API_KEY` only, and never read the
credentials file.

Both keep their existing lookups working, so a configured environment
variable or the older `~/.mmx/` layout still resolves.
2026-10-31 00:41:24 +08:00
36 changed files with 2211 additions and 857 deletions
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
@@ -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(
@@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -55,6 +56,40 @@ class TerminalRowLayoutTest {
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
@@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
paints.selectionForeground = argb(value.selectionForeground)
invalidate()
}
@@ -27,7 +27,6 @@ class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
@Field val selectionForeground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
canvas.drawRenderNode(recorded)
}
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
/** Draws backgrounds, the selection overlay, then text with its original colors. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints)
}
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val background =
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
if (background ushr 24 == 0) continue
fill.color = background
canvas.drawRect(
cellCol[cell] * cellWidth,
0f,
(cellCol[cell] + cellSpan[cell]) * cellWidth,
cellHeight,
fill,
)
val left = cellCol[cell] * cellWidth
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
val background = cellBackground[cell]
if (background ushr 24 != 0) {
fill.color = background
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
if (selected(row, selection, cell)) {
fill.color = paints.selectionBackground
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
}
val baseline = font.baselinePx.toFloat()
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = foreground(row, selection, first, paints)
val color = cellForeground[first]
var last = first
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell
canvas.save()
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = foreground(row, selection, cell, paints)
val foreground = cellForeground[cell]
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
@@ -291,9 +291,6 @@ internal class TerminalRowLayout(
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
@@ -301,6 +298,5 @@ internal class TerminalRowLayout(
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0xFFFFFF00.toInt()
var selectionForeground: Int = 0xFF000000.toInt()
var selectionBackground: Int = 0x472684FF
}
@@ -43,7 +43,6 @@ export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
selectionForeground: number;
}
export interface PaseoTerminalCellMetrics {
@@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView {
private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0xffff_ff00)),
selectionForeground: TerminalColor(Double(0xff00_0000)))
selectionBackground: TerminalColor(Double(0x4726_84ff)))
private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) {
@@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView {
background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground),
selectionForeground: TerminalColor(record.selectionForeground))
selectionBackground: TerminalColor(record.selectionBackground))
backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay()
}
@@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record {
@Field var background: Double = 0
@Field var cursor: Double = 0
@Field var selectionBackground: Double = 0
@Field var selectionForeground: Double = 0
}
struct TerminalCustomGlyphRecord: Record {
@@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
/// Colors a row is painted with besides its own styles.
struct TerminalRowPaint {
let selectionBackground: TerminalColor
let selectionForeground: TerminalColor
}
/// One row shaped once and placed on the terminal grid.
@@ -267,31 +266,33 @@ final class TerminalRowLayout {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
}
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
}
/// Draws the row with its top edge at `top`, selected cells in selection colors.
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
let cellWidth = font.cellWidth
let cellHeight = font.cellHeight
for cell in 0..<cellCount {
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
guard background.isPainted else { continue }
context.setFillColor(background.cgColor())
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
let background = cellBackground[cell]
if background.isPainted {
context.setFillColor(background.cgColor())
context.fill(rect)
}
if isSelected(cell, row: row, selection: selection) {
context.setFillColor(paint.selectionBackground.cgColor())
context.fill(rect)
}
}
let centerY = top + cellHeight / 2
let baselineInTextSpace = cellHeight / 2 - font.baseline
// A group whose cells differ in color or selection is drawn once per color, clipped to those
// A group whose cells differ in color is drawn once per color, clipped to those
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
for group in groups {
var first = group.firstCell
while first <= group.lastCell {
let color = foreground(first, row: row, selection: selection, paint: paint)
let color = cellForeground[first]
var last = first
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
while last < group.lastCell && cellForeground[last + 1] == color {
last += 1
}
context.saveGState()
@@ -315,7 +316,7 @@ final class TerminalRowLayout {
let baseline = top + font.baseline
for cell in 0..<cellCount {
let color = foreground(cell, row: row, selection: selection, paint: paint)
let color = cellForeground[cell]
let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] {
+1
View File
@@ -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 {
+127
View File
@@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644
}
return props.transform as TransformsStyle['transform'];
}
diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle
--- a/node_modules/react-native-svg/android/build.gradle
+++ b/node_modules/react-native-svg/android/build.gradle
@@ -103,6 +103,7 @@
}
defaultConfig {
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
minSdkVersion safeExtGet('minSdkVersion', 16)
//noinspection OldTargetApi
targetSdkVersion safeExtGet('targetSdkVersion', 28)
@@ -136,6 +137,8 @@
}
dependencies {
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
implementation 'com.facebook.react:react-native:+'
implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") {
exclude group: 'com.facebook.soloader'
diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
+++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
@@ -159,6 +159,8 @@
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
+ // Percentage lengths and paths depend on the canvas size, not just SVG props.
+ clearChildCache();
this.invalidate();
}
diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
new file mode 100644
--- /dev/null
+++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
@@ -0,0 +1,90 @@
+package com.horcrux.svg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.view.View;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.facebook.react.bridge.BridgeReactContext;
+import com.facebook.react.bridge.DynamicFromObject;
+import com.facebook.react.bridge.JavaOnlyArray;
+import com.facebook.react.bridge.JavaOnlyMap;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.react.uimanager.DisplayMetricsHolder;
+import com.facebook.soloader.SoLoader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SvgResizeTest {
+ @Test
+ public void percentageFadeFollowsCanvasGrowthAndShrinkage() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app);
+ try {
+ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
+ } catch (java.io.IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ BridgeReactContext context = new DrawingContext(app);
+ SvgView svg = new SvgView(context);
+ LinearGradientView gradient = new LinearGradientView(context);
+ gradient.setName("fade");
+ gradient.setX1(new DynamicFromObject("0%"));
+ gradient.setY1(new DynamicFromObject("0%"));
+ gradient.setX2(new DynamicFromObject("0%"));
+ gradient.setY2(new DynamicFromObject("100%"));
+ gradient.setGradientUnits(0);
+ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT));
+ svg.addView(gradient);
+ // Exercise recursive invalidation as well as the direct child case.
+ GroupView group = new GroupView(context);
+ RectView rectangle = new RectView(context);
+ rectangle.setX(new DynamicFromObject(0.0));
+ rectangle.setY(new DynamicFromObject(0.0));
+ rectangle.setWidth(new DynamicFromObject("100%"));
+ rectangle.setHeight(new DynamicFromObject("100%"));
+ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade"));
+ group.addView(rectangle);
+ svg.addView(group);
+
+ assertFade(svg, 80, 60);
+ assertFade(svg, 160, 120);
+ assertFade(svg, 60, 40);
+ assertFade(svg, 160, 120);
+ });
+ }
+
+ // Pixel rendering needs a React context but does not send layout events to JavaScript.
+ private static final class DrawingContext extends BridgeReactContext
+ implements com.facebook.react.uimanager.events.EventDispatcherProvider {
+ DrawingContext(android.content.Context context) { super(context); }
+
+ @Override
+ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; }
+ }
+
+ private static void assertFade(SvgView svg, int width, int height) {
+ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ svg.layout(0, 0, width, height);
+ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ svg.draw(new Canvas(image));
+ try {
+ int middle = Color.alpha(image.getPixel(width / 2, height / 2));
+ int right = Color.alpha(image.getPixel(width - 2, height / 2));
+ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height
+ + "; alpha=" + middle, middle >= 120 && middle <= 135);
+ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right);
+ assertTrue("Fade must end at the new bottom edge",
+ Color.alpha(image.getPixel(width / 2, height - 2)) < 20);
+ } finally {
+ image.recycle();
+ }
+ }
+}
+58 -9
View File
@@ -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", () => {
+37 -2
View File
@@ -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 -1
View File
@@ -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();
+5
View File
@@ -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.
+2
View File
@@ -59,6 +59,8 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+",
},
// Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes.
// The native pixel regression is included in the patch; preserve the separate transform hunks.
{
nodeModulesPath: "packages/app/node_modules/react-native-svg",
patchPrefix: "react-native-svg+",