@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71

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.
Files changed (248) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Mark geometry for an annotated line of text: which stretches are marked
3
+ * (a discussion span, a held selection) or carry the caret cell, the greedy
4
+ * word wrap into visual lines, and the printable-key test that starts a
5
+ * comment. Renderer-free, shared by the plan thread view and the diff sheet.
6
+ */
7
+
8
+ import type { KeyEvent } from "@opentui/core";
9
+ import { wordRanges } from "./thread-selection";
10
+
11
+ export interface MarkRange {
12
+ start: number;
13
+ end: number;
14
+ /** The bare caret's single cell - a cursor, not yet a mark. */
15
+ caretOnly?: boolean;
16
+ }
17
+
18
+ export interface Run {
19
+ text: string;
20
+ marked: boolean;
21
+ caretOnly: boolean;
22
+ }
23
+
24
+ /** Split a block's text into plain/marked runs from the union of ranges. */
25
+ export function runsFor(text: string, ranges: MarkRange[]): Run[] {
26
+ const cuts = new Set<number>([0, text.length]);
27
+
28
+ for (const range of ranges) {
29
+ cuts.add(Math.max(0, range.start));
30
+ cuts.add(Math.min(text.length, range.end));
31
+ }
32
+ const edges = [...cuts].toSorted((left, right) => left - right);
33
+ const runs: Run[] = [];
34
+
35
+ for (let index = 0; index < edges.length - 1; index++) {
36
+ const start = edges[index]!;
37
+ const end = edges[index + 1]!;
38
+
39
+ if (end <= start) continue;
40
+ const covering = ranges.filter((range) => range.start <= start && end <= range.end);
41
+ // the caret cell shows through a mark, so the head of a selection is visible
42
+ runs.push({
43
+ text: text.slice(start, end),
44
+ marked: covering.some((range) => range.caretOnly !== true),
45
+ caretOnly: covering.some((range) => range.caretOnly === true),
46
+ });
47
+ }
48
+
49
+ return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
50
+ }
51
+
52
+ /** A visual line of a block: the char range of the block's text it shows. */
53
+ export interface VisualLine {
54
+ start: number;
55
+ end: number;
56
+ }
57
+
58
+ /**
59
+ * Greedy word wrap into visual-line char ranges, so a comment card can slot
60
+ * in directly below the line its mark ends on (mid-paragraph when needed).
61
+ */
62
+ export function wrapLines(text: string, width: number): VisualLine[] {
63
+ const lines: VisualLine[] = [];
64
+ let segmentStart = 0;
65
+
66
+ // hard breaks first: every visual line must be exactly one terminal row,
67
+ // or hit-testing and the mark geometry drift from what is painted
68
+ for (const segment of text.split("\n")) {
69
+ const segmentEnd = segmentStart + segment.length;
70
+
71
+ if (width <= 0 || segment.length <= width) {
72
+ lines.push({ start: segmentStart, end: segmentEnd });
73
+ } else {
74
+ // greedy word wrap inside the segment; the first line keeps its
75
+ // leading indentation, continuation lines start at a word
76
+ let lineStart = segmentStart;
77
+ let lineEnd = segmentStart;
78
+
79
+ for (const word of wordRanges(segment)) {
80
+ const wordStart = segmentStart + word.start;
81
+ const wordEnd = segmentStart + word.end;
82
+
83
+ if (wordEnd - lineStart > width && lineEnd > lineStart) {
84
+ lines.push({ start: lineStart, end: lineEnd });
85
+ lineStart = wordStart;
86
+ }
87
+ // a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
88
+ // every visual line stays one terminal row and nothing is clipped away
89
+ while (wordEnd - lineStart > width) {
90
+ lines.push({ start: lineStart, end: lineStart + width });
91
+ lineStart += width;
92
+ }
93
+ lineEnd = wordEnd;
94
+ }
95
+ lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
96
+ }
97
+ segmentStart = segmentEnd + 1;
98
+ }
99
+
100
+ return lines;
101
+ }
102
+
103
+ /** The block's mark ranges clipped to one visual line, in that line's coordinates. */
104
+ export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange[] {
105
+ return ranges
106
+ .map((range) => ({
107
+ start: Math.max(range.start, line.start) - line.start,
108
+ end: Math.min(range.end, line.end) - line.start,
109
+ caretOnly: range.caretOnly,
110
+ }))
111
+ .filter((range) => range.end > range.start);
112
+ }
113
+
114
+ /** A printable character: single-width input that should reach a composer. */
115
+ export function printableSequence(key: KeyEvent): string | null {
116
+ const printable =
117
+ key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta && key.sequence >= " ";
118
+
119
+ return printable ? key.sequence : null;
120
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { cssBoxToCell, imageCellToCss } from "./prototype-browser";
3
+
4
+ const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
5
+ const VIEWPORT = { width: 1280, height: 720 };
6
+
7
+ describe("imageCellToCss", () => {
8
+ test("maps a cell inside the image to the center of its viewport pixel band", () => {
9
+ // Act - the image's top-left cell (1,1)
10
+ const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
11
+
12
+ // Assert - half a cell into the first band
13
+ expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
14
+ expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
15
+ });
16
+
17
+ test("a click below the image's origin lands deeper in the page", () => {
18
+ // Act
19
+ const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
20
+ const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
21
+
22
+ // Assert
23
+ expect(bottom.y).toBeGreaterThan(top.y);
24
+ });
25
+
26
+ test("a cell outside the image returns null", () => {
27
+ // Assert - the gutter left of and the row above the image
28
+ expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
29
+ expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
30
+ expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
31
+ });
32
+ });
33
+
34
+ describe("cssBoxToCell", () => {
35
+ test("maps a viewport rect back to the image cells covering it", () => {
36
+ // Arrange - a rect covering the right half, lower half of the page
37
+ const box = { x: 640, y: 360, width: 320, height: 180 };
38
+
39
+ // Act
40
+ const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
41
+
42
+ // Assert - x at half width -> 40 cells in from the image origin (1)
43
+ expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
44
+ expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
45
+ expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
46
+ expect(cell.rows).toBe(Math.round((180 / 720) * 40));
47
+ });
48
+
49
+ test("a zero-size rect still reserves at least one cell", () => {
50
+ // Assert
51
+ const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
52
+
53
+ expect(cell.columns).toBe(1);
54
+ expect(cell.rows).toBe(1);
55
+ });
56
+ });
@@ -0,0 +1,307 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Headless-Chromium backing for prototype review: render an HTML file to a PNG
4
+ * and resolve a click coordinate to a DOM element. puppeteer-core is imported
5
+ * lazily so plan/diff review and the test suite never load it.
6
+ */
7
+
8
+ export interface PrototypeElement {
9
+ /** Stable CSS selector - the annotation anchor's authority. */
10
+ selector: string;
11
+ /** Short human label for the rail card (element text, else the selector). */
12
+ quote: string;
13
+ /** Element rectangle in CSS pixels within the captured viewport. */
14
+ box: ElementBox;
15
+ }
16
+
17
+ export interface ElementBox {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ }
23
+
24
+ export interface PrototypeViewport {
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ export interface PrototypeRenderer {
30
+ readonly viewport: PrototypeViewport;
31
+ screenshot(): Promise<Uint8Array>;
32
+ elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
33
+ /** Scroll the page by a pixel delta; returns whether the scroll position moved. */
34
+ scrollBy(deltaY: number): Promise<boolean>;
35
+ close(): Promise<void>;
36
+ }
37
+
38
+ export interface LaunchOptions {
39
+ filePath: string;
40
+ viewport: PrototypeViewport;
41
+ /** Capture density; 1 when the viewport already matches the region's pixels. */
42
+ deviceScaleFactor?: number;
43
+ /** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
44
+ executablePath?: string;
45
+ /** The terminal's color scheme, emulated as prefers-color-scheme so a mockup
46
+ * with theme-aware CSS renders to match the surface it emerges into. */
47
+ colorScheme?: "dark" | "light";
48
+ }
49
+
50
+ /** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
51
+ export function imageCellToCss(
52
+ event: { x: number; y: number },
53
+ image: { x: number; y: number; width: number; height: number },
54
+ viewport: PrototypeViewport,
55
+ ): { x: number; y: number } | null {
56
+ const columnInImage = event.x - image.x;
57
+ const rowInImage = event.y - image.y;
58
+
59
+ if (
60
+ columnInImage < 0 ||
61
+ rowInImage < 0 ||
62
+ columnInImage >= image.width ||
63
+ rowInImage >= image.height
64
+ ) {
65
+ return null;
66
+ }
67
+
68
+ return {
69
+ x: ((columnInImage + 0.5) / image.width) * viewport.width,
70
+ y: ((rowInImage + 0.5) / image.height) * viewport.height,
71
+ };
72
+ }
73
+
74
+ /** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
75
+ export function cssBoxToCell(
76
+ box: ElementBox,
77
+ image: { x: number; y: number; width: number; height: number },
78
+ viewport: PrototypeViewport,
79
+ ) {
80
+ const scaleColumn = image.width / viewport.width;
81
+ const scaleRow = image.height / viewport.height;
82
+
83
+ return {
84
+ column: image.x + Math.floor(box.x * scaleColumn),
85
+ row: image.y + Math.floor(box.y * scaleRow),
86
+ columns: Math.max(1, Math.round(box.width * scaleColumn)),
87
+ rows: Math.max(1, Math.round(box.height * scaleRow)),
88
+ };
89
+ }
90
+
91
+ export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
92
+
93
+ let rendererFactory: PrototypeRendererFactory | null = null;
94
+
95
+ /** Override the renderer factory so tests can inject a browserless fake. */
96
+ export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
97
+ rendererFactory = factory;
98
+ }
99
+
100
+ /** The active factory - the injected fake in tests, else real headless Chromium. */
101
+ export function prototypeRendererFactory(): PrototypeRendererFactory {
102
+ return rendererFactory ?? launchPrototypeRenderer;
103
+ }
104
+
105
+ type PuppeteerBrowser = Awaited<ReturnType<typeof import("puppeteer-core").default.launch>>;
106
+
107
+ // One Chromium per process, kept warm and reused across prototype opens: the
108
+ // launch is the dominant load cost, so each open only spawns a fresh page.
109
+ let sharedBrowser: Promise<PuppeteerBrowser> | null = null;
110
+
111
+ async function warmBrowser(executablePath: string | undefined): Promise<PuppeteerBrowser> {
112
+ // reuse the warm browser only while it is still connected; a crashed or
113
+ // disconnected Chromium is dropped so the next open relaunches instead of
114
+ // calling newPage on a dead process forever
115
+ const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
116
+
117
+ if (cached && cached.connected) return cached;
118
+ const puppeteer = (await import("puppeteer-core")).default;
119
+ const launched = puppeteer.launch({
120
+ executablePath: executablePath ?? chromeExecutable(),
121
+ headless: true,
122
+ args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
123
+ });
124
+
125
+ sharedBrowser = launched;
126
+ const browser = await launched.catch((error) => {
127
+ if (sharedBrowser === launched) sharedBrowser = null;
128
+ throw error;
129
+ });
130
+
131
+ browser.once("disconnected", () => {
132
+ if (sharedBrowser === launched) sharedBrowser = null;
133
+ });
134
+
135
+ return browser;
136
+ }
137
+
138
+ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
139
+ const { pathToFileURL } = await import("node:url");
140
+ const browser = await warmBrowser(options.executablePath);
141
+ // close only the page on teardown, never the shared browser, so the next open
142
+ // reuses the warm Chromium
143
+ const page = await browser.newPage();
144
+
145
+ try {
146
+ await page.setViewport({
147
+ width: options.viewport.width,
148
+ height: options.viewport.height,
149
+ deviceScaleFactor: options.deviceScaleFactor ?? 2,
150
+ });
151
+ // match the terminal's scheme so a theme-aware mockup renders its dark or
152
+ // light variant to sit correctly on the surface it emerges into
153
+ await page.emulateMediaFeatures([
154
+ { name: "prefers-color-scheme", value: options.colorScheme ?? "light" },
155
+ ]);
156
+ // `load` waits for images and styles but skips networkidle0's fixed 500ms
157
+ // idle window, which a static local file would otherwise always pay
158
+ await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
159
+ // Render the mockup on the terminal's own surface rather than on an opaque
160
+ // page: strip the root background so it never paints a square box, and the
161
+ // capture (taken with alpha, below) lets the active terminal theme show
162
+ // through around the mockup's own components - the prototype emerges into
163
+ // whatever theme is running instead of sitting in a grey card.
164
+ await page.evaluate(() => {
165
+ for (const element of [document.documentElement, document.body]) {
166
+ element?.style.setProperty("background", "transparent", "important");
167
+ }
168
+ });
169
+ } catch (error) {
170
+ await page.close().catch(() => undefined);
171
+ throw error;
172
+ }
173
+
174
+ return {
175
+ viewport: options.viewport,
176
+ async screenshot() {
177
+ // omitBackground keeps the alpha from the stripped page background, so the
178
+ // terminal composites the mockup over its own theme surface.
179
+ const buffer = await page.screenshot({
180
+ type: "png",
181
+ encoding: "binary",
182
+ omitBackground: true,
183
+ });
184
+
185
+ return new Uint8Array(buffer);
186
+ },
187
+ async elementAt(cssX, cssY) {
188
+ return page.evaluate(elementAtScript, cssX, cssY);
189
+ },
190
+ async scrollBy(deltaY) {
191
+ return page.evaluate(scrollByScript, deltaY);
192
+ },
193
+ async close() {
194
+ await page.close().catch(() => undefined);
195
+ },
196
+ };
197
+ }
198
+
199
+ /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
200
+ function elementAtScript(x: number, y: number) {
201
+ const SEMANTIC = new Set([
202
+ "SECTION",
203
+ "ARTICLE",
204
+ "LI",
205
+ "NAV",
206
+ "HEADER",
207
+ "FOOTER",
208
+ "ASIDE",
209
+ "FORM",
210
+ ]);
211
+ const isNamed = (element: Element): boolean =>
212
+ element.classList.length > 0 || SEMANTIC.has(element.tagName);
213
+ // a card wraps several children: climb to the nearest named container holding
214
+ // more than one child, else the nearest named ancestor, else the clicked leaf
215
+ const componentRoot = (start: Element): Element => {
216
+ let namedFallback: Element | null = null;
217
+ let current: Element | null = start;
218
+
219
+ while (current && current !== document.body) {
220
+ if (isNamed(current)) {
221
+ if (current.childElementCount > 1) return current;
222
+ namedFallback = namedFallback ?? current;
223
+ }
224
+ current = current.parentElement;
225
+ }
226
+
227
+ return namedFallback ?? start;
228
+ };
229
+ const selectorFor = (start: Element): string => {
230
+ const parts: string[] = [];
231
+ let current: Element | null = start;
232
+
233
+ while (current && current.nodeType === 1 && current !== document.documentElement) {
234
+ let part = current.tagName.toLowerCase();
235
+
236
+ if (current.id) {
237
+ parts.unshift(part + "#" + CSS.escape(current.id));
238
+ break;
239
+ }
240
+ const parent: Element | null = current.parentElement;
241
+
242
+ if (parent) {
243
+ const currentTagName = current.tagName;
244
+ const twins = [...parent.children].filter((child) => child.tagName === currentTagName);
245
+
246
+ if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
247
+ }
248
+ parts.unshift(part);
249
+ current = current.parentElement;
250
+ }
251
+
252
+ return parts.join(" > ");
253
+ };
254
+ // Prefer the nearest focusable target before falling back to its component container.
255
+ const focusableTarget = (start: Element | null): Element | null => {
256
+ for (
257
+ let element: Element | null = start;
258
+ element && element !== document.body;
259
+ element = element.parentElement
260
+ ) {
261
+ if (element instanceof HTMLElement && element.tabIndex >= 0) {
262
+ return element;
263
+ }
264
+ }
265
+
266
+ return null;
267
+ };
268
+ const hit = document.elementFromPoint(x, y);
269
+
270
+ if (!(hit instanceof Element)) return null;
271
+ const node = focusableTarget(hit) ?? componentRoot(hit);
272
+ const selector = selectorFor(node);
273
+
274
+ if (!selector) return null;
275
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
276
+ const tag = node.tagName.toLowerCase();
277
+ const quote = text
278
+ ? text.slice(0, 80)
279
+ : node.id
280
+ ? tag + "#" + node.id
281
+ : node.classList.length
282
+ ? tag + "." + node.classList[0]
283
+ : tag;
284
+ const rect = node.getBoundingClientRect();
285
+
286
+ return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
287
+ }
288
+
289
+ function scrollByScript(deltaY: number): boolean {
290
+ const before = window.scrollY;
291
+
292
+ window.scrollBy(0, deltaY);
293
+
294
+ return window.scrollY !== before;
295
+ }
296
+
297
+ /** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
298
+ function chromeExecutable(): string {
299
+ if (process.platform === "darwin") {
300
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
301
+ }
302
+ if (process.platform === "win32") {
303
+ return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
304
+ }
305
+
306
+ return "/usr/bin/google-chrome";
307
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Lazy boundary for the opt-in kitty pixel prototype. The pixel path pulls in the
3
+ * kitty image writer, the headless-browser renderer, and (on first paint) puppeteer;
4
+ * none of that belongs in the runtime unless the `[experimental] prototype_pixels`
5
+ * flag is on. Importing only the TYPE here is erased at build time, so the real
6
+ * module - and its dependencies - loads only when this component actually mounts,
7
+ * which App does exclusively for a pixel prototype. This lives outside components/
8
+ * on purpose: it is an app boundary, not a visual component the story catalog covers.
9
+ */
10
+
11
+ import React, { useEffect, useState } from "react";
12
+ import type { PrototypeContentViewProps } from "./components/PrototypeContentView";
13
+
14
+ type PixelPrototypeView =
15
+ (typeof import("./components/PrototypeContentView"))["PrototypeContentView"];
16
+
17
+ export function PrototypePixels(props: PrototypeContentViewProps): React.ReactNode {
18
+ const [View, setView] = useState<PixelPrototypeView | null>(null);
19
+
20
+ useEffect(() => {
21
+ let alive = true;
22
+
23
+ void import("./components/PrototypeContentView").then((module) => {
24
+ if (alive) setView(() => module.PrototypeContentView);
25
+ });
26
+
27
+ return () => {
28
+ alive = false;
29
+ };
30
+ }, []);
31
+
32
+ if (!View) return null;
33
+
34
+ return <View {...props} />;
35
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ptyAvailable, spawn } from "./pty";
3
+
4
+ // The forkpty shim is a per-platform prebuilt; skip where none ships (e.g. CI
5
+ // without the built dylib), exactly as the caller degrades to a herdr split.
6
+ const ptyTest = ptyAvailable() ? test : test.skip;
7
+
8
+ /** Run a child on a PTY, collect its output, and resolve with the exit code. */
9
+ function runOnPty(file: string, args: string[]): Promise<{ output: string; exitCode: number }> {
10
+ return new Promise((resolve, reject) => {
11
+ const pty = spawn(file, args, { name: "xterm-256color", cols: 80, rows: 24 });
12
+ let output = "";
13
+ const timeout = setTimeout(() => reject(new Error("pty child never exited")), 5000);
14
+
15
+ pty.onData((chunk) => {
16
+ output += chunk;
17
+ });
18
+ pty.onExit(({ exitCode }) => {
19
+ clearTimeout(timeout);
20
+ resolve({ output, exitCode });
21
+ });
22
+ });
23
+ }
24
+
25
+ describe("pty", () => {
26
+ ptyTest("streams a child's output and reports its exit code", async () => {
27
+ // Act
28
+ const { output, exitCode } = await runOnPty("sh", ["-c", "printf 'PTY-OK'; exit 7"]);
29
+
30
+ // Assert
31
+ expect(output).toContain("PTY-OK");
32
+ expect(exitCode).toBe(7);
33
+ });
34
+
35
+ ptyTest("forwards written input to the child", async () => {
36
+ // Arrange - `cat` echoes stdin back through the tty until it closes
37
+ const pty = spawn("cat", [], { name: "xterm-256color", cols: 80, rows: 24 });
38
+ let output = "";
39
+
40
+ pty.onData((chunk) => {
41
+ output += chunk;
42
+ });
43
+
44
+ // Act
45
+ pty.write("ping\n");
46
+ await new Promise((resolve) => setTimeout(resolve, 200));
47
+ pty.kill();
48
+
49
+ // Assert - the tty echoes the written line back
50
+ expect(output).toContain("ping");
51
+ });
52
+
53
+ ptyTest("passes env through to the child", async () => {
54
+ // Act
55
+ const { output } = await runOnPty("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""]);
56
+
57
+ // Assert - a child with no inherited marker prints nothing for it
58
+ expect(output).not.toContain("marker-value");
59
+
60
+ // Act - now inject the marker via env
61
+ const withEnv = await new Promise<string>((resolve, reject) => {
62
+ const pty = spawn("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""], {
63
+ name: "xterm-256color",
64
+ env: { ...process.env, CUELOOP_PTY_MARKER: "marker-value" },
65
+ });
66
+ let collected = "";
67
+ const timeout = setTimeout(() => reject(new Error("no exit")), 5000);
68
+
69
+ pty.onData((chunk) => {
70
+ collected += chunk;
71
+ });
72
+ pty.onExit(() => {
73
+ clearTimeout(timeout);
74
+ resolve(collected);
75
+ });
76
+ });
77
+
78
+ // Assert
79
+ expect(withEnv).toContain("marker-value");
80
+ });
81
+ });