@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1043 -798
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +495 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -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/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +290 -0
- package/src/components/AppShell.zoom.test.tsx +135 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -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 +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -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-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -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/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -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/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -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 +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +153 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -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 -312
package/src/pty.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
/** Shortest read-loop sleep, used while the child is actively streaming - keeps output snappy. */
|
|
17
|
+
const READ_IDLE_MS = 8;
|
|
18
|
+
/** Longest read-loop sleep, reached after the child sits quiet - stops the loop burning a core at idle. */
|
|
19
|
+
const READ_IDLE_MAX_MS = 100;
|
|
20
|
+
const READ_BUFFER_BYTES = 4096;
|
|
21
|
+
/** cueloop_pty_read's sentinel: the child has exited and its output is drained. */
|
|
22
|
+
const CHILD_EXITED = -2;
|
|
23
|
+
|
|
24
|
+
export interface PtyForkOptions {
|
|
25
|
+
name?: string;
|
|
26
|
+
cols?: number;
|
|
27
|
+
rows?: number;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ExitEvent {
|
|
33
|
+
exitCode: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Disposable {
|
|
37
|
+
dispose(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The pseudo-terminal surface the embedded terminal consumes. */
|
|
41
|
+
export interface IPty {
|
|
42
|
+
readonly pid: number;
|
|
43
|
+
readonly cols: number;
|
|
44
|
+
readonly rows: number;
|
|
45
|
+
readonly onData: (listener: (data: string) => void) => Disposable;
|
|
46
|
+
readonly onExit: (listener: (event: ExitEvent) => void) => Disposable;
|
|
47
|
+
write(data: string): void;
|
|
48
|
+
resize(cols: number, rows: number): void;
|
|
49
|
+
kill(signal?: string): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PTY_SYMBOLS = {
|
|
53
|
+
cueloop_pty_spawn: {
|
|
54
|
+
args: [FFIType.ptr, FFIType.cstring, FFIType.ptr, FFIType.i32, FFIType.i32],
|
|
55
|
+
returns: FFIType.i32,
|
|
56
|
+
},
|
|
57
|
+
cueloop_pty_write: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
58
|
+
cueloop_pty_read: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
59
|
+
cueloop_pty_resize: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
60
|
+
cueloop_pty_kill: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
61
|
+
cueloop_pty_get_pid: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
62
|
+
cueloop_pty_get_exit_code: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
63
|
+
cueloop_pty_close: { args: [FFIType.i32], returns: FFIType.void },
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
type PtyLib = ReturnType<typeof dlopen<typeof PTY_SYMBOLS>>["symbols"];
|
|
67
|
+
|
|
68
|
+
/** The prebuilt pty shim for this platform, or null when none ships for it. */
|
|
69
|
+
function nativeLibraryPath(): string | null {
|
|
70
|
+
const suffix = process.platform === "darwin" ? "dylib" : "so";
|
|
71
|
+
const dir = `${process.platform}-${process.arch}`;
|
|
72
|
+
const path = join(import.meta.dir, "..", "native", dir, `libcuelooppty.${suffix}`);
|
|
73
|
+
|
|
74
|
+
return existsSync(path) ? path : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One shared load of the pty shim; null when no dylib ships for the platform. */
|
|
78
|
+
let ptyLib: PtyLib | null | undefined;
|
|
79
|
+
|
|
80
|
+
function library(): PtyLib | null {
|
|
81
|
+
if (ptyLib !== undefined) return ptyLib;
|
|
82
|
+
const path = nativeLibraryPath();
|
|
83
|
+
|
|
84
|
+
if (!path) return (ptyLib = null);
|
|
85
|
+
try {
|
|
86
|
+
ptyLib = dlopen(path, PTY_SYMBOLS).symbols;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
console.error(`cueloop: failed to load ${path}:`, error);
|
|
89
|
+
ptyLib = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ptyLib;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when this platform ships a prebuilt pty shim (embedding is possible). */
|
|
96
|
+
export function ptyAvailable(): boolean {
|
|
97
|
+
return library() !== null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class EventEmitter<T> {
|
|
101
|
+
private listeners: ((value: T) => void)[] = [];
|
|
102
|
+
readonly event = (listener: (value: T) => void): Disposable => {
|
|
103
|
+
this.listeners.push(listener);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
dispose: () => {
|
|
107
|
+
const index = this.listeners.indexOf(listener);
|
|
108
|
+
|
|
109
|
+
if (index !== -1) this.listeners.splice(index, 1);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
fire(value: T): void {
|
|
114
|
+
for (const listener of this.listeners) listener(value);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Pack tokens into the C shim's "tok\0tok\0...\0\0" (double-NUL-terminated) form. */
|
|
119
|
+
function packTokens(tokens: string[]): Buffer {
|
|
120
|
+
return Buffer.from(tokens.map((token) => `${token}\0`).join("") + "\0", "utf8");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
class Pty implements IPty {
|
|
124
|
+
private handle: number;
|
|
125
|
+
private processId: number;
|
|
126
|
+
private columns: number;
|
|
127
|
+
private rowCount: number;
|
|
128
|
+
private closing = false;
|
|
129
|
+
private reading = false;
|
|
130
|
+
private readonly decoder = new TextDecoder("utf-8");
|
|
131
|
+
private readonly dataEvent = new EventEmitter<string>();
|
|
132
|
+
private readonly exitEvent = new EventEmitter<ExitEvent>();
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
private readonly lib: PtyLib,
|
|
136
|
+
file: string,
|
|
137
|
+
args: string[],
|
|
138
|
+
options: PtyForkOptions,
|
|
139
|
+
) {
|
|
140
|
+
this.columns = options.cols ?? DEFAULT_COLS;
|
|
141
|
+
this.rowCount = options.rows ?? DEFAULT_ROWS;
|
|
142
|
+
const cwd = options.cwd ?? process.cwd();
|
|
143
|
+
const env = options.env
|
|
144
|
+
? Object.entries(options.env).map(([key, value]) => `${key}=${value}`)
|
|
145
|
+
: [];
|
|
146
|
+
|
|
147
|
+
const argvPacked = packTokens([file, ...args]);
|
|
148
|
+
const envPacked = packTokens(env);
|
|
149
|
+
|
|
150
|
+
this.handle = lib.cueloop_pty_spawn(
|
|
151
|
+
ptr(argvPacked),
|
|
152
|
+
Buffer.from(`${cwd}\0`, "utf8"),
|
|
153
|
+
ptr(envPacked),
|
|
154
|
+
this.columns,
|
|
155
|
+
this.rowCount,
|
|
156
|
+
);
|
|
157
|
+
if (this.handle < 0) throw new Error("PTY spawn failed");
|
|
158
|
+
this.processId = lib.cueloop_pty_get_pid(this.handle);
|
|
159
|
+
// Let the caller attach onData/onExit before the first bytes arrive.
|
|
160
|
+
queueMicrotask(() => void this.readLoop());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get pid(): number {
|
|
164
|
+
return this.processId;
|
|
165
|
+
}
|
|
166
|
+
get cols(): number {
|
|
167
|
+
return this.columns;
|
|
168
|
+
}
|
|
169
|
+
get rows(): number {
|
|
170
|
+
return this.rowCount;
|
|
171
|
+
}
|
|
172
|
+
get onData() {
|
|
173
|
+
return this.dataEvent.event;
|
|
174
|
+
}
|
|
175
|
+
get onExit() {
|
|
176
|
+
return this.exitEvent.event;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
write(data: string): void {
|
|
180
|
+
if (this.closing) return;
|
|
181
|
+
const buffer = Buffer.from(data, "utf8");
|
|
182
|
+
|
|
183
|
+
this.lib.cueloop_pty_write(this.handle, ptr(buffer), buffer.length);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
resize(cols: number, rows: number): void {
|
|
187
|
+
if (this.closing) return;
|
|
188
|
+
this.columns = cols;
|
|
189
|
+
this.rowCount = rows;
|
|
190
|
+
this.lib.cueloop_pty_resize(this.handle, cols, rows);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
kill(): void {
|
|
194
|
+
if (this.closing) return;
|
|
195
|
+
this.closing = true;
|
|
196
|
+
this.lib.cueloop_pty_kill(this.handle);
|
|
197
|
+
this.lib.cueloop_pty_close(this.handle);
|
|
198
|
+
this.exitEvent.fire({ exitCode: 0 });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async readLoop(): Promise<void> {
|
|
202
|
+
if (this.reading) return;
|
|
203
|
+
this.reading = true;
|
|
204
|
+
const buffer = Buffer.allocUnsafe(READ_BUFFER_BYTES);
|
|
205
|
+
// consecutive empty reads; the idle sleep backs off exponentially so a quiet agent prompt
|
|
206
|
+
// ramps from an 8ms hot poll up to a 100ms idle poll instead of spinning a core forever
|
|
207
|
+
let idlePolls = 0;
|
|
208
|
+
|
|
209
|
+
while (!this.closing) {
|
|
210
|
+
const count = this.lib.cueloop_pty_read(this.handle, ptr(buffer), buffer.length);
|
|
211
|
+
|
|
212
|
+
if (count > 0) {
|
|
213
|
+
idlePolls = 0;
|
|
214
|
+
// Stream mode buffers a multibyte char split across reads (box-drawing etc.).
|
|
215
|
+
const text = this.decoder.decode(buffer.subarray(0, count), { stream: true });
|
|
216
|
+
|
|
217
|
+
if (text) this.dataEvent.fire(text);
|
|
218
|
+
} else if (count === CHILD_EXITED || count < 0) {
|
|
219
|
+
// Both the drained-exit sentinel and a hard read error end the session:
|
|
220
|
+
// flush the decoder, reap for the code, close, and fire the exit once.
|
|
221
|
+
const tail = this.decoder.decode();
|
|
222
|
+
|
|
223
|
+
if (tail) this.dataEvent.fire(tail);
|
|
224
|
+
const exitCode = this.lib.cueloop_pty_get_exit_code(this.handle);
|
|
225
|
+
|
|
226
|
+
this.lib.cueloop_pty_close(this.handle);
|
|
227
|
+
this.closing = true;
|
|
228
|
+
this.exitEvent.fire({ exitCode });
|
|
229
|
+
} else {
|
|
230
|
+
const delay = Math.min(READ_IDLE_MAX_MS, READ_IDLE_MS * 2 ** Math.min(idlePolls, 5));
|
|
231
|
+
idlePolls += 1;
|
|
232
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Spawn `file` with `args` on a fresh PTY. Throws when no shim ships (gate on ptyAvailable). */
|
|
239
|
+
export function spawn(file: string, args: string[], options: PtyForkOptions = {}): IPty {
|
|
240
|
+
const lib = library();
|
|
241
|
+
|
|
242
|
+
if (!lib) throw new Error("cueloop: no pty shim for this platform");
|
|
243
|
+
|
|
244
|
+
return new Pty(lib, file, args, options);
|
|
245
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** The ready signal fires once, after a frame, and writes the ready file when the env names one. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createElement, useState } from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { READY_FILE_ENV, useReadySignal } from "./ready-signal";
|
|
10
|
+
import { settle } from "./test-support";
|
|
11
|
+
|
|
12
|
+
const homes: string[] = [];
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
|
|
16
|
+
delete process.env[READY_FILE_ENV];
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** Hands its readiness setter to the test, so a frame can be rendered before and after the flip. */
|
|
20
|
+
function ReadyProbe(props: {
|
|
21
|
+
initiallyReady: boolean;
|
|
22
|
+
onReady: () => void;
|
|
23
|
+
exposeSetter?: (setReady: (ready: boolean) => void) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const [ready, setReady] = useState(props.initiallyReady);
|
|
26
|
+
|
|
27
|
+
props.exposeSetter?.(setReady);
|
|
28
|
+
useReadySignal(ready, props.onReady);
|
|
29
|
+
|
|
30
|
+
return createElement("text", null, ready ? "ready" : "booting");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("useReadySignal", () => {
|
|
34
|
+
test("fires once on the first frame after ready turns true", async () => {
|
|
35
|
+
// Arrange - not ready yet
|
|
36
|
+
let fired = 0;
|
|
37
|
+
let setReady: ((ready: boolean) => void) | null = null;
|
|
38
|
+
const onReady = () => {
|
|
39
|
+
fired += 1;
|
|
40
|
+
};
|
|
41
|
+
const setup = await testRender(
|
|
42
|
+
createElement(ReadyProbe, {
|
|
43
|
+
initiallyReady: false,
|
|
44
|
+
onReady,
|
|
45
|
+
exposeSetter: (setter) => {
|
|
46
|
+
setReady = setter;
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
{ width: 20, height: 3 },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
await settle(setup);
|
|
53
|
+
expect(fired).toBe(0);
|
|
54
|
+
|
|
55
|
+
// Act - ready, then let the commit, the effect, and more frames than one signal needs run
|
|
56
|
+
setReady!(true);
|
|
57
|
+
await settle(setup);
|
|
58
|
+
await settle(setup);
|
|
59
|
+
|
|
60
|
+
// Assert - exactly one signal, and the screen shows the ready tree
|
|
61
|
+
expect(fired).toBe(1);
|
|
62
|
+
expect(setup.captureCharFrame()).toContain("ready");
|
|
63
|
+
setup.renderer.destroy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("writes the ready file through the env channel", async () => {
|
|
67
|
+
// Arrange
|
|
68
|
+
const home = mkdtempSync(join(tmpdir(), "cueloop-ready-"));
|
|
69
|
+
const readyFile = join(home, "ready");
|
|
70
|
+
|
|
71
|
+
homes.push(home);
|
|
72
|
+
process.env[READY_FILE_ENV] = readyFile;
|
|
73
|
+
const setup = await testRender(
|
|
74
|
+
createElement(ReadyProbe, { initiallyReady: true, onReady: () => {} }),
|
|
75
|
+
{ width: 20, height: 3 },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Act
|
|
79
|
+
await settle(setup);
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
expect(existsSync(readyFile)).toBe(true);
|
|
83
|
+
setup.renderer.destroy();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app ready signal: fired once, after the first frame that paints a
|
|
3
|
+
* usable screen (a session, the no-thread shell, or an error) has been
|
|
4
|
+
* flushed. React runs child effects before parent effects, so by the time the
|
|
5
|
+
* App-level effect arms the frame listener every keyboard handler of that
|
|
6
|
+
* first screen is already subscribed - a key sent after the signal cannot be
|
|
7
|
+
* dropped. Surfaces that mount later (a composer, a lazily loaded tab) still
|
|
8
|
+
* need their own screen predicate. Tests wait on this instead of guessing
|
|
9
|
+
* from output silence. Two channels: an
|
|
10
|
+
* `onReady` callback for in-process suites, and the file named by
|
|
11
|
+
* CUELOOP_READY_FILE for subprocess and PTY suites. Users never see either.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { writeFileSync } from "node:fs";
|
|
15
|
+
import { useEffect, useRef } from "react";
|
|
16
|
+
import { useRenderer } from "@opentui/react";
|
|
17
|
+
|
|
18
|
+
/** The env var naming the file the app writes once it is ready; unset in normal use. */
|
|
19
|
+
export const READY_FILE_ENV = "CUELOOP_READY_FILE";
|
|
20
|
+
|
|
21
|
+
/** Write the ready file when CUELOOP_READY_FILE names one; a no-op otherwise, so the signal costs nothing outside tests. */
|
|
22
|
+
function announceReady(): void {
|
|
23
|
+
const path = process.env[READY_FILE_ENV];
|
|
24
|
+
|
|
25
|
+
if (!path) return;
|
|
26
|
+
writeFileSync(path, "ready\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fire the ready signal once, on the first rendered frame after `ready`
|
|
31
|
+
* turns true. Call it after every keyboard hook of the component so their
|
|
32
|
+
* subscriptions are in place when the signal goes out.
|
|
33
|
+
*/
|
|
34
|
+
export function useReadySignal(ready: boolean, onReady?: () => void): void {
|
|
35
|
+
const renderer = useRenderer();
|
|
36
|
+
const fired = useRef(false);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!ready || fired.current) return;
|
|
40
|
+
const emit = (): void => {
|
|
41
|
+
fired.current = true;
|
|
42
|
+
announceReady();
|
|
43
|
+
onReady?.();
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
renderer?.once("frame", emit);
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
renderer?.off("frame", emit);
|
|
50
|
+
};
|
|
51
|
+
}, [ready, renderer, onReady]);
|
|
52
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -10,18 +10,39 @@ export interface RunClientOptions {
|
|
|
10
10
|
home?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
/** OSC background query budget: brief so a terminal that never answers falls back to dark. */
|
|
14
|
+
const THEME_QUERY_TIMEOUT_MS = 200;
|
|
15
|
+
|
|
16
|
+
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
17
|
+
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
18
|
+
// so the renderer's native selection is the drag driver
|
|
19
|
+
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
20
|
+
const appearance =
|
|
21
|
+
(await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
22
|
+
|
|
15
23
|
return new Promise<number>((resolve) => {
|
|
24
|
+
let exited = false;
|
|
25
|
+
const shutdown = (code: number): void => {
|
|
26
|
+
if (exited) return;
|
|
27
|
+
exited = true;
|
|
28
|
+
renderer.destroy();
|
|
29
|
+
resolve(code);
|
|
30
|
+
// one microtask between destroy and exit lets the renderer flush
|
|
31
|
+
// its terminal-restore sequences before the process dies
|
|
32
|
+
queueMicrotask(() => process.exit(code));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// a closed pane/terminal hangs up (or the parent kills us); exit instead of
|
|
36
|
+
// lingering as an orphan whose pty read loop keeps burning CPU
|
|
37
|
+
process.once("SIGHUP", () => shutdown(0));
|
|
38
|
+
process.once("SIGTERM", () => shutdown(0));
|
|
39
|
+
|
|
16
40
|
createRoot(renderer).render(
|
|
17
41
|
React.createElement(App, {
|
|
18
|
-
home:
|
|
19
|
-
sessionId:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
resolve(code);
|
|
23
|
-
process.exit(code);
|
|
24
|
-
},
|
|
42
|
+
home: options.home,
|
|
43
|
+
sessionId: options.sessionId,
|
|
44
|
+
appearance,
|
|
45
|
+
onExit: shutdown,
|
|
25
46
|
}),
|
|
26
47
|
);
|
|
27
48
|
});
|
package/src/serve.test.ts
CHANGED
|
@@ -10,14 +10,14 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { Client } from "ssh2";
|
|
12
12
|
import { DaemonServer } from "@cueloop/daemon";
|
|
13
|
-
import type {
|
|
13
|
+
import type { Thread } from "@cueloop/schema";
|
|
14
14
|
import { serveClient, type ServeHandle } from "./serve";
|
|
15
15
|
|
|
16
16
|
const PLAN = "# Rollout Plan\n\nShip the store move behind a flag.\n";
|
|
17
17
|
|
|
18
18
|
let home: string;
|
|
19
19
|
let daemon: DaemonServer;
|
|
20
|
-
let session:
|
|
20
|
+
let session: Thread;
|
|
21
21
|
let handle: ServeHandle;
|
|
22
22
|
|
|
23
23
|
beforeEach(async () => {
|
|
@@ -38,7 +38,11 @@ afterEach(async () => {
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
/** Connect, open a PTY shell, resolve with the bytes seen until `until` matches. */
|
|
41
|
-
function sshCapture(
|
|
41
|
+
function sshCapture(
|
|
42
|
+
port: number,
|
|
43
|
+
until: (frame: string) => boolean,
|
|
44
|
+
timeoutMs = 8000,
|
|
45
|
+
): Promise<string> {
|
|
42
46
|
return new Promise((resolve, reject) => {
|
|
43
47
|
const conn = new Client();
|
|
44
48
|
let out = "";
|
|
@@ -48,15 +52,17 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
48
52
|
done = true;
|
|
49
53
|
clearTimeout(timer);
|
|
50
54
|
conn.end();
|
|
51
|
-
|
|
55
|
+
if (err) reject(err);
|
|
56
|
+
else resolve(out);
|
|
52
57
|
};
|
|
53
58
|
const timer = setTimeout(() => finish(new Error(`timed out; captured:\n${out}`)), timeoutMs);
|
|
59
|
+
|
|
54
60
|
conn
|
|
55
61
|
.on("ready", () => {
|
|
56
62
|
conn.shell({ term: "xterm-256color", cols: 100, rows: 30 }, (err, stream) => {
|
|
57
63
|
if (err) return finish(err);
|
|
58
|
-
stream.on("data", (
|
|
59
|
-
out +=
|
|
64
|
+
stream.on("data", (chunk: Buffer) => {
|
|
65
|
+
out += chunk.toString("utf8");
|
|
60
66
|
if (until(out)) finish();
|
|
61
67
|
});
|
|
62
68
|
stream.on("close", () => finish(new Error(`stream closed early; captured:\n${out}`)));
|
|
@@ -70,6 +76,7 @@ function sshCapture(port: number, until: (s: string) => boolean, timeoutMs = 800
|
|
|
70
76
|
|
|
71
77
|
describe("cueloop serve", () => {
|
|
72
78
|
test("listens on localhost, persists a host key under CUELOOP_HOME/ssh", () => {
|
|
79
|
+
// Assert
|
|
73
80
|
expect(handle.host).toBe("127.0.0.1");
|
|
74
81
|
expect(handle.port).toBeGreaterThan(0);
|
|
75
82
|
expect(handle.fingerprints.length).toBe(1);
|
|
@@ -77,19 +84,24 @@ describe("cueloop serve", () => {
|
|
|
77
84
|
expect(existsSync(join(home, "ssh", "host_key"))).toBe(true);
|
|
78
85
|
});
|
|
79
86
|
|
|
80
|
-
test("an anonymous ssh client gets the
|
|
81
|
-
|
|
87
|
+
test("an anonymous ssh client gets the read-only TUI for the session", async () => {
|
|
88
|
+
// Act
|
|
89
|
+
const bytes = await sshCapture(handle.port, (frame) => frame.includes("Rollout Plan"));
|
|
90
|
+
|
|
91
|
+
// Assert - the viewer sees the session; read-only is enforced behaviorally
|
|
92
|
+
// (covered by the observer-primitives tests), not by a header badge
|
|
82
93
|
expect(bytes).toContain("cueloop");
|
|
83
94
|
expect(bytes).toContain("Rollout Plan");
|
|
84
|
-
// observer chrome, not the controller's mutating hint bar
|
|
85
|
-
expect(bytes).toContain("observer - read-only");
|
|
86
95
|
});
|
|
87
96
|
|
|
88
97
|
test("two observers can watch at once", async () => {
|
|
98
|
+
// Act
|
|
89
99
|
const [a, b] = await Promise.all([
|
|
90
|
-
sshCapture(handle.port, (
|
|
91
|
-
sshCapture(handle.port, (
|
|
100
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
101
|
+
sshCapture(handle.port, (frame) => frame.includes("Rollout Plan")),
|
|
92
102
|
]);
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
93
105
|
expect(a).toContain("Rollout Plan");
|
|
94
106
|
expect(b).toContain("Rollout Plan");
|
|
95
107
|
});
|
package/src/serve.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH-served TUI
|
|
2
|
+
* SSH-served TUI: `cueloop serve` lets teammates join a
|
|
3
3
|
* review session over plain ssh. Every SSH connection renders <App> in
|
|
4
4
|
* observer mode (readOnly) against the same local daemon; the one writable
|
|
5
5
|
* controller stays the local owner's own `cueloop` TUI.
|
|
@@ -22,6 +22,9 @@ import { createServer } from "@opentui/ssh";
|
|
|
22
22
|
import { cueloopHome } from "@cueloop/daemon";
|
|
23
23
|
import { App } from "./App";
|
|
24
24
|
|
|
25
|
+
/** OSC background query budget: brief so a terminal that never answers falls back to dark. */
|
|
26
|
+
const THEME_QUERY_TIMEOUT_MS = 200;
|
|
27
|
+
|
|
25
28
|
export interface ServeOptions {
|
|
26
29
|
/** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
|
|
27
30
|
port?: number;
|
|
@@ -33,7 +36,7 @@ export interface ServeOptions {
|
|
|
33
36
|
home?: string;
|
|
34
37
|
/** Silence the @opentui/ssh startup banner (tests). Default true. */
|
|
35
38
|
banner?: boolean;
|
|
36
|
-
onError?: (
|
|
39
|
+
onError?: (cause: unknown) => void;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
export interface ServeHandle {
|
|
@@ -44,25 +47,32 @@ export interface ServeHandle {
|
|
|
44
47
|
close(): Promise<void>;
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
export async function serveClient(
|
|
48
|
-
const home =
|
|
50
|
+
export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
|
|
51
|
+
const home = options.home ?? cueloopHome();
|
|
49
52
|
const sshDir = join(home, "ssh");
|
|
53
|
+
|
|
50
54
|
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
|
|
51
55
|
|
|
52
56
|
const server = createServer({
|
|
53
57
|
// password-less by design; see the trust model in the module comment
|
|
54
58
|
auth: "open",
|
|
55
59
|
hostKey: { path: join(sshDir, "host_key") },
|
|
56
|
-
startupBanner:
|
|
60
|
+
startupBanner: options.banner ?? true,
|
|
57
61
|
idleTimeout: "2h",
|
|
58
|
-
onError:
|
|
59
|
-
}).serve((session) => {
|
|
62
|
+
onError: options.onError,
|
|
63
|
+
}).serve(async (session) => {
|
|
64
|
+
// the observer's terminal reports its own background, so a light-terminal
|
|
65
|
+
// watcher gets the readable variant too
|
|
66
|
+
const appearance =
|
|
67
|
+
(await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
60
68
|
const root = createRoot(session.renderer);
|
|
69
|
+
|
|
61
70
|
root.render(
|
|
62
71
|
React.createElement(App, {
|
|
63
72
|
home,
|
|
64
|
-
sessionId:
|
|
73
|
+
sessionId: options.sessionId,
|
|
65
74
|
readOnly: true,
|
|
75
|
+
appearance,
|
|
66
76
|
// q disconnects only this observer, never the server
|
|
67
77
|
onExit: () => session.end(),
|
|
68
78
|
}),
|
|
@@ -70,6 +80,7 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
|
|
|
70
80
|
session.onClose(() => root.unmount());
|
|
71
81
|
});
|
|
72
82
|
|
|
73
|
-
const info = await server.listen(
|
|
83
|
+
const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
|
|
84
|
+
|
|
74
85
|
return { ...info, close: () => server.close() };
|
|
75
86
|
}
|