@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.91
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 +42 -34
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +77 -51
- package/src/App.diff.test.tsx +259 -45
- package/src/App.export.test.tsx +58 -41
- package/src/App.focus.test.ts +89 -0
- package/src/App.identity.test.tsx +78 -0
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +330 -161
- package/src/App.prototype-markdown.test.tsx +128 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +129 -67
- package/src/App.regression.test.tsx +94 -0
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +264 -0
- package/src/App.test.tsx +402 -126
- package/src/App.toast.test.tsx +98 -0
- package/src/App.tsx +1406 -1060
- package/src/App.visual.test.tsx +57 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/App.workbench.test.tsx +787 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +62 -0
- package/src/ansi-screen.test.ts +28 -0
- package/src/ansi-screen.ts +72 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +577 -0
- package/src/app-view-model.ts +168 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/client-extension-registry.test.tsx +61 -0
- package/src/client-extension-registry.ts +159 -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 +134 -0
- package/src/components/AnnotationCards.stories.tsx +86 -0
- package/src/components/AnnotationCards.tsx +341 -0
- package/src/components/AnsiScreen.stories.tsx +12 -0
- package/src/components/AnsiScreen.tsx +28 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.brand.test.tsx +58 -0
- package/src/components/AppShell.extensions.test.tsx +191 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +362 -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 +44 -0
- package/src/components/ChangesColumn.tsx +199 -0
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +37 -0
- package/src/components/CompletionOverlay.tsx +77 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +150 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +72 -0
- package/src/components/DiffContentView.scroll.test.tsx +149 -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 +906 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +186 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +426 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/ExtensionZone.stories.tsx +28 -0
- package/src/components/ExtensionZone.tsx +145 -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/GridTabContent.stories.tsx +59 -0
- package/src/components/GridTabContent.tsx +182 -0
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -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 +98 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +75 -0
- package/src/components/ProjectTreeView.tsx +125 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +99 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +45 -0
- package/src/components/RootOverlay.tsx +48 -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 +186 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/SlashComposer.stories.tsx +37 -0
- package/src/components/SlashComposer.tsx +87 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.test.tsx +47 -0
- package/src/components/ThreadFooter.tsx +76 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +255 -0
- package/src/components/ThreadView.selection.test.tsx +951 -0
- package/src/components/ThreadView.slash.test.tsx +146 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +525 -0
- package/src/components/Toast.stories.tsx +19 -0
- package/src/components/Toast.tsx +52 -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 +150 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +49 -0
- package/src/components/WelcomePlayground.tsx +139 -0
- package/src/components/WorkspacePanels.stories.tsx +36 -0
- package/src/components/WorkspacePanels.tsx +116 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +2264 -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/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -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 +59 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +63 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +71 -0
- package/src/components/primitives/DialogActions.stories.tsx +20 -0
- package/src/components/primitives/DialogActions.tsx +39 -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/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -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 +100 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +17 -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 +58 -0
- package/src/components/quick-actions-editor.test.tsx +117 -0
- package/src/components/quick-actions-editor.tsx +226 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories-app.tsx +193 -0
- package/src/components/stories.test.tsx +74 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +108 -0
- package/src/components/story.ts +159 -0
- package/src/components/syntax-highlight.ts +133 -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/thread-tree.test.ts +134 -0
- package/src/components/thread-tree.ts +109 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/components/truncate-title.test.ts +31 -0
- package/src/components/truncate-title.ts +15 -0
- package/src/config-identity.test.ts +78 -0
- package/src/config.test.ts +579 -34
- package/src/config.ts +533 -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 +238 -0
- package/src/diff-intraline.ts +236 -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/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +29 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +724 -0
- package/src/intent-dispatch.ts +690 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +459 -0
- package/src/keymap.test.ts +456 -115
- package/src/keymap.ts +315 -81
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/launch-layout.test.ts +78 -0
- package/src/launch-layout.ts +62 -0
- package/src/mark-runs.ts +121 -0
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/pasted-image.test.ts +67 -0
- package/src/pasted-image.ts +60 -0
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/prototype-browser.test.ts +81 -0
- package/src/prototype-browser.ts +329 -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 +85 -12
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +52 -10
- package/src/share.ts +237 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +75 -0
- package/src/slash-palette.test.ts +103 -0
- package/src/slash-palette.ts +129 -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 +369 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +219 -0
- package/src/theme.ts +88 -19
- package/src/thread-chords.test.ts +79 -0
- package/src/thread-chords.ts +163 -0
- package/src/thread-code-syntax.ts +45 -0
- package/src/thread-controller.curate.test.ts +476 -0
- package/src/thread-controller.derivation.test.ts +62 -0
- package/src/thread-controller.live-diff.test.ts +322 -0
- package/src/thread-controller.optimistic.test.ts +69 -0
- package/src/thread-controller.share.test.ts +561 -0
- package/src/thread-controller.switch-cache.test.ts +176 -0
- package/src/thread-controller.tree.test.ts +261 -0
- package/src/thread-controller.ts +2050 -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 +153 -0
- package/src/use-annotation-surface.tsx +975 -0
- package/src/use-changes-workbench.ts +190 -0
- package/src/use-frame-measure.ts +41 -0
- package/src/use-remember-layout.ts +31 -0
- package/src/use-repo-changes.ts +87 -0
- package/src/use-settings-dialog.tsx +364 -0
- package/src/use-terminal-virtualizer.ts +259 -0
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +279 -9
- package/src/view-diff.ts +460 -24
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +545 -0
- package/src/view-plan.ts +1019 -0
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/workbench-snapshot.test.ts +99 -0
- package/src/workbench-snapshot.ts +59 -0
- package/src/session-controller.ts +0 -398
- 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 -364
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,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
|
@@ -4,27 +4,100 @@ import React from "react";
|
|
|
4
4
|
import { createCliRenderer } from "@opentui/core";
|
|
5
5
|
import { createRoot } from "@opentui/react";
|
|
6
6
|
import { App } from "./App";
|
|
7
|
+
import { closePrototypeBrowser } from "./prototype-browser";
|
|
8
|
+
import type { Appearance } from "./theme-presets";
|
|
9
|
+
import { loadConfig } from "./config";
|
|
10
|
+
import { perfMark } from "./perf/perf-timings";
|
|
11
|
+
import { reportPerfMarks } from "./perf/perf-report";
|
|
12
|
+
import { defaultLayout, type LaunchLayout } from "./launch-layout";
|
|
13
|
+
import {
|
|
14
|
+
ClientExtensionRegistry,
|
|
15
|
+
loadInstalledClientExtensions,
|
|
16
|
+
} from "./client-extension-registry";
|
|
7
17
|
|
|
8
18
|
export interface RunClientOptions {
|
|
9
19
|
sessionId?: string;
|
|
10
20
|
home?: string;
|
|
21
|
+
/** The layout a create-command opens in; omit to restore the remembered one, then the default. */
|
|
22
|
+
layout?: LaunchLayout;
|
|
11
23
|
}
|
|
12
24
|
|
|
13
|
-
|
|
25
|
+
/** OSC background query budget: long enough for a terminal to answer, short enough that one that never
|
|
26
|
+
* does not stall the launch; a non-answering terminal falls back to dark after this. */
|
|
27
|
+
const THEME_QUERY_TIMEOUT_MS = 100;
|
|
28
|
+
|
|
29
|
+
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
14
30
|
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
15
31
|
// so the renderer's native selection is the drag driver
|
|
32
|
+
// a create-command dictates its layout; a bare inbox launch restores the remembered one, then the
|
|
33
|
+
// default; opening a specific thread carries no layout, so its own pane sync drives the composition
|
|
34
|
+
const layout =
|
|
35
|
+
options.layout ??
|
|
36
|
+
(options.sessionId === undefined ? (loadConfig().ui.layout ?? defaultLayout()) : undefined);
|
|
16
37
|
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
38
|
+
const extensionRegistry = new ClientExtensionRegistry();
|
|
39
|
+
|
|
40
|
+
void loadInstalledClientExtensions(extensionRegistry).catch((error) => {
|
|
41
|
+
extensionRegistry.reportError(`Client extension discovery: ${String(error)}`);
|
|
42
|
+
});
|
|
43
|
+
// a full screen of measured elements each holds a frame listener; lift the default-10 ceiling so a
|
|
44
|
+
// busy view does not trip a false leak warning, while a runaway subscription still would
|
|
45
|
+
renderer.setMaxListeners(64);
|
|
46
|
+
perfMark("renderer");
|
|
47
|
+
// re-assert mouse reporting on focus-in: a multiplexer can drop it, stranding the pointer in native selection
|
|
48
|
+
renderer.on("focus", () => {
|
|
49
|
+
renderer.useMouse = false;
|
|
50
|
+
renderer.useMouse = true;
|
|
51
|
+
});
|
|
17
52
|
return new Promise<number>((resolve) => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
53
|
+
let exited = false;
|
|
54
|
+
const shutdown = async (code: number): Promise<void> => {
|
|
55
|
+
if (exited) return;
|
|
56
|
+
exited = true;
|
|
57
|
+
renderer.destroy();
|
|
58
|
+
// close the warm prototype Chromium before exit so it never orphans; the
|
|
59
|
+
// await also gives the renderer time to flush its terminal-restore bytes
|
|
60
|
+
await closePrototypeBrowser();
|
|
61
|
+
resolve(code);
|
|
62
|
+
process.exit(code);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// a closed pane/terminal hangs up (or the parent kills us); exit instead of
|
|
66
|
+
// lingering as an orphan whose pty read loop keeps burning CPU
|
|
67
|
+
process.once("SIGHUP", () => void shutdown(0));
|
|
68
|
+
process.once("SIGTERM", () => void shutdown(0));
|
|
69
|
+
|
|
70
|
+
const root = createRoot(renderer);
|
|
71
|
+
const renderApp = (appearance: Appearance): void => {
|
|
72
|
+
root.render(
|
|
73
|
+
React.createElement(App, {
|
|
74
|
+
home: options.home,
|
|
75
|
+
sessionId: options.sessionId,
|
|
76
|
+
appearance,
|
|
77
|
+
layout,
|
|
78
|
+
extensionRegistry,
|
|
79
|
+
onExit: shutdown,
|
|
80
|
+
onReady: () => {
|
|
81
|
+
perfMark("firstFrame");
|
|
82
|
+
reportPerfMarks("startup");
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Paint immediately with the default appearance; the OSC theme query resolves off the
|
|
89
|
+
// first-paint path and upgrades the theme in place when the terminal answers, so launch
|
|
90
|
+
// never blocks on it. A non-answering terminal simply stays dark.
|
|
91
|
+
renderApp("dark");
|
|
92
|
+
perfMark("render");
|
|
93
|
+
|
|
94
|
+
void renderer
|
|
95
|
+
.waitForThemeMode(THEME_QUERY_TIMEOUT_MS)
|
|
96
|
+
.then((mode) => {
|
|
97
|
+
perfMark("themeQuery");
|
|
98
|
+
// the query can answer after a fast quit; never render onto a destroyed renderer
|
|
99
|
+
if (!exited && mode && mode !== "dark") renderApp(mode);
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {});
|
|
29
102
|
});
|
|
30
103
|
}
|
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
|
});
|