@cueloop/client 0.1.0-alpha.52 → 0.1.0-alpha.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/App.prototype.test.tsx +29 -1
- package/src/App.tsx +20 -4
- package/src/components/PrototypeSheet.tsx +65 -17
- package/src/kitty-image.test.ts +48 -2
- package/src/kitty-image.ts +76 -22
- package/src/prototype-browser.ts +44 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.53",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
30
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.53",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.53",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.53",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.53",
|
|
33
33
|
"@opentui/core": "^0.5.8",
|
|
34
34
|
"@opentui/keymap": "0.5.8",
|
|
35
35
|
"@opentui/react": "^0.5.8",
|
|
@@ -34,15 +34,16 @@ const FAKE_ELEMENT: PrototypeElement = {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
let rendered = false;
|
|
37
|
+
let screenshotCount = 0;
|
|
37
38
|
let scrollDeltas: number[] = [];
|
|
38
39
|
const fakeRenderer: PrototypeRenderer = {
|
|
39
40
|
viewport: { width: 1280, height: 800 },
|
|
40
41
|
screenshot: async () => {
|
|
41
42
|
rendered = true;
|
|
43
|
+
screenshotCount += 1;
|
|
42
44
|
return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
43
45
|
},
|
|
44
46
|
elementAt: async () => FAKE_ELEMENT,
|
|
45
|
-
highlight: async () => undefined,
|
|
46
47
|
scrollBy: async (deltaY) => {
|
|
47
48
|
scrollDeltas.push(deltaY);
|
|
48
49
|
return true;
|
|
@@ -69,6 +70,7 @@ beforeEach(() => {
|
|
|
69
70
|
},
|
|
70
71
|
});
|
|
71
72
|
rendered = false;
|
|
73
|
+
screenshotCount = 0;
|
|
72
74
|
scrollDeltas = [];
|
|
73
75
|
setPrototypeRendererFactory(async () => fakeRenderer);
|
|
74
76
|
});
|
|
@@ -104,10 +106,14 @@ describe("prototype review", () => {
|
|
|
104
106
|
const setup = await renderApp();
|
|
105
107
|
await waitForState(setup, () => rendered);
|
|
106
108
|
await settle(setup);
|
|
109
|
+
// selecting an element must not re-screenshot the page (the popover-click
|
|
110
|
+
// lag regression): the highlight is the popover, not a fresh Chromium capture
|
|
111
|
+
const screenshotsAfterLoad = screenshotCount;
|
|
107
112
|
|
|
108
113
|
// Act
|
|
109
114
|
await setup.mockMouse.click(6, 6);
|
|
110
115
|
await waitForText(setup, "comment");
|
|
116
|
+
expect(screenshotCount).toBe(screenshotsAfterLoad);
|
|
111
117
|
await clickText(setup, "comment");
|
|
112
118
|
await waitForText(setup, "write a note");
|
|
113
119
|
// the keymap-suppression flag must propagate before typing so keys reach the
|
|
@@ -130,6 +136,25 @@ describe("prototype review", () => {
|
|
|
130
136
|
expect(frame).toContain("COMMENT");
|
|
131
137
|
});
|
|
132
138
|
|
|
139
|
+
test("escape cancels the compose, matching the plan composer", async () => {
|
|
140
|
+
// Arrange
|
|
141
|
+
const setup = await renderApp();
|
|
142
|
+
await waitForState(setup, () => rendered);
|
|
143
|
+
await settle(setup);
|
|
144
|
+
await setup.mockMouse.click(6, 6);
|
|
145
|
+
await waitForText(setup, "comment");
|
|
146
|
+
await clickText(setup, "comment");
|
|
147
|
+
await waitForText(setup, "write a note");
|
|
148
|
+
await settle(setup);
|
|
149
|
+
|
|
150
|
+
// Act
|
|
151
|
+
await press(setup, "escape");
|
|
152
|
+
|
|
153
|
+
// Assert - the composer closes and nothing was saved
|
|
154
|
+
await waitForTextGone(setup, "write a note");
|
|
155
|
+
expect(server.core.sessionGet(session.id).annotations).toHaveLength(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
133
158
|
test("wheel over the preview scrolls the page down, then up, and clears a selection", async () => {
|
|
134
159
|
// Arrange
|
|
135
160
|
const setup = await renderApp();
|
|
@@ -140,9 +165,12 @@ describe("prototype review", () => {
|
|
|
140
165
|
// the page moves under it)
|
|
141
166
|
await setup.mockMouse.click(6, 6);
|
|
142
167
|
await waitForText(setup, "comment");
|
|
168
|
+
// scrolling moves the page, so it DOES re-screenshot (unlike a click)
|
|
169
|
+
const screenshotsBeforeScroll = screenshotCount;
|
|
143
170
|
await setup.mockMouse.scroll(6, 6, "down");
|
|
144
171
|
await waitForState(setup, () => scrollDeltas.length === 1);
|
|
145
172
|
await waitForTextGone(setup, "comment");
|
|
173
|
+
await waitForState(setup, () => screenshotCount > screenshotsBeforeScroll);
|
|
146
174
|
|
|
147
175
|
// Act - scroll back up
|
|
148
176
|
await setup.mockMouse.scroll(6, 6, "up");
|
package/src/App.tsx
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* both sides.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import React, {
|
|
12
|
+
import React, {
|
|
13
|
+
useCallback,
|
|
14
|
+
useEffect,
|
|
15
|
+
useMemo,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
useSyncExternalStore,
|
|
19
|
+
} from "react";
|
|
13
20
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
14
21
|
import type { Clock, MouseEvent } from "@opentui/core";
|
|
15
22
|
import { type VerdictKind } from "@cueloop/schema";
|
|
@@ -59,6 +66,7 @@ import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
|
59
66
|
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
60
67
|
import { DiffSheet } from "./components/DiffSheet";
|
|
61
68
|
import { PrototypeSheet } from "./components/PrototypeSheet";
|
|
69
|
+
import type { PrototypeElement } from "./prototype-browser";
|
|
62
70
|
import { type ReviewRailHandle } from "./components/ReviewRail";
|
|
63
71
|
import type { AgentTerminalHandle } from "./components/agent-launcher";
|
|
64
72
|
import { ReviewPanel } from "./components/ReviewPanel";
|
|
@@ -134,6 +142,13 @@ export function App({
|
|
|
134
142
|
controller.connect();
|
|
135
143
|
return () => controller.close();
|
|
136
144
|
}, [controller]);
|
|
145
|
+
// stable across renders so the memoized PrototypeSheet is not re-rendered by
|
|
146
|
+
// unrelated App state (status ticks, a rail-width drag)
|
|
147
|
+
const onCommentPrototype = useCallback(
|
|
148
|
+
(element: PrototypeElement, body: string) =>
|
|
149
|
+
controller.annotatePrototype(element.selector, element.quote, body),
|
|
150
|
+
[controller],
|
|
151
|
+
);
|
|
137
152
|
const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
|
|
138
153
|
useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
139
154
|
// A shared plan you own polls for collaborator notes while it is open; the
|
|
@@ -1002,6 +1017,9 @@ export function App({
|
|
|
1002
1017
|
onMouseDrag={(event: MouseEvent) => {
|
|
1003
1018
|
if (!dividerDragging || reviewMode !== "expanded") return;
|
|
1004
1019
|
const next = widthFromMouseColumn(event.x, terminalWidth);
|
|
1020
|
+
// many raw mouse-moves land in the same column: only re-render when the
|
|
1021
|
+
// width actually changes, so the drag does not reconcile per pixel
|
|
1022
|
+
if (next === reviewWidthRef.current) return;
|
|
1005
1023
|
reviewWidthRef.current = next;
|
|
1006
1024
|
setReviewWidth(next);
|
|
1007
1025
|
}}
|
|
@@ -1036,9 +1054,7 @@ export function App({
|
|
|
1036
1054
|
prototypePath={activeSession.artifact.meta.prototypePath ?? ""}
|
|
1037
1055
|
quickActions={quickActions}
|
|
1038
1056
|
canComment={isOwner && !resolved}
|
|
1039
|
-
onCommentElement={
|
|
1040
|
-
controller.annotatePrototype(element.selector, element.quote, body)
|
|
1041
|
-
}
|
|
1057
|
+
onCommentElement={onCommentPrototype}
|
|
1042
1058
|
onComposingChange={setPrototypeComposing}
|
|
1043
1059
|
hidden={menuOpen || menuDialog !== null}
|
|
1044
1060
|
/>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import React, { useEffect, useRef, useState } from "react";
|
|
12
12
|
import type { BoxRenderable } from "@opentui/core";
|
|
13
|
-
import { useRenderer } from "@opentui/react";
|
|
13
|
+
import { useKeyboard, useRenderer } from "@opentui/react";
|
|
14
14
|
import type { Theme } from "../theme";
|
|
15
15
|
import { quickActionBody, type QuickAction } from "../config";
|
|
16
16
|
import { useComponentTheme } from "./theme-context";
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
deleteKittyImage,
|
|
30
30
|
placeKittyImage,
|
|
31
|
+
resolveTransmitMedium,
|
|
31
32
|
transmitKittyImage,
|
|
32
33
|
type CellRegion,
|
|
33
34
|
} from "../kitty-image";
|
|
@@ -76,7 +77,46 @@ function regionOf(box: BoxRenderable | null): CellRegion | null {
|
|
|
76
77
|
return { column: box.x, row: box.y, columns: box.width, rows: box.height };
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
type CaptureRenderer = {
|
|
81
|
+
resolution: { width: number; height: number } | null;
|
|
82
|
+
terminalWidth: number;
|
|
83
|
+
terminalHeight: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Capture at the region's own pixel size when the terminal reports its cell
|
|
88
|
+
* pixels, so the terminal never downscales an oversized image (less Chromium
|
|
89
|
+
* raster and fewer bytes). Without a resolution report, fall back to a fixed
|
|
90
|
+
* width at 2x and derive the height from the cell aspect.
|
|
91
|
+
*/
|
|
92
|
+
function captureConfig(
|
|
93
|
+
region: CellRegion,
|
|
94
|
+
renderer: CaptureRenderer,
|
|
95
|
+
): { viewport: PrototypeViewport; deviceScaleFactor: number } {
|
|
96
|
+
const resolution = renderer.resolution;
|
|
97
|
+
if (resolution && renderer.terminalWidth >= 1 && renderer.terminalHeight >= 1) {
|
|
98
|
+
const cellWidth = resolution.width / renderer.terminalWidth;
|
|
99
|
+
const cellHeight = resolution.height / renderer.terminalHeight;
|
|
100
|
+
return {
|
|
101
|
+
viewport: {
|
|
102
|
+
width: Math.max(1, Math.round(region.columns * cellWidth)),
|
|
103
|
+
height: Math.max(1, Math.round(region.rows * cellHeight)),
|
|
104
|
+
},
|
|
105
|
+
deviceScaleFactor: 1,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
viewport: {
|
|
110
|
+
width: CAPTURE_WIDTH,
|
|
111
|
+
height: Math.round(
|
|
112
|
+
(CAPTURE_WIDTH * region.rows * cellHeightOverWidth(renderer)) / region.columns,
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
deviceScaleFactor: 2,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function PrototypeSheetImpl({
|
|
80
120
|
prototypePath,
|
|
81
121
|
quickActions,
|
|
82
122
|
canComment,
|
|
@@ -125,6 +165,7 @@ export function PrototypeSheet({
|
|
|
125
165
|
const rawWrite = (renderer as unknown as { writeOut?: (data: string) => void }).writeOut;
|
|
126
166
|
const write =
|
|
127
167
|
typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
|
|
168
|
+
const medium = resolveTransmitMedium();
|
|
128
169
|
const paint = (): void => {
|
|
129
170
|
if (!write) return;
|
|
130
171
|
// an app overlay is covering the sheet: pull the image so it does not show
|
|
@@ -140,7 +181,7 @@ export function PrototypeSheet({
|
|
|
140
181
|
const png = pngRef.current;
|
|
141
182
|
if (!region || !png) return;
|
|
142
183
|
if (transmittedRef.current !== png) {
|
|
143
|
-
transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID);
|
|
184
|
+
transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID, medium);
|
|
144
185
|
transmittedRef.current = png;
|
|
145
186
|
} else {
|
|
146
187
|
placeKittyImage(write, region, PROTOTYPE_IMAGE_ID);
|
|
@@ -179,16 +220,15 @@ export function PrototypeSheet({
|
|
|
179
220
|
return;
|
|
180
221
|
}
|
|
181
222
|
launchedRef.current = true;
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
height: Math.round(
|
|
185
|
-
(CAPTURE_WIDTH * region.rows * cellHeightOverWidth(renderer!)) / region.columns,
|
|
186
|
-
),
|
|
187
|
-
};
|
|
188
|
-
viewportRef.current = viewport;
|
|
223
|
+
const capture = captureConfig(region, renderer!);
|
|
224
|
+
viewportRef.current = capture.viewport;
|
|
189
225
|
void (async () => {
|
|
190
226
|
try {
|
|
191
|
-
const browser = await prototypeRendererFactory()({
|
|
227
|
+
const browser = await prototypeRendererFactory()({
|
|
228
|
+
filePath: prototypePath,
|
|
229
|
+
viewport: capture.viewport,
|
|
230
|
+
deviceScaleFactor: capture.deviceScaleFactor,
|
|
231
|
+
});
|
|
192
232
|
// the sheet may have unmounted while Chromium was launching; close the
|
|
193
233
|
// late arrival instead of leaking the process
|
|
194
234
|
if (unmountedRef.current) {
|
|
@@ -219,12 +259,15 @@ export function PrototypeSheet({
|
|
|
219
259
|
setActionsOpen(false);
|
|
220
260
|
setComposing(false);
|
|
221
261
|
setDraftText("");
|
|
222
|
-
void browserRef.current
|
|
223
|
-
?.highlight(null)
|
|
224
|
-
.then(refresh)
|
|
225
|
-
.catch(() => undefined);
|
|
226
262
|
};
|
|
227
263
|
|
|
264
|
+
// While the compose textarea owns the keyboard, the global keymap is
|
|
265
|
+
// suppressed, so escape-to-cancel is handled here - matching the plan
|
|
266
|
+
// composer, where escape closes the overlay.
|
|
267
|
+
useKeyboard((key) => {
|
|
268
|
+
if (composing && key.name === "escape") clearSelection();
|
|
269
|
+
});
|
|
270
|
+
|
|
228
271
|
const onRegionMouseDown = (event: { x: number; y: number }): void => {
|
|
229
272
|
const browser = browserRef.current;
|
|
230
273
|
const region = regionOf(regionRef.current);
|
|
@@ -249,8 +292,6 @@ export function PrototypeSheet({
|
|
|
249
292
|
});
|
|
250
293
|
setActionsOpen(false);
|
|
251
294
|
setComposing(false);
|
|
252
|
-
await browser.highlight(element.selector);
|
|
253
|
-
await refresh();
|
|
254
295
|
})().catch((error) => {
|
|
255
296
|
setErrorMessage(error instanceof Error ? error.message : String(error));
|
|
256
297
|
setStatus("error");
|
|
@@ -350,6 +391,13 @@ export function PrototypeSheet({
|
|
|
350
391
|
);
|
|
351
392
|
}
|
|
352
393
|
|
|
394
|
+
/**
|
|
395
|
+
* Memoized so an unrelated App re-render (status ticks, a rail-width drag) does
|
|
396
|
+
* not re-render the sheet; the parent passes a stable onCommentElement so the
|
|
397
|
+
* shallow prop compare holds.
|
|
398
|
+
*/
|
|
399
|
+
export const PrototypeSheet = React.memo(PrototypeSheetImpl);
|
|
400
|
+
|
|
353
401
|
function statusLine(status: SheetStatus, errorMessage: string): string {
|
|
354
402
|
if (status === "loading") return "rendering prototype…";
|
|
355
403
|
if (status === "unsupported")
|
package/src/kitty-image.test.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
/** Kitty emission guards: the image sits beneath painted cells, and every put reuses one placement id so repeated frames REPLACE the placement instead of stacking a new one each frame (the prototype's per-frame lag/ghosting regression). Transmit medium is file locally and base64 over ssh. */
|
|
2
|
+
|
|
1
3
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { placeKittyImage, transmitKittyImage } from "./kitty-image";
|
|
4
|
+
import { placeKittyImage, resolveTransmitMedium, transmitKittyImage } from "./kitty-image";
|
|
3
5
|
|
|
4
6
|
const REGION = { column: 2, row: 3, columns: 40, rows: 12 };
|
|
5
7
|
|
|
6
|
-
describe("kitty image
|
|
8
|
+
describe("kitty image placement", () => {
|
|
7
9
|
test("places the prototype below cells with painted backgrounds", () => {
|
|
8
10
|
// Arrange
|
|
9
11
|
const writes: string[] = [];
|
|
@@ -17,4 +19,48 @@ describe("kitty image stacking", () => {
|
|
|
17
19
|
expect(writes[0]).toContain("z=-1073741825");
|
|
18
20
|
expect(writes[1]).toContain("z=-1073741825");
|
|
19
21
|
});
|
|
22
|
+
|
|
23
|
+
test("every frame reuses one placement id, so repeated puts replace rather than stack", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const writes: string[] = [];
|
|
26
|
+
const write = (chunk: string): void => void writes.push(chunk);
|
|
27
|
+
|
|
28
|
+
// Act - one transmit then many re-places, as the per-frame paint loop does
|
|
29
|
+
transmitKittyImage(write, new Uint8Array([1]), REGION, 811, "base64");
|
|
30
|
+
for (let frame = 0; frame < 5; frame++) placeKittyImage(write, REGION, 811);
|
|
31
|
+
|
|
32
|
+
// Assert - a stable placement id p=1 on the transmit and every place
|
|
33
|
+
expect(writes[0]).toContain("i=811,p=1");
|
|
34
|
+
for (const placement of writes.slice(1)) expect(placement).toContain("i=811,p=1");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("base64 transmit chunks the payload; file transmit hands over a path once", () => {
|
|
38
|
+
// Arrange
|
|
39
|
+
const big = new Uint8Array(9000).fill(1);
|
|
40
|
+
const base64Writes: string[] = [];
|
|
41
|
+
const fileWrites: string[] = [];
|
|
42
|
+
|
|
43
|
+
// Act
|
|
44
|
+
transmitKittyImage((chunk) => base64Writes.push(chunk), big, REGION, 811, "base64");
|
|
45
|
+
transmitKittyImage((chunk) => fileWrites.push(chunk), big, REGION, 811, "file");
|
|
46
|
+
|
|
47
|
+
// Assert - base64 inlines the data (a=T without t=), file references a temp path (t=t)
|
|
48
|
+
expect(base64Writes[0]).toContain("a=T");
|
|
49
|
+
expect(base64Writes[0]).not.toContain("t=t");
|
|
50
|
+
expect(fileWrites[0]).toContain("t=t");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("resolveTransmitMedium", () => {
|
|
55
|
+
test("defaults to base64; file transfer is opt-in and never over ssh", () => {
|
|
56
|
+
// Assert
|
|
57
|
+
expect(resolveTransmitMedium({} as NodeJS.ProcessEnv)).toBe("base64");
|
|
58
|
+
expect(resolveTransmitMedium({ CUELOOP_KITTY_FILE: "1" } as NodeJS.ProcessEnv)).toBe("file");
|
|
59
|
+
expect(
|
|
60
|
+
resolveTransmitMedium({
|
|
61
|
+
CUELOOP_KITTY_FILE: "1",
|
|
62
|
+
SSH_CONNECTION: "1.2.3.4 5 6 7",
|
|
63
|
+
} as NodeJS.ProcessEnv),
|
|
64
|
+
).toBe("base64");
|
|
65
|
+
});
|
|
20
66
|
});
|
package/src/kitty-image.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Direct kitty graphics protocol emission for a cell rectangle. OpenTUI's
|
|
3
3
|
* ImageRenderable does not display in every terminal, so prototype review paints
|
|
4
|
-
* the screenshot itself: transmit the PNG once
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* the screenshot itself: transmit the PNG once under a fixed image AND placement
|
|
5
|
+
* id, then re-place that same placement after each frame over its reserved
|
|
6
|
+
* (never-repainted) cells. Reusing one placement id means each frame REPLACES
|
|
7
|
+
* the placement rather than stacking a new one, which is the protocol's
|
|
8
|
+
* flicker-free move/resize path. Locally the PNG travels out of band via a
|
|
9
|
+
* temporary file so its bytes never cross the pty; a base64 fallback covers the
|
|
10
|
+
* ssh sharing path where no shared filesystem exists.
|
|
7
11
|
*/
|
|
8
12
|
|
|
13
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
9
17
|
export interface CellRegion {
|
|
10
18
|
/** 0-based screen cell of the region's top-left corner. */
|
|
11
19
|
column: number;
|
|
@@ -17,11 +25,32 @@ export interface CellRegion {
|
|
|
17
25
|
/** Writes a raw sequence ordered against the renderer's own frame output. */
|
|
18
26
|
export type OrderedWrite = (chunk: string) => void;
|
|
19
27
|
|
|
28
|
+
export type TransmitMedium = "file" | "base64";
|
|
29
|
+
|
|
20
30
|
const ESCAPE_STRING_TERMINATOR = "\x1b\\";
|
|
21
31
|
const CHUNK_BYTES = 4096;
|
|
22
32
|
// The protocol reserves values below INT32_MIN/2 for images that must sit
|
|
23
33
|
// beneath cells with painted backgrounds, such as the marker popover.
|
|
24
34
|
const IMAGE_Z_INDEX = -1_073_741_825;
|
|
35
|
+
// One fixed placement per image: reusing it makes every put replace that
|
|
36
|
+
// placement instead of accumulating a new one each frame.
|
|
37
|
+
const PLACEMENT_ID = 1;
|
|
38
|
+
const SYNCHRONIZED_UPDATE_START = "\x1b[?2026h";
|
|
39
|
+
const SYNCHRONIZED_UPDATE_END = "\x1b[?2026l";
|
|
40
|
+
|
|
41
|
+
let tempFileCounter = 0;
|
|
42
|
+
let privateTempDir: string | null = null;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* base64 is the safe default: it works over the pty everywhere, including the
|
|
46
|
+
* ssh sharing path. Out-of-band file transfer (t=t) is faster for the initial
|
|
47
|
+
* transmit but depends on the terminal trusting the temp-file path, so it is
|
|
48
|
+
* opt-in via `CUELOOP_KITTY_FILE=1` and never used over ssh (no shared disk).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveTransmitMedium(env: NodeJS.ProcessEnv = process.env): TransmitMedium {
|
|
51
|
+
if (env.CUELOOP_KITTY_FILE === "1" && !env.SSH_CONNECTION && !env.SSH_TTY) return "file";
|
|
52
|
+
return "base64";
|
|
53
|
+
}
|
|
25
54
|
|
|
26
55
|
/** Save the cursor, run the placement at the region's corner, restore it. */
|
|
27
56
|
function atRegion(region: CellRegion, body: string): string {
|
|
@@ -33,40 +62,65 @@ function graphicsCommand(keys: string, payload: string): string {
|
|
|
33
62
|
return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
|
|
34
63
|
}
|
|
35
64
|
|
|
65
|
+
function placementKeys(imageId: number, region: CellRegion): string {
|
|
66
|
+
return `i=${imageId},p=${PLACEMENT_ID},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A per-process 0700 directory with an unpredictable name, so a local attacker
|
|
70
|
+
// cannot pre-place a symlink at the screenshot path and redirect the write.
|
|
71
|
+
function writeTempPng(png: Uint8Array): string {
|
|
72
|
+
if (!privateTempDir) privateTempDir = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
|
|
73
|
+
const path = join(privateTempDir, `${tempFileCounter++}.png`);
|
|
74
|
+
writeFileSync(path, png, { flag: "wx" });
|
|
75
|
+
return path;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function base64Transmit(png: Uint8Array, region: CellRegion, imageId: number): string {
|
|
79
|
+
const base64 = Buffer.from(png).toString("base64");
|
|
80
|
+
const controlKeys = `a=T,f=100,${placementKeys(imageId, region)}`;
|
|
81
|
+
let sequence = "";
|
|
82
|
+
for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
|
|
83
|
+
const chunk = base64.slice(offset, offset + CHUNK_BYTES);
|
|
84
|
+
const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
|
|
85
|
+
sequence +=
|
|
86
|
+
offset === 0
|
|
87
|
+
? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
|
|
88
|
+
: graphicsCommand(`m=${isMore}`, chunk);
|
|
89
|
+
}
|
|
90
|
+
return sequence;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fileTransmit(png: Uint8Array, region: CellRegion, imageId: number): string {
|
|
94
|
+
const path = writeTempPng(png);
|
|
95
|
+
const payload = Buffer.from(path).toString("base64");
|
|
96
|
+
return graphicsCommand(`a=T,f=100,t=t,${placementKeys(imageId, region)}`, payload);
|
|
97
|
+
}
|
|
98
|
+
|
|
36
99
|
/**
|
|
37
100
|
* Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
|
|
38
101
|
* C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
|
|
39
|
-
* q=2 suppresses the terminal's replies.
|
|
102
|
+
* q=2 suppresses the terminal's replies. The whole write is bracketed in
|
|
103
|
+
* synchronized-update mode so the terminal composites it atomically. `file`
|
|
104
|
+
* hands over a temp path (t=t: the terminal deletes it after reading); `base64`
|
|
105
|
+
* inlines the data, chunked at 4096 bytes.
|
|
40
106
|
*/
|
|
41
107
|
export function transmitKittyImage(
|
|
42
108
|
write: OrderedWrite,
|
|
43
109
|
png: Uint8Array,
|
|
44
110
|
region: CellRegion,
|
|
45
111
|
imageId: number,
|
|
112
|
+
medium: TransmitMedium = "base64",
|
|
46
113
|
): void {
|
|
47
114
|
if (region.columns < 1 || region.rows < 1) return;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
|
|
52
|
-
const chunk = base64.slice(offset, offset + CHUNK_BYTES);
|
|
53
|
-
const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
|
|
54
|
-
sequence +=
|
|
55
|
-
offset === 0
|
|
56
|
-
? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
|
|
57
|
-
: graphicsCommand(`m=${isMore}`, chunk);
|
|
58
|
-
}
|
|
59
|
-
write(atRegion(region, sequence));
|
|
115
|
+
const body =
|
|
116
|
+
medium === "file" ? fileTransmit(png, region, imageId) : base64Transmit(png, region, imageId);
|
|
117
|
+
write(SYNCHRONIZED_UPDATE_START + atRegion(region, body) + SYNCHRONIZED_UPDATE_END);
|
|
60
118
|
}
|
|
61
119
|
|
|
62
|
-
/** Re-place
|
|
120
|
+
/** Re-place the already-transmitted image over its region, cheaply (a=p, no data). */
|
|
63
121
|
export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
|
|
64
122
|
if (region.columns < 1 || region.rows < 1) return;
|
|
65
|
-
|
|
66
|
-
`a=p,i=${imageId},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`,
|
|
67
|
-
"",
|
|
68
|
-
);
|
|
69
|
-
write(atRegion(region, command));
|
|
123
|
+
write(atRegion(region, graphicsCommand(`a=p,${placementKeys(imageId, region)}`, "")));
|
|
70
124
|
}
|
|
71
125
|
|
|
72
126
|
/** Remove the image so it does not linger after the sheet closes (a=d). */
|
package/src/prototype-browser.ts
CHANGED
|
@@ -30,7 +30,6 @@ export interface PrototypeRenderer {
|
|
|
30
30
|
readonly viewport: PrototypeViewport;
|
|
31
31
|
screenshot(): Promise<Uint8Array>;
|
|
32
32
|
elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
|
|
33
|
-
highlight(selector: string | null): Promise<void>;
|
|
34
33
|
/** Scroll the page by a pixel delta; returns whether the scroll position moved. */
|
|
35
34
|
scrollBy(deltaY: number): Promise<boolean>;
|
|
36
35
|
close(): Promise<void>;
|
|
@@ -39,6 +38,8 @@ export interface PrototypeRenderer {
|
|
|
39
38
|
export interface LaunchOptions {
|
|
40
39
|
filePath: string;
|
|
41
40
|
viewport: PrototypeViewport;
|
|
41
|
+
/** Capture density; 1 when the viewport already matches the region's pixels. */
|
|
42
|
+
deviceScaleFactor?: number;
|
|
42
43
|
/** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
|
|
43
44
|
executablePath?: string;
|
|
44
45
|
}
|
|
@@ -95,30 +96,54 @@ export function prototypeRendererFactory(): PrototypeRendererFactory {
|
|
|
95
96
|
return rendererFactory ?? launchPrototypeRenderer;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
type PuppeteerBrowser = Awaited<ReturnType<typeof import("puppeteer-core").default.launch>>;
|
|
100
|
+
|
|
101
|
+
// One Chromium per process, kept warm and reused across prototype opens: the
|
|
102
|
+
// launch is the dominant load cost, so each open only spawns a fresh page.
|
|
103
|
+
let sharedBrowser: Promise<PuppeteerBrowser> | null = null;
|
|
104
|
+
|
|
105
|
+
async function warmBrowser(executablePath: string | undefined): Promise<PuppeteerBrowser> {
|
|
106
|
+
// reuse the warm browser only while it is still connected; a crashed or
|
|
107
|
+
// disconnected Chromium is dropped so the next open relaunches instead of
|
|
108
|
+
// calling newPage on a dead process forever
|
|
109
|
+
const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
|
|
110
|
+
if (cached && cached.connected) return cached;
|
|
99
111
|
const puppeteer = (await import("puppeteer-core")).default;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
executablePath: options.executablePath ?? chromeExecutable(),
|
|
112
|
+
const launched = puppeteer.launch({
|
|
113
|
+
executablePath: executablePath ?? chromeExecutable(),
|
|
103
114
|
headless: true,
|
|
104
115
|
args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
105
|
-
defaultViewport: {
|
|
106
|
-
width: options.viewport.width,
|
|
107
|
-
height: options.viewport.height,
|
|
108
|
-
deviceScaleFactor: 2,
|
|
109
|
-
},
|
|
110
116
|
});
|
|
111
|
-
|
|
112
|
-
const
|
|
113
|
-
|
|
117
|
+
sharedBrowser = launched;
|
|
118
|
+
const browser = await launched.catch((error) => {
|
|
119
|
+
if (sharedBrowser === launched) sharedBrowser = null;
|
|
114
120
|
throw error;
|
|
115
121
|
});
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
browser.once("disconnected", () => {
|
|
123
|
+
if (sharedBrowser === launched) sharedBrowser = null;
|
|
124
|
+
});
|
|
125
|
+
return browser;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
|
|
129
|
+
const { pathToFileURL } = await import("node:url");
|
|
130
|
+
const browser = await warmBrowser(options.executablePath);
|
|
131
|
+
// close only the page on teardown, never the shared browser, so the next open
|
|
132
|
+
// reuses the warm Chromium
|
|
133
|
+
const page = await browser.newPage();
|
|
134
|
+
try {
|
|
135
|
+
await page.setViewport({
|
|
136
|
+
width: options.viewport.width,
|
|
137
|
+
height: options.viewport.height,
|
|
138
|
+
deviceScaleFactor: options.deviceScaleFactor ?? 2,
|
|
121
139
|
});
|
|
140
|
+
// `load` waits for images and styles but skips networkidle0's fixed 500ms
|
|
141
|
+
// idle window, which a static local file would otherwise always pay
|
|
142
|
+
await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
await page.close().catch(() => undefined);
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
122
147
|
|
|
123
148
|
return {
|
|
124
149
|
viewport: options.viewport,
|
|
@@ -129,14 +154,11 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
129
154
|
async elementAt(cssX, cssY) {
|
|
130
155
|
return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
|
|
131
156
|
},
|
|
132
|
-
async highlight(selector) {
|
|
133
|
-
await page.evaluate(highlightScript, selector);
|
|
134
|
-
},
|
|
135
157
|
async scrollBy(deltaY) {
|
|
136
158
|
return (await page.evaluate(scrollByScript, deltaY)) as boolean;
|
|
137
159
|
},
|
|
138
160
|
async close() {
|
|
139
|
-
await
|
|
161
|
+
await page.close().catch(() => undefined);
|
|
140
162
|
},
|
|
141
163
|
};
|
|
142
164
|
}
|
|
@@ -212,25 +234,6 @@ function scrollByScript(deltaY: number): boolean {
|
|
|
212
234
|
return window.scrollY !== before;
|
|
213
235
|
}
|
|
214
236
|
|
|
215
|
-
function highlightScript(selector: string | null): void {
|
|
216
|
-
const ATTRIBUTE = "data-cueloop-selected";
|
|
217
|
-
const STYLE_ID = "cueloop-highlight-style";
|
|
218
|
-
document
|
|
219
|
-
.querySelectorAll("[" + ATTRIBUTE + "]")
|
|
220
|
-
.forEach((node) => node.removeAttribute(ATTRIBUTE));
|
|
221
|
-
if (!document.getElementById(STYLE_ID)) {
|
|
222
|
-
const style = document.createElement("style");
|
|
223
|
-
style.id = STYLE_ID;
|
|
224
|
-
style.textContent =
|
|
225
|
-
"[" + ATTRIBUTE + "]{outline:2px solid #f38ba8 !important;outline-offset:2px;}";
|
|
226
|
-
document.head.appendChild(style);
|
|
227
|
-
}
|
|
228
|
-
if (selector) {
|
|
229
|
-
const node = document.querySelector(selector);
|
|
230
|
-
if (node) node.setAttribute(ATTRIBUTE, "");
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
237
|
/** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
|
|
235
238
|
function chromeExecutable(): string {
|
|
236
239
|
if (process.platform === "darwin") {
|