@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.
- package/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- 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
|
+
}
|
package/src/pty.test.ts
ADDED
|
@@ -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
|
+
}
|