@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.
Files changed (316) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1406 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +787 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +29 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +78 -0
  241. package/src/launch-layout.ts +62 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
@@ -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
- export async function runClient(opts: RunClientOptions): Promise<number> {
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
- createRoot(renderer).render(
19
- React.createElement(App, {
20
- home: opts.home,
21
- sessionId: opts.sessionId,
22
- onExit: (code: number) => {
23
- renderer.destroy();
24
- resolve(code);
25
- process.exit(code);
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 { ReviewSession } from "@cueloop/schema";
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: ReviewSession;
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(port: number, until: (s: string) => boolean, timeoutMs = 8000): Promise<string> {
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
- err ? reject(err) : resolve(out);
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", (d: Buffer) => {
59
- out += d.toString("utf8");
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 observer TUI for the session", async () => {
81
- const bytes = await sshCapture(handle.port, (s) => s.includes("Rollout Plan") && s.includes("observer"));
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, (s) => s.includes("Rollout Plan")),
91
- sshCapture(handle.port, (s) => s.includes("Rollout Plan")),
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
  });