@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90

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 (316) 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 +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1402 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +774 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +28 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +67 -0
  241. package/src/launch-layout.ts +47 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Ship perf spans to a local OTLP/HTTP trace viewer (kundi) so an interaction's phases read as a
3
+ * callstack. Every export is a no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Tracing never throws
4
+ * into the app: a failed POST is swallowed. Attributes are scalar-only, matching the viewer's decoder.
5
+ */
6
+
7
+ import type { PhaseMark } from "./perf-timings";
8
+
9
+ export type SpanAttributeValue =
10
+ | { stringValue: string }
11
+ | { doubleValue: number }
12
+ | { boolValue: boolean };
13
+
14
+ export interface SpanAttribute {
15
+ key: string;
16
+ value: SpanAttributeValue;
17
+ }
18
+
19
+ export interface TraceSpan {
20
+ traceId: string;
21
+ spanId: string;
22
+ parentSpanId?: string;
23
+ name: string;
24
+ startTimeUnixNano: string;
25
+ endTimeUnixNano: string;
26
+ attributes?: readonly SpanAttribute[];
27
+ }
28
+
29
+ function randomHex(bytes: number): string {
30
+ const buffer = new Uint8Array(bytes);
31
+ crypto.getRandomValues(buffer);
32
+
33
+ return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join("");
34
+ }
35
+
36
+ /** A 16-byte trace id as 32 lowercase hex chars, per the OTLP wire format. */
37
+ export function newTraceId(): string {
38
+ return randomHex(16);
39
+ }
40
+
41
+ /** An 8-byte span id as 16 lowercase hex chars, per the OTLP wire format. */
42
+ export function newSpanId(): string {
43
+ return randomHex(8);
44
+ }
45
+
46
+ /** Build the OTLP/JSON `resourceSpans` body the trace viewer's `/v1/traces` decoder reads. */
47
+ export function buildOtlpTracePayload(serviceName: string, spans: readonly TraceSpan[]) {
48
+ return {
49
+ resourceSpans: [
50
+ {
51
+ resource: {
52
+ attributes: [{ key: "service.name", value: { stringValue: serviceName } }],
53
+ },
54
+ scopeSpans: [
55
+ {
56
+ spans: spans.map((span) => ({
57
+ traceId: span.traceId,
58
+ spanId: span.spanId,
59
+ // the viewer reads a missing parent as root; an empty string is the same, without a conditional key
60
+ parentSpanId: span.parentSpanId ?? "",
61
+ name: span.name,
62
+ startTimeUnixNano: span.startTimeUnixNano,
63
+ endTimeUnixNano: span.endTimeUnixNano,
64
+ attributes: (span.attributes ?? []).map((attribute) => ({
65
+ key: attribute.key,
66
+ value: attribute.value,
67
+ })),
68
+ })),
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ function nanosFromPerfMs(milliseconds: number): string {
77
+ const originNs = BigInt(Math.round(performance.timeOrigin * 1e6));
78
+
79
+ return String(originNs + BigInt(Math.round(milliseconds * 1e6)));
80
+ }
81
+
82
+ /** Turn drained phase marks into a span tree: a `scope` root with one child span per phase, in order. */
83
+ export function phaseMarksToSpans(scope: string, marks: readonly PhaseMark[]): TraceSpan[] {
84
+ const totalMs = marks.reduce((sum, mark) => sum + mark.elapsedMs, 0);
85
+ const endMs = performance.now();
86
+ const startMs = endMs - totalMs;
87
+ const traceId = newTraceId();
88
+ const rootSpanId = newSpanId();
89
+ const root: TraceSpan = {
90
+ traceId,
91
+ spanId: rootSpanId,
92
+ name: `cueloop.${scope}`,
93
+ startTimeUnixNano: nanosFromPerfMs(startMs),
94
+ endTimeUnixNano: nanosFromPerfMs(endMs),
95
+ };
96
+
97
+ let cursorMs = startMs;
98
+ const children = marks.map((mark) => {
99
+ const phaseStartMs = cursorMs;
100
+ cursorMs += mark.elapsedMs;
101
+
102
+ return {
103
+ traceId,
104
+ spanId: newSpanId(),
105
+ parentSpanId: rootSpanId,
106
+ name: `cueloop.${scope}.${mark.label}`,
107
+ startTimeUnixNano: nanosFromPerfMs(phaseStartMs),
108
+ endTimeUnixNano: nanosFromPerfMs(cursorMs),
109
+ };
110
+ });
111
+
112
+ return [root, ...children];
113
+ }
114
+
115
+ /** POST the spans to `${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces` as OTLP/JSON; a no-op when the endpoint is unset. */
116
+ export async function postOtlpTrace(
117
+ serviceName: string,
118
+ spans: readonly TraceSpan[],
119
+ ): Promise<void> {
120
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
121
+ if (endpoint === undefined || spans.length === 0) return;
122
+
123
+ // strip a trailing slash so an endpoint like "http://host:4318/" does not become "//v1/traces"
124
+ const base = endpoint.replace(/\/+$/, "");
125
+ await fetch(`${base}/v1/traces`, {
126
+ method: "POST",
127
+ headers: { "content-type": "application/json" },
128
+ body: JSON.stringify(buildOtlpTracePayload(serviceName, spans)),
129
+ }).catch(() => undefined);
130
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Report the drained perf marks for a scope two ways from one set of instrumentation: a phase block on
3
+ * stderr when CUELOOP_PERF=1, and a kundi span tree when the OTLP endpoint is set. No-op when neither is on.
4
+ */
5
+
6
+ import { formatPerfBlock, takePerfMarks } from "./perf-timings";
7
+ import { phaseMarksToSpans, postOtlpTrace } from "./otlp-trace";
8
+
9
+ const PERF_SERVICE_NAME = "cueloop-client";
10
+
11
+ export function reportPerfMarks(scope: string): void {
12
+ const marks = takePerfMarks();
13
+ if (marks.length === 0) return;
14
+
15
+ if (process.env.CUELOOP_PERF === "1") process.stderr.write(formatPerfBlock(scope, marks));
16
+ void postOtlpTrace(PERF_SERVICE_NAME, phaseMarksToSpans(scope, marks));
17
+ }
@@ -0,0 +1,18 @@
1
+ import { expect, test } from "bun:test";
2
+ import { formatPerfBlock } from "./perf-timings";
3
+
4
+ test("formatPerfBlock renders labeled rows and a summed TOTAL", () => {
5
+ const block = formatPerfBlock("startup", [
6
+ { label: "connect", elapsedMs: 12.34 },
7
+ { label: "firstRender", elapsedMs: 7.66 },
8
+ ]);
9
+
10
+ expect(block).toContain("--- perf startup ---");
11
+ expect(block).toContain("connect 12.3ms");
12
+ expect(block).toContain("firstRender 7.7ms");
13
+ expect(block).toContain("TOTAL 20.0ms");
14
+ });
15
+
16
+ test("formatPerfBlock totals to zero for no marks", () => {
17
+ expect(formatPerfBlock("startup", [])).toContain("TOTAL 0.0ms");
18
+ });
@@ -0,0 +1,38 @@
1
+ /** Startup phase timing for the perf harness: records elapsed ms between marks and flushes a
2
+ * labeled block to stderr. Every entry point is a no-op unless the CUELOOP_PERF env var is "1". */
3
+
4
+ export interface PhaseMark {
5
+ label: string;
6
+ elapsedMs: number;
7
+ }
8
+
9
+ const phaseMarks: PhaseMark[] = [];
10
+ let lastMarkAt: number | null = null;
11
+
12
+ /** Marks record when the stderr timer (CUELOOP_PERF=1) or the kundi exporter (OTEL endpoint) is on. */
13
+ function perfInstrumentationEnabled(): boolean {
14
+ return process.env.CUELOOP_PERF === "1" || process.env.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined;
15
+ }
16
+
17
+ /** Record the elapsed ms since the previous mark (or process start) under `label`. */
18
+ export function perfMark(label: string): void {
19
+ if (!perfInstrumentationEnabled()) return;
20
+ const now = performance.now();
21
+ phaseMarks.push({ label, elapsedMs: now - (lastMarkAt ?? 0) });
22
+ lastMarkAt = now;
23
+ }
24
+
25
+ /** Drain the recorded marks for a reporter to format or export; empties the buffer for the next scope. */
26
+ export function takePerfMarks(): PhaseMark[] {
27
+ lastMarkAt = null;
28
+
29
+ return phaseMarks.splice(0, phaseMarks.length);
30
+ }
31
+
32
+ /** Render phase deltas as the stderr block: a header, one indented row per mark, a summed TOTAL. */
33
+ export function formatPerfBlock(scope: string, marks: readonly PhaseMark[]): string {
34
+ const totalMs = marks.reduce((sum, mark) => sum + mark.elapsedMs, 0);
35
+ const rows = marks.map((mark) => ` ${mark.label} ${mark.elapsedMs.toFixed(1)}ms`);
36
+
37
+ return [`--- perf ${scope} ---`, ...rows, ` TOTAL ${totalMs.toFixed(1)}ms`, ""].join("\n");
38
+ }
@@ -0,0 +1,81 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ closePrototypeBrowser,
4
+ cssBoxToCell,
5
+ imageCellToCss,
6
+ setPrototypeBrowserForTest,
7
+ } from "./prototype-browser";
8
+
9
+ const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
10
+ const VIEWPORT = { width: 1280, height: 720 };
11
+
12
+ describe("imageCellToCss", () => {
13
+ test("maps a cell inside the image to the center of its viewport pixel band", () => {
14
+ // Act - the image's top-left cell (1,1)
15
+ const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
16
+
17
+ // Assert - half a cell into the first band
18
+ expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
19
+ expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
20
+ });
21
+
22
+ test("a click below the image's origin lands deeper in the page", () => {
23
+ // Act
24
+ const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
25
+ const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
26
+
27
+ // Assert
28
+ expect(bottom.y).toBeGreaterThan(top.y);
29
+ });
30
+
31
+ test("a cell outside the image returns null", () => {
32
+ // Assert - the gutter left of and the row above the image
33
+ expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
34
+ expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
35
+ expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe("cssBoxToCell", () => {
40
+ test("maps a viewport rect back to the image cells covering it", () => {
41
+ // Arrange - a rect covering the right half, lower half of the page
42
+ const box = { x: 640, y: 360, width: 320, height: 180 };
43
+
44
+ // Act
45
+ const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
46
+
47
+ // Assert - x at half width -> 40 cells in from the image origin (1)
48
+ expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
49
+ expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
50
+ expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
51
+ expect(cell.rows).toBe(Math.round((180 / 720) * 40));
52
+ });
53
+
54
+ test("a zero-size rect still reserves at least one cell", () => {
55
+ // Assert
56
+ const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
57
+
58
+ expect(cell.columns).toBe(1);
59
+ expect(cell.rows).toBe(1);
60
+ });
61
+ });
62
+
63
+ describe("closePrototypeBrowser", () => {
64
+ afterEach(() => setPrototypeBrowserForTest(null));
65
+
66
+ test("is a safe no-op when no prototype browser was ever launched", async () => {
67
+ // the shutdown path calls this on every quit; with no warm Chromium it must not throw or load puppeteer
68
+ await expect(closePrototypeBrowser()).resolves.toBeUndefined();
69
+ await expect(closePrototypeBrowser()).resolves.toBeUndefined();
70
+ });
71
+
72
+ test("closes a warm browser once without launching Chrome", async () => {
73
+ const close = mock(async () => {});
74
+
75
+ setPrototypeBrowserForTest({ close });
76
+ await closePrototypeBrowser();
77
+ await closePrototypeBrowser();
78
+
79
+ expect(close).toHaveBeenCalledTimes(1);
80
+ });
81
+ });
@@ -0,0 +1,329 @@
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
+ /** Install only the browser lifecycle used by unit tests, without launching Chrome. */
112
+ export function setPrototypeBrowserForTest(browser: Pick<PuppeteerBrowser, "close"> | null): void {
113
+ // SAFETY: tests call only closePrototypeBrowser before resetting this seam, so close is the sole
114
+ // browser member the shared slot can read while the double is installed.
115
+ sharedBrowser = browser ? Promise.resolve(browser as PuppeteerBrowser) : null;
116
+ }
117
+
118
+ async function warmBrowser(executablePath: string | undefined): Promise<PuppeteerBrowser> {
119
+ // reuse the warm browser only while it is still connected; a crashed or
120
+ // disconnected Chromium is dropped so the next open relaunches instead of
121
+ // calling newPage on a dead process forever
122
+ const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
123
+
124
+ if (cached && cached.connected) return cached;
125
+ const puppeteer = (await import("puppeteer-core")).default;
126
+ const launched = puppeteer.launch({
127
+ executablePath: executablePath ?? chromeExecutable(),
128
+ headless: true,
129
+ args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
130
+ });
131
+
132
+ sharedBrowser = launched;
133
+ const browser = await launched.catch((error) => {
134
+ if (sharedBrowser === launched) sharedBrowser = null;
135
+ throw error;
136
+ });
137
+
138
+ browser.once("disconnected", () => {
139
+ if (sharedBrowser === launched) sharedBrowser = null;
140
+ });
141
+
142
+ return browser;
143
+ }
144
+
145
+ /**
146
+ * Close the warm Chromium so it never orphans when cueloop exits. Called from
147
+ * the shutdown path (the normal quit is not a signal, so puppeteer's own signal
148
+ * handlers do not fire). Idempotent.
149
+ */
150
+ export async function closePrototypeBrowser(): Promise<void> {
151
+ const pending = sharedBrowser;
152
+
153
+ sharedBrowser = null;
154
+ if (!pending) return;
155
+ const browser = await pending.catch(() => null);
156
+
157
+ await browser?.close().catch(() => undefined);
158
+ }
159
+
160
+ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
161
+ const { pathToFileURL } = await import("node:url");
162
+ const browser = await warmBrowser(options.executablePath);
163
+ // close only the page on teardown, never the shared browser, so the next open
164
+ // reuses the warm Chromium
165
+ const page = await browser.newPage();
166
+
167
+ try {
168
+ await page.setViewport({
169
+ width: options.viewport.width,
170
+ height: options.viewport.height,
171
+ deviceScaleFactor: options.deviceScaleFactor ?? 2,
172
+ });
173
+ // match the terminal's scheme so a theme-aware mockup renders its dark or
174
+ // light variant to sit correctly on the surface it emerges into
175
+ await page.emulateMediaFeatures([
176
+ { name: "prefers-color-scheme", value: options.colorScheme ?? "light" },
177
+ ]);
178
+ // `load` waits for images and styles but skips networkidle0's fixed 500ms
179
+ // idle window, which a static local file would otherwise always pay
180
+ await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
181
+ // Render the mockup on the terminal's own surface rather than on an opaque
182
+ // page: strip the root background so it never paints a square box, and the
183
+ // capture (taken with alpha, below) lets the active terminal theme show
184
+ // through around the mockup's own components - the prototype emerges into
185
+ // whatever theme is running instead of sitting in a grey card.
186
+ await page.evaluate(() => {
187
+ for (const element of [document.documentElement, document.body]) {
188
+ element?.style.setProperty("background", "transparent", "important");
189
+ }
190
+ });
191
+ } catch (error) {
192
+ await page.close().catch(() => undefined);
193
+ throw error;
194
+ }
195
+
196
+ return {
197
+ viewport: options.viewport,
198
+ async screenshot() {
199
+ // omitBackground keeps the alpha from the stripped page background, so the
200
+ // terminal composites the mockup over its own theme surface.
201
+ const buffer = await page.screenshot({
202
+ type: "png",
203
+ encoding: "binary",
204
+ omitBackground: true,
205
+ });
206
+
207
+ return new Uint8Array(buffer);
208
+ },
209
+ async elementAt(cssX, cssY) {
210
+ return page.evaluate(elementAtScript, cssX, cssY);
211
+ },
212
+ async scrollBy(deltaY) {
213
+ return page.evaluate(scrollByScript, deltaY);
214
+ },
215
+ async close() {
216
+ await page.close().catch(() => undefined);
217
+ },
218
+ };
219
+ }
220
+
221
+ /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
222
+ function elementAtScript(x: number, y: number) {
223
+ const SEMANTIC = new Set([
224
+ "SECTION",
225
+ "ARTICLE",
226
+ "LI",
227
+ "NAV",
228
+ "HEADER",
229
+ "FOOTER",
230
+ "ASIDE",
231
+ "FORM",
232
+ ]);
233
+ const isNamed = (element: Element): boolean =>
234
+ element.classList.length > 0 || SEMANTIC.has(element.tagName);
235
+ // a card wraps several children: climb to the nearest named container holding
236
+ // more than one child, else the nearest named ancestor, else the clicked leaf
237
+ const componentRoot = (start: Element): Element => {
238
+ let namedFallback: Element | null = null;
239
+ let current: Element | null = start;
240
+
241
+ while (current && current !== document.body) {
242
+ if (isNamed(current)) {
243
+ if (current.childElementCount > 1) return current;
244
+ namedFallback = namedFallback ?? current;
245
+ }
246
+ current = current.parentElement;
247
+ }
248
+
249
+ return namedFallback ?? start;
250
+ };
251
+ const selectorFor = (start: Element): string => {
252
+ const parts: string[] = [];
253
+ let current: Element | null = start;
254
+
255
+ while (current && current.nodeType === 1 && current !== document.documentElement) {
256
+ let part = current.tagName.toLowerCase();
257
+
258
+ if (current.id) {
259
+ parts.unshift(part + "#" + CSS.escape(current.id));
260
+ break;
261
+ }
262
+ const parent: Element | null = current.parentElement;
263
+
264
+ if (parent) {
265
+ const currentTagName = current.tagName;
266
+ const twins = [...parent.children].filter((child) => child.tagName === currentTagName);
267
+
268
+ if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
269
+ }
270
+ parts.unshift(part);
271
+ current = current.parentElement;
272
+ }
273
+
274
+ return parts.join(" > ");
275
+ };
276
+ // Prefer the nearest focusable target before falling back to its component container.
277
+ const focusableTarget = (start: Element | null): Element | null => {
278
+ for (
279
+ let element: Element | null = start;
280
+ element && element !== document.body;
281
+ element = element.parentElement
282
+ ) {
283
+ if (element instanceof HTMLElement && element.tabIndex >= 0) {
284
+ return element;
285
+ }
286
+ }
287
+
288
+ return null;
289
+ };
290
+ const hit = document.elementFromPoint(x, y);
291
+
292
+ if (!(hit instanceof Element)) return null;
293
+ const node = focusableTarget(hit) ?? componentRoot(hit);
294
+ const selector = selectorFor(node);
295
+
296
+ if (!selector) return null;
297
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
298
+ const tag = node.tagName.toLowerCase();
299
+ const quote = text
300
+ ? text.slice(0, 80)
301
+ : node.id
302
+ ? tag + "#" + node.id
303
+ : node.classList.length
304
+ ? tag + "." + node.classList[0]
305
+ : tag;
306
+ const rect = node.getBoundingClientRect();
307
+
308
+ return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
309
+ }
310
+
311
+ function scrollByScript(deltaY: number): boolean {
312
+ const before = window.scrollY;
313
+
314
+ window.scrollBy(0, deltaY);
315
+
316
+ return window.scrollY !== before;
317
+ }
318
+
319
+ /** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
320
+ function chromeExecutable(): string {
321
+ if (process.platform === "darwin") {
322
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
323
+ }
324
+ if (process.platform === "win32") {
325
+ return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
326
+ }
327
+
328
+ return "/usr/bin/google-chrome";
329
+ }
@@ -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
+ }