@cueloop/client 0.1.0-alpha.51 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.51",
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,16 +26,16 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.51",
30
- "@cueloop/extension-api": "0.1.0-alpha.51",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.51",
32
- "@cueloop/schema": "0.1.0-alpha.51",
33
- "@opentui/core": "^0.5.2",
34
- "@opentui/keymap": "0.5.2",
35
- "@opentui/react": "^0.5.2",
36
- "@opentui/ssh": "0.5.2",
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
+ "@opentui/core": "^0.5.8",
34
+ "@opentui/keymap": "0.5.8",
35
+ "@opentui/react": "^0.5.8",
36
+ "@opentui/ssh": "0.5.8",
37
37
  "@pierre/diffs": "^1.3.5",
38
- "diff": "^8",
38
+ "diff": "^9",
39
39
  "puppeteer-core": "^25.8.0",
40
40
  "react": "^19.2.0"
41
41
  },
@@ -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, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "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={(element, body) =>
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
- export function PrototypeSheet({
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 viewport = {
183
- width: CAPTURE_WIDTH,
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()({ filePath: prototypePath, viewport });
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")
@@ -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 stacking", () => {
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
  });
@@ -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, then re-place it after each
5
- * frame over its reserved (never-repainted) cells, cell-addressed so it needs no
6
- * pixel-resolution report.
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. Data over 4096 bytes is chunked.
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 base64 = Buffer.from(png).toString("base64");
49
- const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=${IMAGE_Z_INDEX},c=${region.columns},r=${region.rows}`;
50
- let sequence = "";
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 an already-transmitted image over its region, cheaply (a=p, no data). */
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
- const command = graphicsCommand(
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). */
@@ -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
- export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
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 { pathToFileURL } = await import("node:url");
101
- const browser = await puppeteer.launch({
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
- // close the browser if page setup fails, so a launch error never leaks Chromium
112
- const page = await browser.newPage().catch(async (error) => {
113
- await browser.close();
117
+ sharedBrowser = launched;
118
+ const browser = await launched.catch((error) => {
119
+ if (sharedBrowser === launched) sharedBrowser = null;
114
120
  throw error;
115
121
  });
116
- await page
117
- .goto(pathToFileURL(options.filePath).href, { waitUntil: "networkidle0" })
118
- .catch(async (error) => {
119
- await browser.close();
120
- throw error;
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 browser.close();
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") {