@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 +5 -5
- package/src/App.prototype.test.tsx +125 -0
- package/src/App.tsx +5 -0
- package/src/components/AnnotationCard.tsx +7 -0
- package/src/components/PrototypeSheet.tsx +137 -42
- package/src/components/__snapshots__/stories.test.tsx.snap +28 -28
- package/src/kitty-image.ts +74 -0
- package/src/prototype-browser.ts +14 -0
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.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.
|
|
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.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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
137
|
+
renderer.off("frame", onFrame);
|
|
138
|
+
if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
|
|
64
139
|
};
|
|
65
|
-
}, []);
|
|
140
|
+
}, [renderer]);
|
|
66
141
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
if (
|
|
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 =
|
|
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
|
|
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
|
|
209
|
+
const onRegionMouseDown = (event: { x: number; y: number }): void => {
|
|
120
210
|
const browser = browserRef.current;
|
|
121
|
-
const
|
|
122
|
-
if (status !== "ready" || !browser || !
|
|
123
|
-
const geometry = {
|
|
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:
|
|
157
|
-
top:
|
|
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
|
-
<
|
|
172
|
-
ref={(instance:
|
|
173
|
-
|
|
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={
|
|
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={() =>
|
|
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
|
+
}
|
package/src/prototype-browser.ts
CHANGED
|
@@ -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");
|