@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.51
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 +36 -31
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +672 -0
- package/src/App.prototype.test.tsx +155 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +218 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +289 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +241 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +127 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +108 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +456 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +358 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +317 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -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 +49 -0
- package/src/components/agent-launcher.tsx +147 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +51 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -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 +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +181 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +82 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +493 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +430 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/kitty-image.test.ts +20 -0
- package/src/kitty-image.ts +75 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +243 -0
- package/src/pty.test.ts +78 -0
- package/src/pty.ts +222 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +914 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +38 -17
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +399 -0
- package/src/view-plan.ts +495 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
highlight(selector: string | null): Promise<void>;
|
|
34
|
+
/** Scroll the page by a pixel delta; returns whether the scroll position moved. */
|
|
35
|
+
scrollBy(deltaY: number): Promise<boolean>;
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LaunchOptions {
|
|
40
|
+
filePath: string;
|
|
41
|
+
viewport: PrototypeViewport;
|
|
42
|
+
/** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
|
|
43
|
+
executablePath?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
|
|
47
|
+
export function imageCellToCss(
|
|
48
|
+
event: { x: number; y: number },
|
|
49
|
+
image: { x: number; y: number; width: number; height: number },
|
|
50
|
+
viewport: PrototypeViewport,
|
|
51
|
+
): { x: number; y: number } | null {
|
|
52
|
+
const columnInImage = event.x - image.x;
|
|
53
|
+
const rowInImage = event.y - image.y;
|
|
54
|
+
if (
|
|
55
|
+
columnInImage < 0 ||
|
|
56
|
+
rowInImage < 0 ||
|
|
57
|
+
columnInImage >= image.width ||
|
|
58
|
+
rowInImage >= image.height
|
|
59
|
+
) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
x: ((columnInImage + 0.5) / image.width) * viewport.width,
|
|
64
|
+
y: ((rowInImage + 0.5) / image.height) * viewport.height,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
|
|
69
|
+
export function cssBoxToCell(
|
|
70
|
+
box: ElementBox,
|
|
71
|
+
image: { x: number; y: number; width: number; height: number },
|
|
72
|
+
viewport: PrototypeViewport,
|
|
73
|
+
): { column: number; row: number; columns: number; rows: number } {
|
|
74
|
+
const scaleColumn = image.width / viewport.width;
|
|
75
|
+
const scaleRow = image.height / viewport.height;
|
|
76
|
+
return {
|
|
77
|
+
column: image.x + Math.floor(box.x * scaleColumn),
|
|
78
|
+
row: image.y + Math.floor(box.y * scaleRow),
|
|
79
|
+
columns: Math.max(1, Math.round(box.width * scaleColumn)),
|
|
80
|
+
rows: Math.max(1, Math.round(box.height * scaleRow)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
|
|
85
|
+
|
|
86
|
+
let rendererFactory: PrototypeRendererFactory | null = null;
|
|
87
|
+
|
|
88
|
+
/** Override the renderer factory so tests can inject a browserless fake. */
|
|
89
|
+
export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
|
|
90
|
+
rendererFactory = factory;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The active factory - the injected fake in tests, else real headless Chromium. */
|
|
94
|
+
export function prototypeRendererFactory(): PrototypeRendererFactory {
|
|
95
|
+
return rendererFactory ?? launchPrototypeRenderer;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
|
|
99
|
+
const puppeteer = (await import("puppeteer-core")).default;
|
|
100
|
+
const { pathToFileURL } = await import("node:url");
|
|
101
|
+
const browser = await puppeteer.launch({
|
|
102
|
+
executablePath: options.executablePath ?? chromeExecutable(),
|
|
103
|
+
headless: true,
|
|
104
|
+
args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
105
|
+
defaultViewport: {
|
|
106
|
+
width: options.viewport.width,
|
|
107
|
+
height: options.viewport.height,
|
|
108
|
+
deviceScaleFactor: 2,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
// close the browser if page setup fails, so a launch error never leaks Chromium
|
|
112
|
+
const page = await browser.newPage().catch(async (error) => {
|
|
113
|
+
await browser.close();
|
|
114
|
+
throw error;
|
|
115
|
+
});
|
|
116
|
+
await page
|
|
117
|
+
.goto(pathToFileURL(options.filePath).href, { waitUntil: "networkidle0" })
|
|
118
|
+
.catch(async (error) => {
|
|
119
|
+
await browser.close();
|
|
120
|
+
throw error;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
viewport: options.viewport,
|
|
125
|
+
async screenshot() {
|
|
126
|
+
const buffer = await page.screenshot({ type: "png", encoding: "binary" });
|
|
127
|
+
return new Uint8Array(buffer as Buffer);
|
|
128
|
+
},
|
|
129
|
+
async elementAt(cssX, cssY) {
|
|
130
|
+
return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
|
|
131
|
+
},
|
|
132
|
+
async highlight(selector) {
|
|
133
|
+
await page.evaluate(highlightScript, selector);
|
|
134
|
+
},
|
|
135
|
+
async scrollBy(deltaY) {
|
|
136
|
+
return (await page.evaluate(scrollByScript, deltaY)) as boolean;
|
|
137
|
+
},
|
|
138
|
+
async close() {
|
|
139
|
+
await browser.close();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
|
|
145
|
+
function elementAtScript(x: number, y: number): unknown {
|
|
146
|
+
const SEMANTIC = new Set([
|
|
147
|
+
"SECTION",
|
|
148
|
+
"ARTICLE",
|
|
149
|
+
"LI",
|
|
150
|
+
"NAV",
|
|
151
|
+
"HEADER",
|
|
152
|
+
"FOOTER",
|
|
153
|
+
"ASIDE",
|
|
154
|
+
"FORM",
|
|
155
|
+
]);
|
|
156
|
+
const isNamed = (element: Element): boolean =>
|
|
157
|
+
element.classList.length > 0 || SEMANTIC.has(element.tagName);
|
|
158
|
+
// a card wraps several children: climb to the nearest named container holding
|
|
159
|
+
// more than one child, else the nearest named ancestor, else the clicked leaf
|
|
160
|
+
const componentRoot = (start: Element): Element => {
|
|
161
|
+
let namedFallback: Element | null = null;
|
|
162
|
+
let current: Element | null = start;
|
|
163
|
+
while (current && current !== document.body) {
|
|
164
|
+
if (isNamed(current)) {
|
|
165
|
+
if (current.childElementCount > 1) return current;
|
|
166
|
+
namedFallback = namedFallback ?? current;
|
|
167
|
+
}
|
|
168
|
+
current = current.parentElement;
|
|
169
|
+
}
|
|
170
|
+
return namedFallback ?? start;
|
|
171
|
+
};
|
|
172
|
+
const selectorFor = (start: Element): string => {
|
|
173
|
+
const parts: string[] = [];
|
|
174
|
+
let current: Element | null = start;
|
|
175
|
+
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
176
|
+
let part = current.tagName.toLowerCase();
|
|
177
|
+
if (current.id) {
|
|
178
|
+
parts.unshift(part + "#" + CSS.escape(current.id));
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
const parent: Element | null = current.parentElement;
|
|
182
|
+
if (parent) {
|
|
183
|
+
const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
|
|
184
|
+
if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
|
|
185
|
+
}
|
|
186
|
+
parts.unshift(part);
|
|
187
|
+
current = current.parentElement;
|
|
188
|
+
}
|
|
189
|
+
return parts.join(" > ");
|
|
190
|
+
};
|
|
191
|
+
const hit = document.elementFromPoint(x, y);
|
|
192
|
+
if (!(hit instanceof Element)) return null;
|
|
193
|
+
const node = componentRoot(hit);
|
|
194
|
+
const selector = selectorFor(node);
|
|
195
|
+
if (!selector) return null;
|
|
196
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
197
|
+
const tag = node.tagName.toLowerCase();
|
|
198
|
+
const quote = text
|
|
199
|
+
? text.slice(0, 80)
|
|
200
|
+
: node.id
|
|
201
|
+
? tag + "#" + node.id
|
|
202
|
+
: node.classList.length
|
|
203
|
+
? tag + "." + node.classList[0]
|
|
204
|
+
: tag;
|
|
205
|
+
const rect = node.getBoundingClientRect();
|
|
206
|
+
return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function scrollByScript(deltaY: number): boolean {
|
|
210
|
+
const before = window.scrollY;
|
|
211
|
+
window.scrollBy(0, deltaY);
|
|
212
|
+
return window.scrollY !== before;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function highlightScript(selector: string | null): void {
|
|
216
|
+
const ATTRIBUTE = "data-cueloop-selected";
|
|
217
|
+
const STYLE_ID = "cueloop-highlight-style";
|
|
218
|
+
document
|
|
219
|
+
.querySelectorAll("[" + ATTRIBUTE + "]")
|
|
220
|
+
.forEach((node) => node.removeAttribute(ATTRIBUTE));
|
|
221
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
222
|
+
const style = document.createElement("style");
|
|
223
|
+
style.id = STYLE_ID;
|
|
224
|
+
style.textContent =
|
|
225
|
+
"[" + ATTRIBUTE + "]{outline:2px solid #f38ba8 !important;outline-offset:2px;}";
|
|
226
|
+
document.head.appendChild(style);
|
|
227
|
+
}
|
|
228
|
+
if (selector) {
|
|
229
|
+
const node = document.querySelector(selector);
|
|
230
|
+
if (node) node.setAttribute(ATTRIBUTE, "");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
|
|
235
|
+
function chromeExecutable(): string {
|
|
236
|
+
if (process.platform === "darwin") {
|
|
237
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
238
|
+
}
|
|
239
|
+
if (process.platform === "win32") {
|
|
240
|
+
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
241
|
+
}
|
|
242
|
+
return "/usr/bin/google-chrome";
|
|
243
|
+
}
|
package/src/pty.test.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
pty.onData((chunk) => {
|
|
15
|
+
output += chunk;
|
|
16
|
+
});
|
|
17
|
+
pty.onExit(({ exitCode }) => {
|
|
18
|
+
clearTimeout(timeout);
|
|
19
|
+
resolve({ output, exitCode });
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("pty", () => {
|
|
25
|
+
ptyTest("streams a child's output and reports its exit code", async () => {
|
|
26
|
+
// Act
|
|
27
|
+
const { output, exitCode } = await runOnPty("sh", ["-c", "printf 'PTY-OK'; exit 7"]);
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
30
|
+
expect(output).toContain("PTY-OK");
|
|
31
|
+
expect(exitCode).toBe(7);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
ptyTest("forwards written input to the child", async () => {
|
|
35
|
+
// Arrange - `cat` echoes stdin back through the tty until it closes
|
|
36
|
+
const pty = spawn("cat", [], { name: "xterm-256color", cols: 80, rows: 24 });
|
|
37
|
+
let output = "";
|
|
38
|
+
pty.onData((chunk) => {
|
|
39
|
+
output += chunk;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Act
|
|
43
|
+
pty.write("ping\n");
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
45
|
+
pty.kill();
|
|
46
|
+
|
|
47
|
+
// Assert - the tty echoes the written line back
|
|
48
|
+
expect(output).toContain("ping");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
ptyTest("passes env through to the child", async () => {
|
|
52
|
+
// Act
|
|
53
|
+
const { output } = await runOnPty("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""]);
|
|
54
|
+
|
|
55
|
+
// Assert - a child with no inherited marker prints nothing for it
|
|
56
|
+
expect(output).not.toContain("marker-value");
|
|
57
|
+
|
|
58
|
+
// Act - now inject the marker via env
|
|
59
|
+
const withEnv = await new Promise<string>((resolve, reject) => {
|
|
60
|
+
const pty = spawn("sh", ["-c", "printf '%s' \"$CUELOOP_PTY_MARKER\""], {
|
|
61
|
+
name: "xterm-256color",
|
|
62
|
+
env: { ...process.env, CUELOOP_PTY_MARKER: "marker-value" },
|
|
63
|
+
});
|
|
64
|
+
let collected = "";
|
|
65
|
+
const timeout = setTimeout(() => reject(new Error("no exit")), 5000);
|
|
66
|
+
pty.onData((chunk) => {
|
|
67
|
+
collected += chunk;
|
|
68
|
+
});
|
|
69
|
+
pty.onExit(() => {
|
|
70
|
+
clearTimeout(timeout);
|
|
71
|
+
resolve(collected);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(withEnv).toContain("marker-value");
|
|
77
|
+
});
|
|
78
|
+
});
|
package/src/pty.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
return existsSync(path) ? path : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One shared load of the pty shim; null when no dylib ships for the platform. */
|
|
75
|
+
let ptyLib: PtyLib | null | undefined;
|
|
76
|
+
function library(): PtyLib | null {
|
|
77
|
+
if (ptyLib !== undefined) return ptyLib;
|
|
78
|
+
const path = nativeLibraryPath();
|
|
79
|
+
if (!path) return (ptyLib = null);
|
|
80
|
+
try {
|
|
81
|
+
ptyLib = dlopen(path, PTY_SYMBOLS).symbols;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(`cueloop: failed to load ${path}:`, error);
|
|
84
|
+
ptyLib = null;
|
|
85
|
+
}
|
|
86
|
+
return ptyLib;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when this platform ships a prebuilt pty shim (embedding is possible). */
|
|
90
|
+
export function ptyAvailable(): boolean {
|
|
91
|
+
return library() !== null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class EventEmitter<T> {
|
|
95
|
+
private listeners: ((value: T) => void)[] = [];
|
|
96
|
+
readonly event = (listener: (value: T) => void): Disposable => {
|
|
97
|
+
this.listeners.push(listener);
|
|
98
|
+
return {
|
|
99
|
+
dispose: () => {
|
|
100
|
+
const index = this.listeners.indexOf(listener);
|
|
101
|
+
if (index !== -1) this.listeners.splice(index, 1);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
fire(value: T): void {
|
|
106
|
+
for (const listener of this.listeners) listener(value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Pack tokens into the C shim's "tok\0tok\0...\0\0" (double-NUL-terminated) form. */
|
|
111
|
+
function packTokens(tokens: string[]): Buffer {
|
|
112
|
+
return Buffer.from(tokens.map((token) => `${token}\0`).join("") + "\0", "utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class Pty implements IPty {
|
|
116
|
+
private handle: number;
|
|
117
|
+
private processId: number;
|
|
118
|
+
private columns: number;
|
|
119
|
+
private rowCount: number;
|
|
120
|
+
private closing = false;
|
|
121
|
+
private reading = false;
|
|
122
|
+
private readonly decoder = new TextDecoder("utf-8");
|
|
123
|
+
private readonly dataEvent = new EventEmitter<string>();
|
|
124
|
+
private readonly exitEvent = new EventEmitter<ExitEvent>();
|
|
125
|
+
|
|
126
|
+
constructor(
|
|
127
|
+
private readonly lib: PtyLib,
|
|
128
|
+
file: string,
|
|
129
|
+
args: string[],
|
|
130
|
+
options: PtyForkOptions,
|
|
131
|
+
) {
|
|
132
|
+
this.columns = options.cols ?? DEFAULT_COLS;
|
|
133
|
+
this.rowCount = options.rows ?? DEFAULT_ROWS;
|
|
134
|
+
const cwd = options.cwd ?? process.cwd();
|
|
135
|
+
const env = options.env
|
|
136
|
+
? Object.entries(options.env).map(([key, value]) => `${key}=${value}`)
|
|
137
|
+
: [];
|
|
138
|
+
|
|
139
|
+
const argvPacked = packTokens([file, ...args]);
|
|
140
|
+
const envPacked = packTokens(env);
|
|
141
|
+
this.handle = lib.cueloop_pty_spawn(
|
|
142
|
+
ptr(argvPacked),
|
|
143
|
+
Buffer.from(`${cwd}\0`, "utf8"),
|
|
144
|
+
ptr(envPacked),
|
|
145
|
+
this.columns,
|
|
146
|
+
this.rowCount,
|
|
147
|
+
);
|
|
148
|
+
if (this.handle < 0) throw new Error("PTY spawn failed");
|
|
149
|
+
this.processId = lib.cueloop_pty_get_pid(this.handle);
|
|
150
|
+
// Let the caller attach onData/onExit before the first bytes arrive.
|
|
151
|
+
queueMicrotask(() => void this.readLoop());
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
get pid(): number {
|
|
155
|
+
return this.processId;
|
|
156
|
+
}
|
|
157
|
+
get cols(): number {
|
|
158
|
+
return this.columns;
|
|
159
|
+
}
|
|
160
|
+
get rows(): number {
|
|
161
|
+
return this.rowCount;
|
|
162
|
+
}
|
|
163
|
+
get onData() {
|
|
164
|
+
return this.dataEvent.event;
|
|
165
|
+
}
|
|
166
|
+
get onExit() {
|
|
167
|
+
return this.exitEvent.event;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
write(data: string): void {
|
|
171
|
+
if (this.closing) return;
|
|
172
|
+
const buffer = Buffer.from(data, "utf8");
|
|
173
|
+
this.lib.cueloop_pty_write(this.handle, ptr(buffer), buffer.length);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
resize(cols: number, rows: number): void {
|
|
177
|
+
if (this.closing) return;
|
|
178
|
+
this.columns = cols;
|
|
179
|
+
this.rowCount = rows;
|
|
180
|
+
this.lib.cueloop_pty_resize(this.handle, cols, rows);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
kill(): void {
|
|
184
|
+
if (this.closing) return;
|
|
185
|
+
this.closing = true;
|
|
186
|
+
this.lib.cueloop_pty_kill(this.handle);
|
|
187
|
+
this.lib.cueloop_pty_close(this.handle);
|
|
188
|
+
this.exitEvent.fire({ exitCode: 0 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async readLoop(): Promise<void> {
|
|
192
|
+
if (this.reading) return;
|
|
193
|
+
this.reading = true;
|
|
194
|
+
const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
|
|
195
|
+
while (!this.closing) {
|
|
196
|
+
const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
|
|
197
|
+
if (count > 0) {
|
|
198
|
+
// Stream mode buffers a multibyte char split across reads (box-drawing etc.).
|
|
199
|
+
const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
|
|
200
|
+
if (text) this.dataEvent.fire(text);
|
|
201
|
+
} else if (count === CHILD_EXITED || count < 0) {
|
|
202
|
+
// Both the drained-exit sentinel and a hard read error end the session:
|
|
203
|
+
// flush the decoder, reap for the code, close, and fire the exit once.
|
|
204
|
+
const tail = this.decoder.decode();
|
|
205
|
+
if (tail) this.dataEvent.fire(tail);
|
|
206
|
+
const exitCode = this.lib.cueloop_pty_get_exit_code(this.handle);
|
|
207
|
+
this.lib.cueloop_pty_close(this.handle);
|
|
208
|
+
this.closing = true;
|
|
209
|
+
this.exitEvent.fire({ exitCode });
|
|
210
|
+
} else {
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, READ_IDLE_MS));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Spawn `file` with `args` on a fresh PTY. Throws when no shim ships (gate on ptyAvailable). */
|
|
218
|
+
export function spawn(file: string, args: string[], options: PtyForkOptions = {}): IPty {
|
|
219
|
+
const lib = library();
|
|
220
|
+
if (!lib) throw new Error("cueloop: no pty shim for this platform");
|
|
221
|
+
return new Pty(lib, file, args, options);
|
|
222
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_DEFAULT_WIDTH,
|
|
4
|
+
REVIEW_MAX_WIDTH,
|
|
5
|
+
REVIEW_MIN_WIDTH,
|
|
6
|
+
REVIEW_PLAN_MIN_WIDTH,
|
|
7
|
+
REVIEW_RESIZE_STEP,
|
|
8
|
+
clampWidth,
|
|
9
|
+
cycleReviewPanelMode,
|
|
10
|
+
maxWidthForTerminal,
|
|
11
|
+
resolveReviewWidth,
|
|
12
|
+
reviewRowsToDivider,
|
|
13
|
+
toggleReviewPanelMode,
|
|
14
|
+
widthFromMouseColumn,
|
|
15
|
+
} from "./review-panel";
|
|
16
|
+
|
|
17
|
+
describe("clampWidth", () => {
|
|
18
|
+
test("keeps a width inside the bounds untouched", () => {
|
|
19
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("clamps below the minimum and above the maximum", () => {
|
|
23
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - 10)).toBe(REVIEW_MIN_WIDTH);
|
|
24
|
+
expect(clampWidth(REVIEW_MAX_WIDTH + 10)).toBe(REVIEW_MAX_WIDTH);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rounds a fractional column to a whole one", () => {
|
|
28
|
+
expect(clampWidth(30.7)).toBe(31);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("honors explicit bounds", () => {
|
|
32
|
+
expect(clampWidth(100, 10, 20)).toBe(20);
|
|
33
|
+
expect(clampWidth(5, 10, 20)).toBe(10);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("cycleReviewPanelMode", () => {
|
|
38
|
+
test("cycles expanded -> compact -> hidden -> expanded", () => {
|
|
39
|
+
expect(cycleReviewPanelMode("expanded")).toBe("compact");
|
|
40
|
+
expect(cycleReviewPanelMode("compact")).toBe("hidden");
|
|
41
|
+
expect(cycleReviewPanelMode("hidden")).toBe("expanded");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("toggleReviewPanelMode", () => {
|
|
46
|
+
test("the chevron toggles only between expanded and compact, never hidden", () => {
|
|
47
|
+
expect(toggleReviewPanelMode("expanded")).toBe("compact");
|
|
48
|
+
expect(toggleReviewPanelMode("compact")).toBe("expanded");
|
|
49
|
+
// a hidden panel has no chevron; a stray toggle still opens it
|
|
50
|
+
expect(toggleReviewPanelMode("hidden")).toBe("expanded");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("widthFromMouseColumn", () => {
|
|
55
|
+
test("measures the rail from the mouse column to the right edge", () => {
|
|
56
|
+
// divider dragged to column 80 of a 120-wide terminal -> a 40-col rail
|
|
57
|
+
expect(widthFromMouseColumn(80, 120)).toBe(40);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("clamps the dragged width to the bounds", () => {
|
|
61
|
+
expect(widthFromMouseColumn(0, 120)).toBe(REVIEW_MAX_WIDTH);
|
|
62
|
+
expect(widthFromMouseColumn(119, 120)).toBe(REVIEW_MIN_WIDTH);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("cannot drag the rail wide enough to starve the plan on a narrow terminal", () => {
|
|
66
|
+
// dragging to the far left edge of a 60-col terminal still leaves the plan its minimum
|
|
67
|
+
expect(widthFromMouseColumn(0, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the resize step widens and narrows within the clamp", () => {
|
|
71
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(
|
|
72
|
+
REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP,
|
|
73
|
+
);
|
|
74
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("maxWidthForTerminal", () => {
|
|
79
|
+
test("allows the full rail on a wide terminal", () => {
|
|
80
|
+
expect(maxWidthForTerminal(120)).toBe(REVIEW_MAX_WIDTH);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("reserves the plan its minimum on a narrow terminal", () => {
|
|
84
|
+
// 60-col terminal: 30 for the plan and 1 for the divider leaves 29 for the rail
|
|
85
|
+
expect(maxWidthForTerminal(60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("never drops below the rail minimum, however narrow the terminal", () => {
|
|
89
|
+
expect(maxWidthForTerminal(40)).toBe(REVIEW_MIN_WIDTH);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("resolveReviewWidth", () => {
|
|
94
|
+
test("leaves a width that already fits untouched", () => {
|
|
95
|
+
expect(resolveReviewWidth(REVIEW_DEFAULT_WIDTH, 120)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("shrinks a wide persisted width so the plan keeps its room", () => {
|
|
99
|
+
// a saved 50-col rail on a 60-col terminal would leave the plan 9 cols; clamp it back
|
|
100
|
+
expect(resolveReviewWidth(REVIEW_MAX_WIDTH, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("reviewRowsToDivider", () => {
|
|
105
|
+
test("builds one vertical glyph per row", () => {
|
|
106
|
+
expect(reviewRowsToDivider(3)).toBe("│\n│\n│");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("never collapses to an empty column", () => {
|
|
110
|
+
expect(reviewRowsToDivider(0)).toBe("│");
|
|
111
|
+
expect(reviewRowsToDivider(-4)).toBe("│");
|
|
112
|
+
});
|
|
113
|
+
});
|