@cueloop/client 0.1.0-alpha.46 → 0.1.0-alpha.47

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.46",
3
+ "version": "0.1.0-alpha.47",
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.46",
30
- "@cueloop/extension-api": "0.1.0-alpha.46",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.46",
32
- "@cueloop/schema": "0.1.0-alpha.46",
29
+ "@cueloop/daemon": "0.1.0-alpha.47",
30
+ "@cueloop/extension-api": "0.1.0-alpha.47",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.47",
32
+ "@cueloop/schema": "0.1.0-alpha.47",
33
33
  "@opentui/core": "^0.5.2",
34
34
  "@opentui/keymap": "0.5.2",
35
35
  "@opentui/react": "^0.5.2",
@@ -0,0 +1,125 @@
1
+ /** Prototype review end to end: a click on the rendered page selects a DOM element, the marker actions bar opens, a typed comment saves through the controller, and the annotation shows in the rail. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import {
13
+ setPrototypeRendererFactory,
14
+ type PrototypeElement,
15
+ type PrototypeRenderer,
16
+ } from "./prototype-browser";
17
+ import {
18
+ isolateUserConfig,
19
+ press,
20
+ settle,
21
+ typeText,
22
+ waitForState,
23
+ waitForText,
24
+ } from "./test-support";
25
+
26
+ const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
27
+
28
+ const FAKE_ELEMENT: PrototypeElement = {
29
+ selector: "main > div.card",
30
+ quote: "Pro $24/mo",
31
+ // mid-page, so the popover anchors well away from the click point below
32
+ box: { x: 440, y: 320, width: 320, height: 220 },
33
+ };
34
+
35
+ let rendered = false;
36
+ const fakeRenderer: PrototypeRenderer = {
37
+ viewport: { width: 1280, height: 800 },
38
+ screenshot: async () => {
39
+ rendered = true;
40
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
41
+ },
42
+ elementAt: async () => FAKE_ELEMENT,
43
+ highlight: async () => undefined,
44
+ close: async () => undefined,
45
+ };
46
+
47
+ let home: string;
48
+ let server: DaemonServer;
49
+ let session: ReviewSession;
50
+ let restoreUserConfig: () => void;
51
+
52
+ beforeEach(() => {
53
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
54
+ restoreUserConfig = isolateUserConfig(home);
55
+ server = new DaemonServer({ home, idleExitMs: 0 });
56
+ server.start();
57
+ session = server.core.sessionCreate({
58
+ workspace: { repoRoot: "/repo", branch: "main" },
59
+ artifact: {
60
+ type: "prototype",
61
+ content: HTML,
62
+ meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
63
+ },
64
+ });
65
+ rendered = false;
66
+ setPrototypeRendererFactory(async () => fakeRenderer);
67
+ });
68
+ afterEach(() => {
69
+ setPrototypeRendererFactory(null);
70
+ restoreUserConfig();
71
+ server.stop();
72
+ rmSync(home, { recursive: true, force: true });
73
+ });
74
+
75
+ async function renderApp() {
76
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
77
+ width: 120,
78
+ height: 32,
79
+ });
80
+ await waitForText(setup, "cueloop");
81
+ return setup;
82
+ }
83
+
84
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
85
+
86
+ async function clickText(setup: Setup, needle: string): Promise<void> {
87
+ const lines = setup.captureCharFrame().split("\n");
88
+ const row = lines.findIndex((line) => line.includes(needle));
89
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
90
+ await setup.renderOnce();
91
+ }
92
+
93
+ describe("prototype review", () => {
94
+ test("click an element, comment on it, and it lands in the rail", async () => {
95
+ // Arrange - wait until the fake browser produced its first screenshot, so
96
+ // the sheet is interactive before the click
97
+ const setup = await renderApp();
98
+ await waitForState(setup, () => rendered);
99
+ await settle(setup);
100
+
101
+ // Act
102
+ await setup.mockMouse.click(6, 6);
103
+ await waitForText(setup, "comment");
104
+ await clickText(setup, "comment");
105
+ await waitForText(setup, "write a note");
106
+ // the keymap-suppression flag must propagate before typing so keys reach the
107
+ // textarea, not the global keymap
108
+ await settle(setup);
109
+ await typeText(setup, "tighten the padding");
110
+ await press(setup, "enter");
111
+
112
+ // Assert - the annotation is stored against the element's selector
113
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
114
+ const stored = server.core.sessionGet(session.id).annotations[0]!;
115
+ expect(stored.kind).toBe("comment");
116
+ expect(stored.body).toBe("tighten the padding");
117
+ expect(stored.anchor.selector).toBe("main > div.card");
118
+
119
+ // and it renders in the rail
120
+ await settle(setup);
121
+ const frame = setup.captureCharFrame();
122
+ expect(frame).toContain("tighten the padding");
123
+ expect(frame).toContain("COMMENT");
124
+ });
125
+ });
package/src/App.tsx CHANGED
@@ -253,6 +253,7 @@ export function App({
253
253
  const resolved = session?.status === "resolved";
254
254
  const isDiff = session?.artifact.type === "diff";
255
255
  const isPrototype = session?.artifact.type === "prototype";
256
+ const [prototypeComposing, setPrototypeComposing] = useState(false);
256
257
  // sort position per annotation so the rail interleaves annotation and removal
257
258
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
258
259
  // annotation resolves to the display index it marked
@@ -589,6 +590,9 @@ export function App({
589
590
  };
590
591
 
591
592
  useKeyboard((key) => {
593
+ // The prototype compose textarea owns the keyboard while open: let it receive
594
+ // the typed note instead of the global keymap acting on each letter.
595
+ if (prototypeComposing) return;
592
596
  // A running in-tab agent terminal owns the keyboard: forward every key to it,
593
597
  // with ctrl+] as the detach chord back to the review.
594
598
  if (agentTerminal) {
@@ -1035,6 +1039,7 @@ export function App({
1035
1039
  onCommentElement={(element, body) =>
1036
1040
  controller.annotatePrototype(element.selector, element.quote, body)
1037
1041
  }
1042
+ onComposingChange={setPrototypeComposing}
1038
1043
  />
1039
1044
  ) : isDiff ? (
1040
1045
  // the sheet dims to reading-quiet colors while the wizard has focus
@@ -25,6 +25,12 @@ export interface AnnotationDraft {
25
25
  onInput: (text: string) => void;
26
26
  onSave: () => void;
27
27
  onCancel: () => void;
28
+ /**
29
+ * When set, ⏎ in the textarea saves through this handler. The plan and diff
30
+ * composers leave it unset because the app keymap owns ⏎ there; the prototype
31
+ * composer sets it because the app keymap is suppressed while it is open.
32
+ */
33
+ onSubmit?: () => void;
28
34
  }
29
35
 
30
36
  export interface AnnotationSaved {
@@ -119,6 +125,7 @@ function DraftEditor({
119
125
  initialValue={draft.text}
120
126
  placeholder="write a note..."
121
127
  keyBindings={COMPOSE_KEY_BINDINGS}
128
+ {...(draft.onSubmit ? { onSubmit: draft.onSubmit } : {})}
122
129
  onContentChange={() => {
123
130
  const editor = editorRef.current;
124
131
  if (!editor) return;
@@ -1,11 +1,15 @@
1
1
  /**
2
- * Prototype review surface: renders the HTML as an image and turns a click into
3
- * a DOM-element selection, so the shared marker actions bar and compose card
4
- * annotate an element rather than a text span.
2
+ * Prototype review surface: renders the HTML screenshot into a reserved cell
3
+ * region via the kitty graphics protocol, and turns a click into a DOM-element
4
+ * selection, so the shared marker actions bar and compose card annotate an
5
+ * element rather than a text span. The image is painted directly (not through
6
+ * OpenTUI's ImageRenderable, which does not display in every terminal): the
7
+ * region's cells are never repainted, and the picture is re-placed after each
8
+ * frame so it survives OpenTUI's own draws.
5
9
  */
6
10
 
7
11
  import React, { useEffect, useRef, useState } from "react";
8
- import type { ImageRenderable } from "@opentui/core";
12
+ import type { BoxRenderable } from "@opentui/core";
9
13
  import { useRenderer } from "@opentui/react";
10
14
  import type { Theme } from "../theme";
11
15
  import { quickActionBody, type QuickAction } from "../config";
@@ -16,79 +20,162 @@ import { AnnotationCard } from "./AnnotationCard";
16
20
  import {
17
21
  cssBoxToCell,
18
22
  imageCellToCss,
19
- launchPrototypeRenderer,
23
+ prototypeRendererFactory,
20
24
  type PrototypeElement,
21
25
  type PrototypeRenderer,
22
26
  type PrototypeViewport,
23
27
  } from "../prototype-browser";
28
+ import {
29
+ deleteKittyImage,
30
+ placeKittyImage,
31
+ transmitKittyImage,
32
+ type CellRegion,
33
+ } from "../kitty-image";
24
34
 
25
35
  export interface PrototypeSheetProps {
26
36
  prototypePath: string;
27
37
  quickActions: QuickAction[];
28
38
  canComment: boolean;
29
39
  onCommentElement: (element: PrototypeElement, body: string) => void;
40
+ /** Signals when the inline compose owns the keyboard, so the app suppresses
41
+ * its global keymap and the compose textarea receives the typed note. */
42
+ onComposingChange?: (active: boolean) => void;
30
43
  theme?: Theme;
31
44
  }
32
45
 
33
46
  type SheetStatus = "loading" | "ready" | "unsupported" | "error";
34
47
 
35
- const DEFAULT_CELL_ASPECT = 2;
36
48
  const POPOVER_ROWS = 3;
49
+ const PROTOTYPE_IMAGE_ID = 811;
50
+ // The capture viewport width in CSS pixels; its height matches the region's
51
+ // cell aspect so the rendered image fills the box instead of letterboxing. The
52
+ // aspect comes from the terminal's reported cell pixels, falling back to a
53
+ // typical monospace ratio when the terminal never reports one.
54
+ const CAPTURE_WIDTH = 1280;
55
+ const FALLBACK_CELL_HEIGHT_OVER_WIDTH = 2.18;
56
+
57
+ function cellHeightOverWidth(renderer: {
58
+ resolution: { width: number; height: number } | null;
59
+ terminalWidth: number;
60
+ terminalHeight: number;
61
+ }): number {
62
+ const resolution = renderer.resolution;
63
+ if (!resolution || renderer.terminalWidth < 1 || renderer.terminalHeight < 1) {
64
+ return FALLBACK_CELL_HEIGHT_OVER_WIDTH;
65
+ }
66
+ return resolution.height / renderer.terminalHeight / (resolution.width / renderer.terminalWidth);
67
+ }
68
+
69
+ function regionOf(box: BoxRenderable | null): CellRegion | null {
70
+ if (!box || box.width < 1 || box.height < 1) return null;
71
+ return { column: box.x, row: box.y, columns: box.width, rows: box.height };
72
+ }
37
73
 
38
74
  export function PrototypeSheet({
39
75
  prototypePath,
40
76
  quickActions,
41
77
  canComment,
42
78
  onCommentElement,
79
+ onComposingChange,
43
80
  theme,
44
81
  }: PrototypeSheetProps): React.ReactNode {
45
82
  const tokens = useComponentTheme(theme);
46
83
  const renderer = useRenderer();
47
- const imageRef = useRef<ImageRenderable | null>(null);
84
+ const regionRef = useRef<BoxRenderable | null>(null);
48
85
  const browserRef = useRef<PrototypeRenderer | null>(null);
49
86
  const launchedRef = useRef(false);
50
- const viewportRef = useRef<PrototypeViewport>({ width: 1280, height: 800 });
87
+ const viewportRef = useRef<PrototypeViewport>({ width: CAPTURE_WIDTH, height: 800 });
88
+ const pngRef = useRef<Uint8Array | null>(null);
89
+ const transmittedRef = useRef<Uint8Array | null>(null);
90
+ const paintRef = useRef<() => void>(() => undefined);
91
+ const launchRef = useRef<() => void>(() => undefined);
51
92
 
52
93
  const [status, setStatus] = useState<SheetStatus>("loading");
53
94
  const [errorMessage, setErrorMessage] = useState<string>("");
54
- const [png, setPng] = useState<Uint8Array | null>(null);
55
95
  const [selected, setSelected] = useState<PrototypeElement | null>(null);
56
96
  const [overlayCell, setOverlayCell] = useState<{ left: number; top: number } | null>(null);
57
97
  const [actionsOpen, setActionsOpen] = useState(false);
58
98
  const [composing, setComposing] = useState(false);
59
99
  const [draftText, setDraftText] = useState("");
60
100
 
101
+ const setPng = (png: Uint8Array): void => {
102
+ pngRef.current = png;
103
+ transmittedRef.current = null;
104
+ paintRef.current();
105
+ };
106
+
107
+ // Every frame: start the browser once layout is known, then keep the picture
108
+ // asserted over its cells. The kitty emission (transmit once, cheap re-place
109
+ // after) is skipped when the renderer has no raw writeOut channel (the test
110
+ // harness), but the launch trigger still runs.
61
111
  useEffect(() => {
112
+ if (!renderer) return;
113
+ // writeOut is the renderer's raw output channel, ordered against its frame
114
+ // bytes; it is private, so reach it through one narrow cast
115
+ const rawWrite = (renderer as unknown as { writeOut?: (data: string) => void }).writeOut;
116
+ const write =
117
+ typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
118
+ const paint = (): void => {
119
+ if (!write) return;
120
+ const region = regionOf(regionRef.current);
121
+ const png = pngRef.current;
122
+ if (!region || !png) return;
123
+ if (transmittedRef.current !== png) {
124
+ transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID);
125
+ transmittedRef.current = png;
126
+ } else {
127
+ placeKittyImage(write, region, PROTOTYPE_IMAGE_ID);
128
+ }
129
+ };
130
+ paintRef.current = paint;
131
+ const onFrame = (): void => {
132
+ launchRef.current();
133
+ paint();
134
+ };
135
+ renderer.on("frame", onFrame);
62
136
  return () => {
63
- void browserRef.current?.close();
137
+ renderer.off("frame", onFrame);
138
+ if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
64
139
  };
65
- }, []);
140
+ }, [renderer]);
66
141
 
67
- const viewportFor = (columns: number, rows: number): PrototypeViewport => {
68
- const resolution = renderer?.resolution ?? null;
69
- const cellWidth = resolution ? resolution.width / renderer!.terminalWidth : 1;
70
- const cellHeight = resolution
71
- ? resolution.height / renderer!.terminalHeight
72
- : DEFAULT_CELL_ASPECT;
73
- return {
74
- width: Math.max(320, Math.round(columns * cellWidth)),
75
- height: Math.max(240, Math.round(rows * cellHeight)),
142
+ useEffect(() => {
143
+ onComposingChange?.(composing);
144
+ }, [composing, onComposingChange]);
145
+
146
+ const unmountedRef = useRef(false);
147
+ useEffect(() => {
148
+ return () => {
149
+ unmountedRef.current = true;
150
+ void browserRef.current?.close();
76
151
  };
77
- };
152
+ }, []);
78
153
 
79
154
  const launch = (): void => {
80
- const image = imageRef.current;
81
- if (launchedRef.current || !image || image.width < 1 || image.height < 1) return;
82
- if (renderer?.capabilities && renderer.capabilities.kitty_graphics === false) {
155
+ const region = regionOf(regionRef.current);
156
+ const caps = renderer?.capabilities;
157
+ if (launchedRef.current || !region) return;
158
+ if (caps && caps.kitty_graphics === false) {
83
159
  setStatus("unsupported");
84
160
  return;
85
161
  }
86
162
  launchedRef.current = true;
87
- const viewport = viewportFor(image.width, image.height);
163
+ const viewport = {
164
+ width: CAPTURE_WIDTH,
165
+ height: Math.round(
166
+ (CAPTURE_WIDTH * region.rows * cellHeightOverWidth(renderer!)) / region.columns,
167
+ ),
168
+ };
88
169
  viewportRef.current = viewport;
89
170
  void (async () => {
90
171
  try {
91
- const browser = await launchPrototypeRenderer({ filePath: prototypePath, viewport });
172
+ const browser = await prototypeRendererFactory()({ filePath: prototypePath, viewport });
173
+ // the sheet may have unmounted while Chromium was launching; close the
174
+ // late arrival instead of leaking the process
175
+ if (unmountedRef.current) {
176
+ void browser.close();
177
+ return;
178
+ }
92
179
  browserRef.current = browser;
93
180
  setPng(await browser.screenshot());
94
181
  setStatus("ready");
@@ -98,6 +185,9 @@ export function PrototypeSheet({
98
185
  }
99
186
  })();
100
187
  };
188
+ useEffect(() => {
189
+ launchRef.current = launch;
190
+ });
101
191
 
102
192
  const refresh = async (): Promise<void> => {
103
193
  const browser = browserRef.current;
@@ -116,11 +206,16 @@ export function PrototypeSheet({
116
206
  .catch(() => undefined);
117
207
  };
118
208
 
119
- const onImageMouseDown = (event: { x: number; y: number }): void => {
209
+ const onRegionMouseDown = (event: { x: number; y: number }): void => {
120
210
  const browser = browserRef.current;
121
- const image = imageRef.current;
122
- if (status !== "ready" || !browser || !image) return;
123
- const geometry = { x: image.x, y: image.y, width: image.width, height: image.height };
211
+ const region = regionOf(regionRef.current);
212
+ if (status !== "ready" || !browser || !region) return;
213
+ const geometry = {
214
+ x: region.column,
215
+ y: region.row,
216
+ width: region.columns,
217
+ height: region.rows,
218
+ };
124
219
  const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
125
220
  if (!cssPoint) return;
126
221
  void (async () => {
@@ -149,12 +244,10 @@ export function PrototypeSheet({
149
244
  clearSelection();
150
245
  };
151
246
 
152
- const overlayLeft = overlayCell?.left ?? 0;
153
- const overlayTop = Math.max(0, overlayCell?.top ?? 0);
154
247
  const overlayStyle = {
155
248
  position: "absolute" as const,
156
- left: overlayLeft,
157
- top: overlayTop,
249
+ left: overlayCell?.left ?? 0,
250
+ top: Math.max(0, overlayCell?.top ?? 0),
158
251
  flexDirection: "column" as const,
159
252
  };
160
253
 
@@ -168,16 +261,13 @@ export function PrototypeSheet({
168
261
  borderColor: tokens.text,
169
262
  }}
170
263
  >
171
- <image
172
- ref={(instance: ImageRenderable | null) => {
173
- imageRef.current = instance;
264
+ <box
265
+ ref={(instance: BoxRenderable | null) => {
266
+ regionRef.current = instance;
174
267
  }}
175
- {...(png ? { source: png } : {})}
176
- fit="fill"
177
- protocol="auto"
178
268
  style={{ flexGrow: 1 }}
179
269
  onSizeChange={launch}
180
- onMouseDown={onImageMouseDown}
270
+ onMouseDown={onRegionMouseDown}
181
271
  />
182
272
  {status !== "ready" ? (
183
273
  <box style={{ position: "absolute", left: 2, top: 1 }}>
@@ -191,7 +281,11 @@ export function PrototypeSheet({
191
281
  actions={quickActions}
192
282
  actionIndex={0}
193
283
  canCut={false}
194
- onComment={() => (canComment ? setComposing(true) : undefined)}
284
+ onComment={() => {
285
+ if (!canComment) return;
286
+ onComposingChange?.(true);
287
+ setComposing(true);
288
+ }}
195
289
  onCut={() => undefined}
196
290
  onOpenActions={() => setActionsOpen(true)}
197
291
  onClose={clearSelection}
@@ -210,6 +304,7 @@ export function PrototypeSheet({
210
304
  text: draftText,
211
305
  onInput: setDraftText,
212
306
  onSave: () => commit(draftText),
307
+ onSubmit: () => commit(draftText),
213
308
  onCancel: clearSelection,
214
309
  }}
215
310
  theme={theme}
@@ -817,6 +817,34 @@ exports[`stories catalog PromptDialog/RenameAuthor renders, snapshots, and carri
817
817
 
818
818
 
819
819
 
820
+ "
821
+ `;
822
+
823
+ exports[`stories catalog PrototypeSheet/Unsupported renders, snapshots, and carries its colors: PrototypeSheet/Unsupported 1`] = `
824
+ "╭──────────────────────────────────────────────────────────────────────────────╮
825
+ │ │
826
+ │ rendering prototype… │
827
+ │ │
828
+ │ │
829
+ │ │
830
+ │ │
831
+ │ │
832
+ │ │
833
+ │ │
834
+ │ │
835
+ │ │
836
+ │ │
837
+ │ │
838
+ │ │
839
+ │ │
840
+ │ │
841
+ │ │
842
+ │ │
843
+ │ │
844
+ │ │
845
+ │ │
846
+ │ │
847
+ ╰──────────────────────────────────────────────────────────────────────────────╯
820
848
  "
821
849
  `;
822
850
 
@@ -1825,33 +1853,5 @@ exports[`stories catalog theme-context/ProviderSwap renders, snapshots, and carr
1825
1853
 
1826
1854
 
1827
1855
 
1828
- "
1829
- `;
1830
-
1831
- exports[`stories catalog PrototypeSheet/Unsupported renders, snapshots, and carries its colors: PrototypeSheet/Unsupported 1`] = `
1832
- "╭──────────────────────────────────────────────────────────────────────────────╮
1833
- │ │
1834
- │ rendering prototype… │
1835
- │ │
1836
- │ │
1837
- │ │
1838
- │ │
1839
- │ │
1840
- │ │
1841
- │ │
1842
- │ │
1843
- │ │
1844
- │ │
1845
- │ │
1846
- │ │
1847
- │ │
1848
- │ │
1849
- │ │
1850
- │ │
1851
- │ │
1852
- │ │
1853
- │ │
1854
- │ │
1855
- ╰──────────────────────────────────────────────────────────────────────────────╯
1856
1856
  "
1857
1857
  `;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Direct kitty graphics protocol emission for a cell rectangle. OpenTUI's
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.
7
+ */
8
+
9
+ export interface CellRegion {
10
+ /** 0-based screen cell of the region's top-left corner. */
11
+ column: number;
12
+ row: number;
13
+ columns: number;
14
+ rows: number;
15
+ }
16
+
17
+ /** Writes a raw sequence ordered against the renderer's own frame output. */
18
+ export type OrderedWrite = (chunk: string) => void;
19
+
20
+ const ESCAPE_STRING_TERMINATOR = "\x1b\\";
21
+ const CHUNK_BYTES = 4096;
22
+
23
+ /** Save the cursor, run the placement at the region's corner, restore it. */
24
+ function atRegion(region: CellRegion, body: string): string {
25
+ const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
26
+ return `\x1b7${moveToCorner}${body}\x1b8`;
27
+ }
28
+
29
+ function graphicsCommand(keys: string, payload: string): string {
30
+ return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
31
+ }
32
+
33
+ /**
34
+ * Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
35
+ * C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
36
+ * q=2 suppresses the terminal's replies. Data over 4096 bytes is chunked.
37
+ */
38
+ export function transmitKittyImage(
39
+ write: OrderedWrite,
40
+ png: Uint8Array,
41
+ region: CellRegion,
42
+ imageId: number,
43
+ ): void {
44
+ if (region.columns < 1 || region.rows < 1) return;
45
+ const base64 = Buffer.from(png).toString("base64");
46
+ // z<0 places the image beneath the text layer, so the marker popover and
47
+ // compose card (ordinary cells) still paint over it
48
+ const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`;
49
+ let sequence = "";
50
+ for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
51
+ const chunk = base64.slice(offset, offset + CHUNK_BYTES);
52
+ const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
53
+ sequence +=
54
+ offset === 0
55
+ ? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
56
+ : graphicsCommand(`m=${isMore}`, chunk);
57
+ }
58
+ write(atRegion(region, sequence));
59
+ }
60
+
61
+ /** Re-place an already-transmitted image over its region, cheaply (a=p, no data). */
62
+ export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
63
+ if (region.columns < 1 || region.rows < 1) return;
64
+ const command = graphicsCommand(
65
+ `a=p,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`,
66
+ "",
67
+ );
68
+ write(atRegion(region, command));
69
+ }
70
+
71
+ /** Remove the image so it does not linger after the sheet closes (a=d). */
72
+ export function deleteKittyImage(write: OrderedWrite, imageId: number): void {
73
+ write(graphicsCommand(`a=d,d=i,i=${imageId},q=2`, ""));
74
+ }
@@ -79,6 +79,20 @@ export function cssBoxToCell(
79
79
  };
80
80
  }
81
81
 
82
+ export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
83
+
84
+ let rendererFactory: PrototypeRendererFactory | null = null;
85
+
86
+ /** Override the renderer factory so tests can inject a browserless fake. */
87
+ export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
88
+ rendererFactory = factory;
89
+ }
90
+
91
+ /** The active factory - the injected fake in tests, else real headless Chromium. */
92
+ export function prototypeRendererFactory(): PrototypeRendererFactory {
93
+ return rendererFactory ?? launchPrototypeRenderer;
94
+ }
95
+
82
96
  export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
83
97
  const puppeteer = (await import("puppeteer-core")).default;
84
98
  const { pathToFileURL } = await import("node:url");