@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) 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 +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -0,0 +1,289 @@
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
+ }
46
+
47
+ /** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
48
+ export function imageCellToCss(
49
+ event: { x: number; y: number },
50
+ image: { x: number; y: number; width: number; height: number },
51
+ viewport: PrototypeViewport,
52
+ ): { x: number; y: number } | null {
53
+ const columnInImage = event.x - image.x;
54
+ const rowInImage = event.y - image.y;
55
+
56
+ if (
57
+ columnInImage < 0 ||
58
+ rowInImage < 0 ||
59
+ columnInImage >= image.width ||
60
+ rowInImage >= image.height
61
+ ) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ x: ((columnInImage + 0.5) / image.width) * viewport.width,
67
+ y: ((rowInImage + 0.5) / image.height) * viewport.height,
68
+ };
69
+ }
70
+
71
+ /** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
72
+ export function cssBoxToCell(
73
+ box: ElementBox,
74
+ image: { x: number; y: number; width: number; height: number },
75
+ viewport: PrototypeViewport,
76
+ ): { column: number; row: number; columns: number; rows: number } {
77
+ const scaleColumn = image.width / viewport.width;
78
+ const scaleRow = image.height / viewport.height;
79
+
80
+ return {
81
+ column: image.x + Math.floor(box.x * scaleColumn),
82
+ row: image.y + Math.floor(box.y * scaleRow),
83
+ columns: Math.max(1, Math.round(box.width * scaleColumn)),
84
+ rows: Math.max(1, Math.round(box.height * scaleRow)),
85
+ };
86
+ }
87
+
88
+ export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
89
+
90
+ let rendererFactory: PrototypeRendererFactory | null = null;
91
+
92
+ /** Override the renderer factory so tests can inject a browserless fake. */
93
+ export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
94
+ rendererFactory = factory;
95
+ }
96
+
97
+ /** The active factory - the injected fake in tests, else real headless Chromium. */
98
+ export function prototypeRendererFactory(): PrototypeRendererFactory {
99
+ return rendererFactory ?? launchPrototypeRenderer;
100
+ }
101
+
102
+ type PuppeteerBrowser = Awaited<ReturnType<typeof import("puppeteer-core").default.launch>>;
103
+
104
+ // One Chromium per process, kept warm and reused across prototype opens: the
105
+ // launch is the dominant load cost, so each open only spawns a fresh page.
106
+ let sharedBrowser: Promise<PuppeteerBrowser> | null = null;
107
+
108
+ async function warmBrowser(executablePath: string | undefined): Promise<PuppeteerBrowser> {
109
+ // reuse the warm browser only while it is still connected; a crashed or
110
+ // disconnected Chromium is dropped so the next open relaunches instead of
111
+ // calling newPage on a dead process forever
112
+ const cached = sharedBrowser ? await sharedBrowser.catch(() => null) : null;
113
+
114
+ if (cached && cached.connected) return cached;
115
+ const puppeteer = (await import("puppeteer-core")).default;
116
+ const launched = puppeteer.launch({
117
+ executablePath: executablePath ?? chromeExecutable(),
118
+ headless: true,
119
+ args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
120
+ });
121
+
122
+ sharedBrowser = launched;
123
+ const browser = await launched.catch((error) => {
124
+ if (sharedBrowser === launched) sharedBrowser = null;
125
+ throw error;
126
+ });
127
+
128
+ browser.once("disconnected", () => {
129
+ if (sharedBrowser === launched) sharedBrowser = null;
130
+ });
131
+
132
+ return browser;
133
+ }
134
+
135
+ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
136
+ const { pathToFileURL } = await import("node:url");
137
+ const browser = await warmBrowser(options.executablePath);
138
+ // close only the page on teardown, never the shared browser, so the next open
139
+ // reuses the warm Chromium
140
+ const page = await browser.newPage();
141
+
142
+ try {
143
+ await page.setViewport({
144
+ width: options.viewport.width,
145
+ height: options.viewport.height,
146
+ deviceScaleFactor: options.deviceScaleFactor ?? 2,
147
+ });
148
+ // `load` waits for images and styles but skips networkidle0's fixed 500ms
149
+ // idle window, which a static local file would otherwise always pay
150
+ await page.goto(pathToFileURL(options.filePath).href, { waitUntil: "load" });
151
+ // Render the mockup on the terminal's own surface rather than on an opaque
152
+ // page: strip the root background so it never paints a square box, and the
153
+ // capture (taken with alpha, below) lets the active terminal theme show
154
+ // through around the mockup's own components - the prototype emerges into
155
+ // whatever theme is running instead of sitting in a grey card.
156
+ await page.evaluate(() => {
157
+ for (const element of [document.documentElement, document.body]) {
158
+ element?.style.setProperty("background", "transparent", "important");
159
+ }
160
+ });
161
+ } catch (error) {
162
+ await page.close().catch(() => undefined);
163
+ throw error;
164
+ }
165
+
166
+ return {
167
+ viewport: options.viewport,
168
+ async screenshot() {
169
+ // omitBackground keeps the alpha from the stripped page background, so the
170
+ // terminal composites the mockup over its own theme surface.
171
+ const buffer = await page.screenshot({
172
+ type: "png",
173
+ encoding: "binary",
174
+ omitBackground: true,
175
+ });
176
+
177
+ return new Uint8Array(buffer as Buffer);
178
+ },
179
+ async elementAt(cssX, cssY) {
180
+ return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
181
+ },
182
+ async scrollBy(deltaY) {
183
+ return (await page.evaluate(scrollByScript, deltaY)) as boolean;
184
+ },
185
+ async close() {
186
+ await page.close().catch(() => undefined);
187
+ },
188
+ };
189
+ }
190
+
191
+ /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
192
+ function elementAtScript(x: number, y: number): unknown {
193
+ const SEMANTIC = new Set([
194
+ "SECTION",
195
+ "ARTICLE",
196
+ "LI",
197
+ "NAV",
198
+ "HEADER",
199
+ "FOOTER",
200
+ "ASIDE",
201
+ "FORM",
202
+ ]);
203
+ const isNamed = (element: Element): boolean =>
204
+ element.classList.length > 0 || SEMANTIC.has(element.tagName);
205
+ // a card wraps several children: climb to the nearest named container holding
206
+ // more than one child, else the nearest named ancestor, else the clicked leaf
207
+ const componentRoot = (start: Element): Element => {
208
+ let namedFallback: Element | null = null;
209
+ let current: Element | null = start;
210
+
211
+ while (current && current !== document.body) {
212
+ if (isNamed(current)) {
213
+ if (current.childElementCount > 1) return current;
214
+ namedFallback = namedFallback ?? current;
215
+ }
216
+ current = current.parentElement;
217
+ }
218
+
219
+ return namedFallback ?? start;
220
+ };
221
+ const selectorFor = (start: Element): string => {
222
+ const parts: string[] = [];
223
+ let current: Element | null = start;
224
+
225
+ while (current && current.nodeType === 1 && current !== document.documentElement) {
226
+ let part = current.tagName.toLowerCase();
227
+
228
+ if (current.id) {
229
+ parts.unshift(part + "#" + CSS.escape(current.id));
230
+ break;
231
+ }
232
+ const parent: Element | null = current.parentElement;
233
+
234
+ if (parent) {
235
+ const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
236
+
237
+ if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
238
+ }
239
+ parts.unshift(part);
240
+ current = current.parentElement;
241
+ }
242
+
243
+ return parts.join(" > ");
244
+ };
245
+ const hit = document.elementFromPoint(x, y);
246
+
247
+ if (!(hit instanceof Element)) return null;
248
+ // A click on an interactive control anchors to that control - a button in a
249
+ // grid is the target, not the grid. Everything else climbs to its component.
250
+ const control = hit.closest(
251
+ "button, a, [role='button'], input, select, textarea, label, summary",
252
+ );
253
+ const node = control ?? componentRoot(hit);
254
+ const selector = selectorFor(node);
255
+
256
+ if (!selector) return null;
257
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
258
+ const tag = node.tagName.toLowerCase();
259
+ const quote = text
260
+ ? text.slice(0, 80)
261
+ : node.id
262
+ ? tag + "#" + node.id
263
+ : node.classList.length
264
+ ? tag + "." + node.classList[0]
265
+ : tag;
266
+ const rect = node.getBoundingClientRect();
267
+
268
+ return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
269
+ }
270
+
271
+ function scrollByScript(deltaY: number): boolean {
272
+ const before = window.scrollY;
273
+
274
+ window.scrollBy(0, deltaY);
275
+
276
+ return window.scrollY !== before;
277
+ }
278
+
279
+ /** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
280
+ function chromeExecutable(): string {
281
+ if (process.platform === "darwin") {
282
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
283
+ }
284
+ if (process.platform === "win32") {
285
+ return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
286
+ }
287
+
288
+ return "/usr/bin/google-chrome";
289
+ }
@@ -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
+ });
package/src/pty.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * A pseudo-terminal for the embedded Agent-tab terminal, over cueloop's own
3
+ * forkpty(3) FFI shim (native/src/pty.zig). `spawn` returns a `Pty` with
4
+ * `onData`/`onExit` events and `write`/`resize`/`kill`; a poll loop drains the
5
+ * child's output and decodes it as streaming UTF-8. `ptyAvailable()` returns
6
+ * false when no prebuilt shim ships for this platform, so callers fall back to a
7
+ * herdr split, exactly like the Ghostty VT loader.
8
+ */
9
+
10
+ import { dlopen, FFIType, ptr } from "bun:ffi";
11
+ import { existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ const DEFAULT_COLS = 80;
15
+ const DEFAULT_ROWS = 24;
16
+ /** How long the read loop sleeps when the child produced no output this tick. */
17
+ const READ_IDLE_MS = 8;
18
+ const READ_BUFFER_BYTES = 4096;
19
+ /** cueloop_pty_read's sentinel: the child has exited and its output is drained. */
20
+ const CHILD_EXITED = -2;
21
+
22
+ export interface PtyForkOptions {
23
+ name?: string;
24
+ cols?: number;
25
+ rows?: number;
26
+ cwd?: string;
27
+ env?: Record<string, string>;
28
+ }
29
+
30
+ export interface ExitEvent {
31
+ exitCode: number;
32
+ }
33
+
34
+ export interface Disposable {
35
+ dispose(): void;
36
+ }
37
+
38
+ /** The pseudo-terminal surface the embedded terminal consumes. */
39
+ export interface IPty {
40
+ readonly pid: number;
41
+ readonly cols: number;
42
+ readonly rows: number;
43
+ readonly onData: (listener: (data: string) => void) => Disposable;
44
+ readonly onExit: (listener: (event: ExitEvent) => void) => Disposable;
45
+ write(data: string): void;
46
+ resize(cols: number, rows: number): void;
47
+ kill(signal?: string): void;
48
+ }
49
+
50
+ const PTY_SYMBOLS = {
51
+ cueloop_pty_spawn: {
52
+ args: [FFIType.ptr, FFIType.cstring, FFIType.ptr, FFIType.i32, FFIType.i32],
53
+ returns: FFIType.i32,
54
+ },
55
+ cueloop_pty_write: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
56
+ cueloop_pty_read: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
57
+ cueloop_pty_resize: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
58
+ cueloop_pty_kill: { args: [FFIType.i32], returns: FFIType.i32 },
59
+ cueloop_pty_get_pid: { args: [FFIType.i32], returns: FFIType.i32 },
60
+ cueloop_pty_get_exit_code: { args: [FFIType.i32], returns: FFIType.i32 },
61
+ cueloop_pty_close: { args: [FFIType.i32], returns: FFIType.void },
62
+ } as const;
63
+
64
+ type PtyLib = ReturnType<typeof dlopen<typeof PTY_SYMBOLS>>["symbols"];
65
+
66
+ /** The prebuilt pty shim for this platform, or null when none ships for it. */
67
+ function nativeLibraryPath(): string | null {
68
+ const suffix = process.platform === "darwin" ? "dylib" : "so";
69
+ const dir = `${process.platform}-${process.arch}`;
70
+ const path = join(import.meta.dir, "..", "native", dir, `libcuelooppty.${suffix}`);
71
+
72
+ return existsSync(path) ? path : null;
73
+ }
74
+
75
+ /** One shared load of the pty shim; null when no dylib ships for the platform. */
76
+ let ptyLib: PtyLib | null | undefined;
77
+
78
+ function library(): PtyLib | null {
79
+ if (ptyLib !== undefined) return ptyLib;
80
+ const path = nativeLibraryPath();
81
+
82
+ if (!path) return (ptyLib = null);
83
+ try {
84
+ ptyLib = dlopen(path, PTY_SYMBOLS).symbols;
85
+ } catch (error) {
86
+ console.error(`cueloop: failed to load ${path}:`, error);
87
+ ptyLib = null;
88
+ }
89
+
90
+ return ptyLib;
91
+ }
92
+
93
+ /** True when this platform ships a prebuilt pty shim (embedding is possible). */
94
+ export function ptyAvailable(): boolean {
95
+ return library() !== null;
96
+ }
97
+
98
+ class EventEmitter<T> {
99
+ private listeners: ((value: T) => void)[] = [];
100
+ readonly event = (listener: (value: T) => void): Disposable => {
101
+ this.listeners.push(listener);
102
+
103
+ return {
104
+ dispose: () => {
105
+ const index = this.listeners.indexOf(listener);
106
+
107
+ if (index !== -1) this.listeners.splice(index, 1);
108
+ },
109
+ };
110
+ };
111
+ fire(value: T): void {
112
+ for (const listener of this.listeners) listener(value);
113
+ }
114
+ }
115
+
116
+ /** Pack tokens into the C shim's "tok\0tok\0...\0\0" (double-NUL-terminated) form. */
117
+ function packTokens(tokens: string[]): Buffer {
118
+ return Buffer.from(tokens.map((token) => `${token}\0`).join("") + "\0", "utf8");
119
+ }
120
+
121
+ class Pty implements IPty {
122
+ private handle: number;
123
+ private processId: number;
124
+ private columns: number;
125
+ private rowCount: number;
126
+ private closing = false;
127
+ private reading = false;
128
+ private readonly decoder = new TextDecoder("utf-8");
129
+ private readonly dataEvent = new EventEmitter<string>();
130
+ private readonly exitEvent = new EventEmitter<ExitEvent>();
131
+
132
+ constructor(
133
+ private readonly lib: PtyLib,
134
+ file: string,
135
+ args: string[],
136
+ options: PtyForkOptions,
137
+ ) {
138
+ this.columns = options.cols ?? DEFAULT_COLS;
139
+ this.rowCount = options.rows ?? DEFAULT_ROWS;
140
+ const cwd = options.cwd ?? process.cwd();
141
+ const env = options.env
142
+ ? Object.entries(options.env).map(([key, value]) => `${key}=${value}`)
143
+ : [];
144
+
145
+ const argvPacked = packTokens([file, ...args]);
146
+ const envPacked = packTokens(env);
147
+
148
+ this.handle = lib.cueloop_pty_spawn(
149
+ ptr(argvPacked),
150
+ Buffer.from(`${cwd}\0`, "utf8"),
151
+ ptr(envPacked),
152
+ this.columns,
153
+ this.rowCount,
154
+ );
155
+ if (this.handle < 0) throw new Error("PTY spawn failed");
156
+ this.processId = lib.cueloop_pty_get_pid(this.handle);
157
+ // Let the caller attach onData/onExit before the first bytes arrive.
158
+ queueMicrotask(() => void this.readLoop());
159
+ }
160
+
161
+ get pid(): number {
162
+ return this.processId;
163
+ }
164
+ get cols(): number {
165
+ return this.columns;
166
+ }
167
+ get rows(): number {
168
+ return this.rowCount;
169
+ }
170
+ get onData() {
171
+ return this.dataEvent.event;
172
+ }
173
+ get onExit() {
174
+ return this.exitEvent.event;
175
+ }
176
+
177
+ write(data: string): void {
178
+ if (this.closing) return;
179
+ const buffer = Buffer.from(data, "utf8");
180
+
181
+ this.lib.cueloop_pty_write(this.handle, ptr(buffer), buffer.length);
182
+ }
183
+
184
+ resize(cols: number, rows: number): void {
185
+ if (this.closing) return;
186
+ this.columns = cols;
187
+ this.rowCount = rows;
188
+ this.lib.cueloop_pty_resize(this.handle, cols, rows);
189
+ }
190
+
191
+ kill(): void {
192
+ if (this.closing) return;
193
+ this.closing = true;
194
+ this.lib.cueloop_pty_kill(this.handle);
195
+ this.lib.cueloop_pty_close(this.handle);
196
+ this.exitEvent.fire({ exitCode: 0 });
197
+ }
198
+
199
+ private async readLoop(): Promise<void> {
200
+ if (this.reading) return;
201
+ this.reading = true;
202
+ const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
203
+
204
+ while (!this.closing) {
205
+ const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
206
+
207
+ if (count > 0) {
208
+ // Stream mode buffers a multibyte char split across reads (box-drawing etc.).
209
+ const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
210
+
211
+ if (text) this.dataEvent.fire(text);
212
+ } else if (count === CHILD_EXITED || count < 0) {
213
+ // Both the drained-exit sentinel and a hard read error end the session:
214
+ // flush the decoder, reap for the code, close, and fire the exit once.
215
+ const tail = this.decoder.decode();
216
+
217
+ if (tail) this.dataEvent.fire(tail);
218
+ const exitCode = this.lib.cueloop_pty_get_exit_code(this.handle);
219
+
220
+ this.lib.cueloop_pty_close(this.handle);
221
+ this.closing = true;
222
+ this.exitEvent.fire({ exitCode });
223
+ } else {
224
+ await new Promise((resolve) => setTimeout(resolve, READ_IDLE_MS));
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ /** Spawn `file` with `args` on a fresh PTY. Throws when no shim ships (gate on ptyAvailable). */
231
+ export function spawn(file: string, args: string[], options: PtyForkOptions = {}): IPty {
232
+ const lib = library();
233
+
234
+ if (!lib) throw new Error("cueloop: no pty shim for this platform");
235
+
236
+ return new Pty(lib, file, args, options);
237
+ }