@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,199 @@
1
+ /**
2
+ * A live in-process terminal emulator backed by Ghostty's VT core (libghostty-vt)
3
+ * through a flat FFI shim (native/src/shim.zig). Feed it a child process's PTY
4
+ * output with `write`, then read the rendered screen cell-by-cell with `readCell`
5
+ * to paint it anywhere - this is what lets a real `cc`/shell render inside an
6
+ * OpenTUI box. Load with `loadGhosttyTerminals`, which returns null when no
7
+ * prebuilt dylib exists for this platform (callers fall back to a herdr split).
8
+ */
9
+
10
+ import { dlopen, FFIType, ptr } from "bun:ffi";
11
+ import { existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ /** How a cell's color arrived from Ghostty: the terminal default, an ANSI palette index, or true RGB. */
15
+ export type GhosttyColor =
16
+ | { kind: "default" }
17
+ | { kind: "palette"; index: number }
18
+ | { kind: "rgb"; r: number; g: number; b: number };
19
+
20
+ /** The cursor's viewport position and whether it should be drawn. */
21
+ export interface GhosttyCursor {
22
+ x: number;
23
+ y: number;
24
+ visible: boolean;
25
+ }
26
+
27
+ /** One decoded screen cell: its glyph, colors, cell width, and text decorations. */
28
+ export interface GhosttyCell {
29
+ /** Unicode code point; 0 = blank. */
30
+ codepoint: number;
31
+ fg: GhosttyColor;
32
+ bg: GhosttyColor;
33
+ /** Ghostty width tag: 0 = normal, 1 = double-width lead, 2 = trailing cell. */
34
+ width: number;
35
+ bold: boolean;
36
+ italic: boolean;
37
+ underline: boolean;
38
+ inverse: boolean;
39
+ faint: boolean;
40
+ strikethrough: boolean;
41
+ }
42
+
43
+ const CVT_SYMBOLS = {
44
+ cvt_new: { args: [FFIType.u16, FFIType.u16], returns: FFIType.ptr },
45
+ cvt_free: { args: [FFIType.ptr], returns: FFIType.void },
46
+ cvt_write: { args: [FFIType.ptr, FFIType.ptr, FFIType.u64], returns: FFIType.void },
47
+ cvt_resize: { args: [FFIType.ptr, FFIType.u16, FFIType.u16], returns: FFIType.void },
48
+ cvt_cell: { args: [FFIType.ptr, FFIType.u16, FFIType.u32, FFIType.ptr], returns: FFIType.i32 },
49
+ cvt_cursor: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
50
+ } as const;
51
+
52
+ type CvtLib = ReturnType<typeof dlopen<typeof CVT_SYMBOLS>>["symbols"];
53
+
54
+ /** The prebuilt shim dylib for this platform, or null when none ships for it. */
55
+ function nativeLibraryPath(): string | null {
56
+ const suffix = process.platform === "darwin" ? "dylib" : "so";
57
+ const dir = `${process.platform}-${process.arch}`;
58
+ const path = join(import.meta.dir, "..", "native", dir, `libcueloopvt.${suffix}`);
59
+
60
+ return existsSync(path) ? path : null;
61
+ }
62
+
63
+ /** One shared load of the VT library; null when no dylib ships or it failed to load. */
64
+ let ghosttyFactory: GhosttyTerminalFactory | null | undefined;
65
+
66
+ /**
67
+ * Open the Ghostty VT library for this platform, or return null when the prebuilt
68
+ * dylib is missing (the caller degrades to a herdr split instead of embedding).
69
+ * The library is opened once per process; later calls return the same factory.
70
+ */
71
+ export function loadGhosttyTerminals(): GhosttyTerminalFactory | null {
72
+ if (ghosttyFactory !== undefined) return ghosttyFactory;
73
+ const path = nativeLibraryPath();
74
+
75
+ if (!path) return (ghosttyFactory = null);
76
+ try {
77
+ const lib = dlopen(path, CVT_SYMBOLS);
78
+
79
+ ghosttyFactory = new GhosttyTerminalFactory(lib.symbols);
80
+ } catch (error) {
81
+ // The file exists but failed to load (bad arch, missing symbol) - a real
82
+ // fault, not the expected no-prebuilt case; surface it, then degrade.
83
+ console.error(`cueloop: failed to load ${path}:`, error);
84
+ ghosttyFactory = null;
85
+ }
86
+
87
+ return ghosttyFactory;
88
+ }
89
+
90
+ /** Creates `GhosttyTerminal`s that share one loaded copy of the VT library. */
91
+ export class GhosttyTerminalFactory {
92
+ constructor(private readonly lib: CvtLib) {}
93
+ /** A fresh terminal sized to cols x rows, or null if allocation failed. */
94
+ create(cols: number, rows: number): GhosttyTerminal | null {
95
+ const handle = this.lib.cvt_new(cols, rows);
96
+
97
+ return handle ? new GhosttyTerminal(this.lib, handle) : null;
98
+ }
99
+ }
100
+
101
+ // The flat CvtCell struct the shim writes: u32 codepoint, fg rgba-ish (4 bytes),
102
+ // bg (4 bytes), u8 width, u8 flags, u16 pad = 16 bytes.
103
+ const CELL_STRUCT_BYTES = 16;
104
+ const FLAG_BOLD = 1;
105
+ const FLAG_ITALIC = 2;
106
+ const FLAG_UNDERLINE = 4;
107
+ const FLAG_INVERSE = 8;
108
+ const FLAG_FAINT = 16;
109
+ const FLAG_STRIKETHROUGH = 32;
110
+
111
+ /** A live Ghostty terminal: feed PTY bytes, read the screen, resize, free. */
112
+ export class GhosttyTerminal {
113
+ private readonly out = new Uint8Array(CELL_STRUCT_BYTES);
114
+ private readonly outView = new DataView(this.out.buffer);
115
+ private readonly cursorOut = new Uint8Array(8);
116
+ private freed = false;
117
+
118
+ constructor(
119
+ private readonly lib: CvtLib,
120
+ private readonly handle: NonNullable<ReturnType<CvtLib["cvt_new"]>>,
121
+ ) {}
122
+
123
+ /** Process a chunk of the child's PTY output into the screen state. */
124
+ write(bytes: Uint8Array): void {
125
+ if (this.freed || bytes.length === 0) return;
126
+ this.lib.cvt_write(this.handle, ptr(bytes), BigInt(bytes.length));
127
+ }
128
+
129
+ /** Resize the screen (and the child's tty, via the caller) to cols x rows. */
130
+ resize(cols: number, rows: number): void {
131
+ if (this.freed) return;
132
+ this.lib.cvt_resize(this.handle, cols, rows);
133
+ }
134
+
135
+ /** The rendered cell at viewport (x, y), or null when out of range. */
136
+ readCell(x: number, y: number): GhosttyCell | null {
137
+ if (this.freed) return null;
138
+ if (this.lib.cvt_cell(this.handle, x, y, ptr(this.out)) !== 0) return null;
139
+ const flags = this.out[13]!;
140
+
141
+ return {
142
+ codepoint: this.outView.getUint32(0, true),
143
+ fg: decodeColor(this.out[7]!, this.out[4]!, this.out[5]!, this.out[6]!),
144
+ bg: decodeColor(this.out[11]!, this.out[8]!, this.out[9]!, this.out[10]!),
145
+ width: this.out[12]!,
146
+ bold: (flags & FLAG_BOLD) !== 0,
147
+ italic: (flags & FLAG_ITALIC) !== 0,
148
+ underline: (flags & FLAG_UNDERLINE) !== 0,
149
+ inverse: (flags & FLAG_INVERSE) !== 0,
150
+ faint: (flags & FLAG_FAINT) !== 0,
151
+ strikethrough: (flags & FLAG_STRIKETHROUGH) !== 0,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Row `y` as text: blank cells read as spaces, the trailing half of a wide
157
+ * glyph is skipped, and trailing spaces are trimmed.
158
+ */
159
+ rowText(y: number, cols: number): string {
160
+ let line = "";
161
+
162
+ for (let x = 0; x < cols; x++) {
163
+ const cell = this.readCell(x, y);
164
+
165
+ if (cell === null || cell.width === 2) continue;
166
+ line += cell.codepoint === 0 ? " " : String.fromCodePoint(cell.codepoint);
167
+ }
168
+
169
+ return line.trimEnd();
170
+ }
171
+
172
+ /** The cursor's current viewport position and visibility. */
173
+ readCursor(): GhosttyCursor {
174
+ if (this.freed) return { x: 0, y: 0, visible: false };
175
+ this.lib.cvt_cursor(this.handle, ptr(this.cursorOut));
176
+ const view = new DataView(this.cursorOut.buffer);
177
+
178
+ return {
179
+ x: view.getUint16(0, true),
180
+ y: view.getUint16(2, true),
181
+ visible: this.cursorOut[4] === 1,
182
+ };
183
+ }
184
+
185
+ /** Release the terminal; further calls are no-ops. Call once on teardown. */
186
+ free(): void {
187
+ if (this.freed) return;
188
+ this.freed = true;
189
+ this.lib.cvt_free(this.handle);
190
+ }
191
+ }
192
+
193
+ /** Decode the shim's (kind, r, g, b) color triple; palette carries its index in r. */
194
+ function decodeColor(kind: number, r: number, g: number, b: number): GhosttyColor {
195
+ if (kind === 2) return { kind: "rgb", r, g, b };
196
+ if (kind === 1) return { kind: "palette", index: r };
197
+
198
+ return { kind: "default" };
199
+ }
@@ -0,0 +1,58 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveGithubIdentity } from "./github-identity";
6
+
7
+ const originalBinary = process.env.CUELOOP_GH;
8
+ const tempDirectories: string[] = [];
9
+
10
+ afterEach(() => {
11
+ if (originalBinary === undefined) delete process.env.CUELOOP_GH;
12
+ else process.env.CUELOOP_GH = originalBinary;
13
+ for (const directory of tempDirectories.splice(0)) {
14
+ rmSync(directory, { recursive: true, force: true });
15
+ }
16
+ });
17
+
18
+ function stubGithubCli(script: string): string {
19
+ const directory = mkdtempSync(join(tmpdir(), "gh-stub-"));
20
+
21
+ tempDirectories.push(directory);
22
+ const path = join(directory, "gh");
23
+
24
+ writeFileSync(path, `#!/bin/sh\n${script}\n`);
25
+ chmodSync(path, 0o755);
26
+
27
+ return path;
28
+ }
29
+
30
+ test("reads the GitHub login and name from gh", async () => {
31
+ process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":"Markus M"}'`);
32
+
33
+ expect(await resolveGithubIdentity()).toEqual({ login: "markus", name: "Markus M" });
34
+ });
35
+
36
+ test("drops a null name to undefined", async () => {
37
+ process.env.CUELOOP_GH = stubGithubCli(`printf '{"login":"markus","name":null}'`);
38
+
39
+ expect(await resolveGithubIdentity()).toEqual({ login: "markus" });
40
+ });
41
+
42
+ test("falls back to null when gh exits non-zero (logged out)", async () => {
43
+ process.env.CUELOOP_GH = stubGithubCli(`exit 1`);
44
+
45
+ expect(await resolveGithubIdentity()).toBeNull();
46
+ });
47
+
48
+ test("falls back to null when gh is not installed", async () => {
49
+ process.env.CUELOOP_GH = join(tmpdir(), "cueloop-absent-gh-binary");
50
+
51
+ expect(await resolveGithubIdentity()).toBeNull();
52
+ });
53
+
54
+ test("falls back to null when gh prints an unexpected shape", async () => {
55
+ process.env.CUELOOP_GH = stubGithubCli(`printf 'not json'`);
56
+
57
+ expect(await resolveGithubIdentity()).toBeNull();
58
+ });
@@ -0,0 +1,41 @@
1
+ /** The signed-in GitHub identity from the local gh tool, or null when gh cannot answer. */
2
+
3
+ import * as v from "valibot";
4
+
5
+ export interface GithubIdentity {
6
+ login: string;
7
+ name?: string;
8
+ }
9
+
10
+ const GithubUserSchema = v.object({
11
+ login: v.string(),
12
+ name: v.nullish(v.string()),
13
+ });
14
+
15
+ /** The gh binary is injectable so tests stub it without a real GitHub login. */
16
+ function githubCliBinary(): string {
17
+ return process.env.CUELOOP_GH || "gh";
18
+ }
19
+
20
+ /** Read the authenticated GitHub login and name, or null when gh cannot answer. */
21
+ export async function resolveGithubIdentity(): Promise<GithubIdentity | null> {
22
+ try {
23
+ const githubProcess = Bun.spawn(
24
+ [githubCliBinary(), "api", "user", "--jq", "{login: .login, name: .name}"],
25
+ { stdout: "pipe", stderr: "ignore" },
26
+ );
27
+ const [output, exitCode] = await Promise.all([
28
+ new Response(githubProcess.stdout).text(),
29
+ githubProcess.exited,
30
+ ]);
31
+
32
+ if (exitCode !== 0) return null;
33
+ const parsed = v.safeParse(GithubUserSchema, JSON.parse(output));
34
+
35
+ if (!parsed.success) return null;
36
+
37
+ return { login: parsed.output.login, name: parsed.output.name ?? undefined };
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
package/src/herdr.test.ts CHANGED
@@ -2,50 +2,23 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { focusHerdrPane, returnPaneFor } from "./herdr";
5
+ import { focusHerdrPane } from "./herdr";
6
6
 
7
7
  let dir: string;
8
- const savedEnv: Record<string, string | undefined> = {};
9
8
 
10
9
  beforeEach(() => {
11
10
  dir = mkdtempSync(join(tmpdir(), "cueloop-herdr-"));
12
- for (const k of ["HERDR_ENV", "HERDR_PANE_ID", "HERDR_BIN_PATH", "CUELOOP_RETURN_PANE"]) {
13
- savedEnv[k] = process.env[k];
14
- delete process.env[k];
15
- }
16
11
  });
17
12
  afterEach(() => {
18
- for (const [k, v] of Object.entries(savedEnv)) {
19
- if (v === undefined) delete process.env[k];
20
- else process.env[k] = v;
21
- }
22
13
  rmSync(dir, { recursive: true, force: true });
23
14
  });
24
15
 
25
- describe("returnPaneFor", () => {
26
- test("outside herdr there is never a return target", () => {
27
- expect(returnPaneFor("w1:p1")).toBeUndefined();
28
- });
29
-
30
- test("env override wins, session meta is the fallback", () => {
31
- process.env.HERDR_ENV = "1";
32
- process.env.HERDR_PANE_ID = "w1:p9";
33
- expect(returnPaneFor("w1:p1")).toBe("w1:p1");
34
- process.env.CUELOOP_RETURN_PANE = "w1:p2";
35
- expect(returnPaneFor("w1:p1")).toBe("w1:p2");
36
- });
37
-
38
- test("returning to our own pane is a no-op", () => {
39
- process.env.HERDR_ENV = "1";
40
- process.env.HERDR_PANE_ID = "w1:p1";
41
- expect(returnPaneFor("w1:p1")).toBeUndefined();
42
- });
43
- });
44
-
45
16
  describe("focusHerdrPane", () => {
46
17
  test("resolves the pane's tab and focuses it", () => {
18
+ // Arrange
47
19
  const log = join(dir, "calls.log");
48
20
  const stub = join(dir, "herdr");
21
+
49
22
  writeFileSync(
50
23
  stub,
51
24
  `#!/bin/sh
@@ -54,15 +27,17 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
54
27
  `,
55
28
  );
56
29
  Bun.spawnSync(["chmod", "+x", stub]);
57
- process.env.HERDR_BIN_PATH = stub;
58
- expect(focusHerdrPane("w1:p1")).toBe(true);
30
+
31
+ // Assert
32
+ expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
59
33
  const calls = readFileSync(log, "utf8").trim().split("\n");
34
+
60
35
  expect(calls[0]).toBe("pane get w1:p1");
61
36
  expect(calls[1]).toBe("tab focus w1:t3");
62
37
  });
63
38
 
64
39
  test("a missing binary never throws", () => {
65
- process.env.HERDR_BIN_PATH = join(dir, "nope");
66
- expect(focusHerdrPane("w1:p1")).toBe(false);
40
+ // Assert
41
+ expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
67
42
  });
68
43
  });
package/src/herdr.ts CHANGED
@@ -1,33 +1,37 @@
1
1
  /**
2
- * First-class herdr return (#23): a review opened beside an agent should hand
3
- * focus back to the agent's pane when it closes, not leave the reviewer on a
4
- * dead tab. Detection is the ambient env contract; the return target comes
5
- * from CUELOOP_RETURN_PANE (set by whoever opened this pane) or the session's
6
- * recorded herdrPane (set by the adapter hook). Everything here is
7
- * best-effort: focus failures never block closing the review.
2
+ * First-class herdr return: a review opened beside an agent hands focus back
3
+ * to the agent's pane when it closes, not leaving the reviewer on a dead tab.
4
+ * The env contract and the return-target rule live in @cueloop/schema; this
5
+ * module owns the one reviewer-side action that spawns the herdr CLI. Focus
6
+ * is best-effort - a failure never blocks closing the review.
8
7
  */
9
8
 
10
- export function insideHerdr(): boolean {
11
- return process.env.HERDR_ENV === "1";
12
- }
9
+ import * as v from "valibot";
13
10
 
14
- export function returnPaneFor(sessionHerdrPane?: string): string | undefined {
15
- if (!insideHerdr()) return undefined;
16
- const pane = process.env.CUELOOP_RETURN_PANE ?? sessionHerdrPane;
17
- // returning to our own pane would be a no-op at best
18
- return pane && pane !== process.env.HERDR_PANE_ID ? pane : undefined;
19
- }
11
+ const HerdrPaneSchema = v.object({
12
+ result: v.optional(v.object({ pane: v.optional(v.object({ tab_id: v.optional(v.string()) })) })),
13
+ });
20
14
 
21
15
  /** Focus the tab holding the target pane. Synchronous: runs just before exit. */
22
- export function focusHerdrPane(paneId: string): boolean {
23
- const bin = process.env.HERDR_BIN_PATH ?? "herdr";
16
+ export function focusHerdrPane(binPath: string, paneId: string): boolean {
24
17
  try {
25
- const got = Bun.spawnSync([bin, "pane", "get", paneId], { stdout: "pipe", stderr: "ignore" });
18
+ const got = Bun.spawnSync([binPath, "pane", "get", paneId], {
19
+ stdout: "pipe",
20
+ stderr: "ignore",
21
+ });
22
+
26
23
  if (got.exitCode !== 0) return false;
27
- const parsed = JSON.parse(got.stdout.toString()) as { result?: { pane?: { tab_id?: string } } };
28
- const tab = parsed.result?.pane?.tab_id;
24
+ const parsed = v.safeParse(HerdrPaneSchema, JSON.parse(got.stdout.toString()));
25
+
26
+ if (!parsed.success) return false;
27
+ const tab = parsed.output.result?.pane?.tab_id;
28
+
29
29
  if (!tab) return false;
30
- return Bun.spawnSync([bin, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
30
+
31
+ return (
32
+ Bun.spawnSync([binPath, "tab", "focus", tab], { stdout: "ignore", stderr: "ignore" })
33
+ .exitCode === 0
34
+ );
31
35
  } catch {
32
36
  return false;
33
37
  }
package/src/index.ts CHANGED
@@ -1,2 +1,31 @@
1
1
  export { runClient, type RunClientOptions } from "./run";
2
+ export { snapshotWorkbench, type RepoDiff } from "./workbench-snapshot";
3
+ export {
4
+ defaultLayout,
5
+ pairLayout,
6
+ planLayout,
7
+ pullRequestReviewLayout,
8
+ reviewLayout,
9
+ type LaunchLayout,
10
+ } from "./launch-layout";
11
+ export {
12
+ diffRowAnchor,
13
+ diffRows,
14
+ diffRowText,
15
+ fileRowRange,
16
+ resolveReviewAnchorRow,
17
+ type DiffRow,
18
+ } from "./view-diff";
19
+ export { loadConfig, type ReviewWorkspaceMode } from "./config";
2
20
  export { serveClient, type ServeHandle, type ServeOptions } from "./serve";
21
+ export { App, type AppProps } from "./App";
22
+ export {
23
+ collaboratorAnnotations,
24
+ mergeFromShare,
25
+ publishShare,
26
+ pullShare,
27
+ pushShare,
28
+ shareIdFromLine,
29
+ type ShareResult,
30
+ type ShareTarget,
31
+ } from "./share";
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { OBSIDIAN_DEFAULTS } from "@cueloop/integration-obsidian";
3
+ import { loadBundledExporters } from "./integrations";
4
+ import type { IntegrationsConfig } from "./config";
5
+
6
+ function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): IntegrationsConfig {
7
+ return { obsidian: { ...OBSIDIAN_DEFAULTS, ...overrides } };
8
+ }
9
+
10
+ describe("loadBundledExporters", () => {
11
+ test("composes the obsidian integration into one named exporter", async () => {
12
+ // Act
13
+ const exporters = await loadBundledExporters(integrations());
14
+
15
+ // Assert
16
+ expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
17
+ });
18
+
19
+ test("runsOn mirrors the configured export policy", async () => {
20
+ // Arrange
21
+ const onApprove = (await loadBundledExporters(integrations({ exportOn: "approved" })))[0]!;
22
+
23
+ // Assert
24
+ expect(onApprove.runsOn("approved")).toBeTrue();
25
+ expect(onApprove.runsOn("changes_requested")).toBeFalse();
26
+
27
+ // Arrange
28
+ const onMessage = (await loadBundledExporters(integrations({ exportOn: "message" })))[0]!;
29
+
30
+ // Assert
31
+ expect(onMessage.runsOn("approved")).toBeTrue();
32
+ expect(onMessage.runsOn("changes_requested")).toBeTrue();
33
+
34
+ // Arrange
35
+ const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
36
+
37
+ // Assert
38
+ expect(manual.runsOn("approved")).toBeFalse();
39
+ expect(manual.runsOn("changes_requested")).toBeFalse();
40
+ });
41
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Bundled integration wiring: composes the configured integrations into
3
+ * generic exporters plus their run policy, so the session controller depends
4
+ * only on the extension seam, never on an integration's own config type. This
5
+ * and config.ts are the only client modules that name a concrete integration.
6
+ */
7
+
8
+ import { Registry, type Exporter } from "@cueloop/extension-api";
9
+ import { createObsidianExtension, shouldExport } from "@cueloop/integration-obsidian";
10
+ import type { MessageOutcome } from "@cueloop/schema";
11
+ import type { IntegrationsConfig } from "./config";
12
+
13
+ /** An exporter plus the policy deciding which messages trigger it. */
14
+ export interface BundledExporter {
15
+ name: string;
16
+ run: Exporter;
17
+ runsOn(message: MessageOutcome): boolean;
18
+ }
19
+
20
+ /** Load the bundled integrations from config as generic exporters. */
21
+ export async function loadBundledExporters(
22
+ integrations: IntegrationsConfig,
23
+ ): Promise<BundledExporter[]> {
24
+ const registry = new Registry();
25
+ const obsidian = integrations.obsidian;
26
+ const record = await registry.load("obsidian", createObsidianExtension(obsidian));
27
+ const run = record.exporters.get("obsidian");
28
+
29
+ if (!run) return [];
30
+
31
+ return [{ name: "obsidian", run, runsOn: (message) => shouldExport(obsidian.exportOn, message) }];
32
+ }