@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
package/src/serve.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * SSH-served TUI (#22 share path): `cueloop serve` lets teammates join a
3
- * review session over plain ssh. Every SSH connection renders <App> in
2
+ * SSH-served TUI: `cueloop serve` lets teammates join a
3
+ * thread over plain ssh. Every SSH connection renders <App> in
4
4
  * observer mode (readOnly) against the same local daemon; the one writable
5
5
  * controller stays the local owner's own `cueloop` TUI.
6
6
  *
@@ -20,7 +20,38 @@ import React from "react";
20
20
  import { createRoot } from "@opentui/react";
21
21
  import { createServer } from "@opentui/ssh";
22
22
  import { cueloopHome } from "@cueloop/daemon";
23
+ import { DaemonClient } from "@cueloop/daemon/client";
24
+ import type { Artifact } from "@cueloop/schema";
23
25
  import { App } from "./App";
26
+ import { snapshotWorkbench } from "./workbench-snapshot";
27
+
28
+ /** OSC background query budget: brief so a terminal that never answers falls back to dark. */
29
+ const THEME_QUERY_TIMEOUT_MS = 200;
30
+
31
+ /**
32
+ * The frozen diff an observer reads for a served workbench thread: captured once at serve time, so a
33
+ * late local edit never shifts it. Undefined when nothing is served or the thread is not a workbench.
34
+ */
35
+ async function captureFrozenArtifact(
36
+ home: string,
37
+ sessionId: string | undefined,
38
+ ): Promise<Artifact | undefined> {
39
+ if (sessionId === undefined) return undefined;
40
+
41
+ const probe = await DaemonClient.connect({ home, autostart: true });
42
+
43
+ try {
44
+ const thread = await probe.sessionGet(sessionId).catch(() => undefined);
45
+
46
+ if (thread === undefined) return undefined;
47
+
48
+ const snapshot = await snapshotWorkbench(thread, (root, vcs) => probe.repoDiff(root, vcs));
49
+
50
+ return snapshot === thread ? undefined : snapshot.artifact;
51
+ } finally {
52
+ probe.close();
53
+ }
54
+ }
24
55
 
25
56
  export interface ServeOptions {
26
57
  /** TCP port for the SSH listener; 0 picks an ephemeral port. Default 2222. */
@@ -33,7 +64,7 @@ export interface ServeOptions {
33
64
  home?: string;
34
65
  /** Silence the @opentui/ssh startup banner (tests). Default true. */
35
66
  banner?: boolean;
36
- onError?: (err: unknown) => void;
67
+ onError?: (cause: unknown) => void;
37
68
  }
38
69
 
39
70
  export interface ServeHandle {
@@ -44,25 +75,35 @@ export interface ServeHandle {
44
75
  close(): Promise<void>;
45
76
  }
46
77
 
47
- export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle> {
48
- const home = opts.home ?? cueloopHome();
78
+ export async function serveClient(options: ServeOptions = {}): Promise<ServeHandle> {
79
+ const home = options.home ?? cueloopHome();
49
80
  const sshDir = join(home, "ssh");
81
+
50
82
  mkdirSync(sshDir, { recursive: true, mode: 0o700 });
51
83
 
84
+ const servedArtifact = await captureFrozenArtifact(home, options.sessionId);
85
+
52
86
  const server = createServer({
53
87
  // password-less by design; see the trust model in the module comment
54
88
  auth: "open",
55
89
  hostKey: { path: join(sshDir, "host_key") },
56
- startupBanner: opts.banner ?? true,
90
+ startupBanner: options.banner ?? true,
57
91
  idleTimeout: "2h",
58
- onError: opts.onError,
59
- }).serve((session) => {
92
+ onError: options.onError,
93
+ }).serve(async (session) => {
94
+ // the observer's terminal reports its own background, so a light-terminal
95
+ // watcher gets the readable variant too
96
+ const appearance =
97
+ (await session.renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
60
98
  const root = createRoot(session.renderer);
99
+
61
100
  root.render(
62
101
  React.createElement(App, {
63
102
  home,
64
- sessionId: opts.sessionId,
103
+ sessionId: options.sessionId,
65
104
  readOnly: true,
105
+ appearance,
106
+ servedArtifact,
66
107
  // q disconnects only this observer, never the server
67
108
  onExit: () => session.end(),
68
109
  }),
@@ -70,6 +111,7 @@ export async function serveClient(opts: ServeOptions = {}): Promise<ServeHandle>
70
111
  session.onClose(() => root.unmount());
71
112
  });
72
113
 
73
- const info = await server.listen(opts.port ?? 2222, opts.host ?? "127.0.0.1");
114
+ const info = await server.listen(options.port ?? 2222, options.host ?? "127.0.0.1");
115
+
74
116
  return { ...info, close: () => server.close() };
75
117
  }
package/src/share.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Publish a thread as a share: pack it, stream it to the gateway's
3
+ * `share` user over SSH, and copy the returned `ssh p_…@host` line to the
4
+ * clipboard. The planner holds no key, so all this side does is upload; the
5
+ * gateway seals and stores. Used by both `cueloop share` and the in-TUI
6
+ * Share button, so the two share exactly the same path.
7
+ */
8
+
9
+ import {
10
+ DEFAULT_SHARE_HOST,
11
+ DEFAULT_SHARE_PORT,
12
+ SHARE_UPLOAD_USER,
13
+ packSessionBlob,
14
+ } from "@cueloop/daemon/share-blob";
15
+ import {
16
+ removalEntries,
17
+ viewFollowing,
18
+ type Annotation,
19
+ type ShareAccess,
20
+ type Thread,
21
+ } from "@cueloop/schema";
22
+ import { ThreadRecordSchema } from "@cueloop/daemon/validate";
23
+ import type { SharedMerge } from "@cueloop/daemon/client";
24
+ import * as v from "valibot";
25
+ import { copyToClipboard } from "./clipboard";
26
+
27
+ export interface ShareTarget {
28
+ host?: string;
29
+ port?: number;
30
+ }
31
+
32
+ export interface ShareResult {
33
+ /** The one line to paste: `ssh p_xxxxxxxx@host`. */
34
+ line: string;
35
+ /** Whether the line made it onto the system clipboard. */
36
+ copied: boolean;
37
+ }
38
+
39
+ /** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
40
+ export function shareIdFromLine(line: string): string | undefined {
41
+ return line.match(/^ssh (\S+)@/)?.[1];
42
+ }
43
+
44
+ /** The paste line for a share id: `ssh p_…@host`. Used to copy an existing link. */
45
+ export function formatShareLine(shareId: string, target: ShareTarget = {}): string {
46
+ return `ssh ${shareId}@${target.host ?? DEFAULT_SHARE_HOST}`;
47
+ }
48
+
49
+ /** Upload the session to the gateway and copy the resulting ssh line. */
50
+ export async function publishShare(
51
+ session: Thread,
52
+ target: ShareTarget = {},
53
+ ): Promise<ShareResult> {
54
+ const { stdout, stderr, code } = await runShareSsh(
55
+ "cueloop-share",
56
+ // the share follows one branch: collaborators see its path wherever the owner stands
57
+ packSessionBlob(viewFollowing(session)),
58
+ target,
59
+ );
60
+
61
+ if (code !== 0)
62
+ throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
63
+ const line = stdout.trim();
64
+
65
+ if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
66
+ const copied = await copyToClipboard(line);
67
+
68
+ return { line, copied };
69
+ }
70
+
71
+ /**
72
+ * Pull a share's current session back from the gateway. The gateway lets only
73
+ * the fingerprint that uploaded it through, so collaborator notes reach the
74
+ * planner without exposing the master key.
75
+ */
76
+ export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<Thread> {
77
+ const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
78
+
79
+ if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
80
+
81
+ return v.parse(ThreadRecordSchema, JSON.parse(stdout));
82
+ }
83
+
84
+ /** A share's collaborator notes: the ones a viewer authored (author stamped). */
85
+ export function collaboratorAnnotations(session: Thread): Annotation[] {
86
+ return session.annotations.filter((annotation) => annotation.author);
87
+ }
88
+
89
+ /**
90
+ * What a pulled share hands the local merge: collaborators' notes, the
91
+ * participant registry, and the removals the share recorded, by entry id.
92
+ */
93
+ export function mergeFromShare(remote: Thread): SharedMerge {
94
+ const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
95
+
96
+ if (remote.participants) merge.participants = remote.participants;
97
+ if (remote.history) {
98
+ merge.removals = removalEntries(remote.history).map((entry) => ({
99
+ id: entry.id,
100
+ annotationId: entry.annotationId,
101
+ createdAt: entry.createdAt,
102
+ }));
103
+ }
104
+
105
+ return merge;
106
+ }
107
+
108
+ /**
109
+ * Mirror the planner's own annotations up into the share, so collaborators see
110
+ * them on their next refresh. Owner-gated at the gateway; the notes stay
111
+ * unauthored (the planner's), unioned by id.
112
+ */
113
+ export async function pushShare(
114
+ shareId: string,
115
+ annotations: Array<Omit<Annotation, "createdAt">>,
116
+ // ShareAccess sets a private allowlist; "public" clears it (makes the link public); undefined leaves it
117
+ access?: ShareAccess | "public",
118
+ target: ShareTarget = {},
119
+ ): Promise<void> {
120
+ const payload = access ? { shareId, annotations, access } : { shareId, annotations };
121
+ const { stderr, code } = await runShareSsh(
122
+ "cueloop-push",
123
+ Buffer.from(JSON.stringify(payload)),
124
+ target,
125
+ );
126
+
127
+ if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
128
+ }
129
+
130
+ /** Revoke a share: the gateway deletes the blob so its link stops resolving. Owner-gated; idempotent. */
131
+ export async function revokeShare(shareId: string, target: ShareTarget = {}): Promise<void> {
132
+ const { stderr, code } = await runShareSsh("cueloop-revoke", Buffer.from(shareId), target);
133
+
134
+ if (code !== 0)
135
+ throw new Error(`gateway revoke failed: ${stderr.trim() || `ssh exited ${code}`}`);
136
+ }
137
+
138
+ export interface ShareWatchHandlers {
139
+ /** The whole session record, each time the share changes. */
140
+ onSession: (session: Thread) => void;
141
+ /** The stream ended, for any reason; the caller decides whether to reconnect. */
142
+ onClose: (reason: string) => void;
143
+ }
144
+
145
+ const WatchFrameSchema = v.variant("type", [
146
+ v.object({ type: v.literal("ready") }),
147
+ v.object({ type: v.literal("ping") }),
148
+ v.object({ type: v.literal("session"), session: ThreadRecordSchema }),
149
+ ]);
150
+
151
+ /**
152
+ * Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
153
+ * a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
154
+ */
155
+ export function watchShare(
156
+ shareId: string,
157
+ handlers: ShareWatchHandlers,
158
+ target: ShareTarget = {},
159
+ ): () => void {
160
+ const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
161
+ stdin: "pipe",
162
+ stdout: "pipe",
163
+ stderr: "pipe",
164
+ });
165
+
166
+ proc.stdin.write(shareId);
167
+ proc.stdin.end();
168
+ void readLines(proc.stdout, (line) => {
169
+ const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
170
+
171
+ if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
172
+ }).catch(() => {});
173
+ void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
174
+ handlers.onClose(stderr.trim() || `ssh exited ${code}`),
175
+ );
176
+
177
+ return () => proc.kill();
178
+ }
179
+
180
+ /** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
181
+ async function readLines(
182
+ stream: ReadableStream<Uint8Array>,
183
+ onLine: (line: string) => void,
184
+ ): Promise<void> {
185
+ const decoder = new TextDecoder();
186
+ let pending = "";
187
+
188
+ for await (const chunk of stream) {
189
+ pending += decoder.decode(chunk, { stream: true });
190
+ const lines = pending.split("\n");
191
+
192
+ pending = lines.pop() ?? "";
193
+ for (const line of lines) {
194
+ if (line.trim() === "") continue;
195
+ try {
196
+ onLine(line);
197
+ } catch {
198
+ // one bad line never ends the stream
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ function shareSshCommand(command: string, target: ShareTarget): string[] {
205
+ const host = target.host ?? DEFAULT_SHARE_HOST;
206
+ const port = target.port ?? DEFAULT_SHARE_PORT;
207
+
208
+ return [
209
+ "ssh",
210
+ "-p",
211
+ String(port),
212
+ "-o",
213
+ "StrictHostKeyChecking=accept-new",
214
+ `${SHARE_UPLOAD_USER}@${host}`,
215
+ command,
216
+ ];
217
+ }
218
+
219
+ /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
220
+ async function runShareSsh(
221
+ command: string,
222
+ stdin: Buffer,
223
+ target: ShareTarget,
224
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
225
+ const proc = Bun.spawn(shareSshCommand(command, target), {
226
+ stdin,
227
+ stdout: "pipe",
228
+ stderr: "pipe",
229
+ });
230
+ const [stdout, stderr, code] = await Promise.all([
231
+ new Response(proc.stdout).text(),
232
+ new Response(proc.stderr).text(),
233
+ proc.exited,
234
+ ]);
235
+
236
+ return { stdout, stderr, code };
237
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadSkills } from "./skills";
6
+ import { mergeSlashItems, type SlashItem } from "./slash-palette";
7
+
8
+ function skillDir(): string {
9
+ const root = mkdtempSync(join(tmpdir(), "cueloop-skills-"));
10
+
11
+ mkdirSync(join(root, "typescript-magician"));
12
+ writeFileSync(
13
+ join(root, "typescript-magician", "SKILL.md"),
14
+ "---\nname: typescript-magician\ndescription: Design complex generic types.\n---\n\nbody\n",
15
+ );
16
+ mkdirSync(join(root, "no-frontmatter"));
17
+ writeFileSync(join(root, "no-frontmatter", "SKILL.md"), "just prose, no frontmatter\n");
18
+
19
+ return root;
20
+ }
21
+
22
+ describe("loadSkills", () => {
23
+ test("reads name and description from each SKILL.md frontmatter", () => {
24
+ const root = skillDir();
25
+
26
+ try {
27
+ const skills = loadSkills(root);
28
+ const magician = skills.find((skill) => skill.name === "typescript-magician");
29
+
30
+ expect(magician?.description).toBe("Design complex generic types.");
31
+ // a directory whose SKILL.md has no frontmatter is skipped, not crashed on
32
+ expect(skills.some((skill) => skill.name === "no-frontmatter")).toBe(false);
33
+ } finally {
34
+ rmSync(root, { recursive: true, force: true });
35
+ }
36
+ });
37
+
38
+ test("a missing skills directory yields no skills", () => {
39
+ expect(loadSkills(join(tmpdir(), "cueloop-does-not-exist-xyz"))).toEqual([]);
40
+ });
41
+
42
+ test("CRLF delimiters parse, and a quoted name is unquoted", () => {
43
+ const root = mkdtempSync(join(tmpdir(), "cueloop-skills-crlf-"));
44
+
45
+ try {
46
+ mkdirSync(join(root, "review"));
47
+ writeFileSync(
48
+ join(root, "review", "SKILL.md"),
49
+ '---\r\nname: "review"\r\ndescription: Review the change.\r\n---\r\n',
50
+ );
51
+ const skills = loadSkills(root);
52
+
53
+ // no stray quotes and no CRLF-broken frontmatter
54
+ expect(skills.map((skill) => skill.name)).toEqual(["review"]);
55
+ } finally {
56
+ rmSync(root, { recursive: true, force: true });
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("mergeSlashItems", () => {
62
+ test("a quick action wins a name collision with a skill", () => {
63
+ const actions: SlashItem[] = [{ name: "lgtm", description: "action", body: "LGTM" }];
64
+ const skills: SlashItem[] = [
65
+ { name: "lgtm", description: "skill", body: "" },
66
+ { name: "vitest-patterns", description: "skill", body: "" },
67
+ ];
68
+ const merged = mergeSlashItems(actions, skills);
69
+
70
+ expect(merged.filter((item) => item.name === "lgtm")).toHaveLength(1);
71
+ expect(merged.find((item) => item.name === "lgtm")?.description).toBe("action");
72
+ expect(merged.some((item) => item.name === "vitest-patterns")).toBe(true);
73
+ });
74
+ });
package/src/skills.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { createContext } from "react";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import * as v from "valibot";
6
+ import type { SlashItem } from "./slash-palette";
7
+
8
+ /** The default user-level skill store, harness-agnostic; a config path overrides it. */
9
+ export function defaultSkillsDir(): string {
10
+ return join(homedir(), ".agents", "skills");
11
+ }
12
+
13
+ const SkillNameSchema = v.pipe(v.string(), v.trim(), v.minLength(1));
14
+
15
+ /** One `key: value` line from the frontmatter, unquoted; undefined when the key is absent. */
16
+ function frontmatterValue(frontmatter: string, key: string): string | undefined {
17
+ const match = new RegExp(`^${key}:\\s*(.+)$`, "m").exec(frontmatter);
18
+
19
+ return match
20
+ ? match[1]!
21
+ .trim()
22
+ .replace(/^["']|["']$/g, "")
23
+ .trim()
24
+ : undefined;
25
+ }
26
+
27
+ /** User skills as palette items: each `<dir>/SKILL.md` whose frontmatter carries a name and description. */
28
+ export function loadSkills(dir: string): SlashItem[] {
29
+ let entries;
30
+
31
+ try {
32
+ entries = readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return [];
35
+ }
36
+ const items: SlashItem[] = [];
37
+
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory()) continue;
40
+ let text;
41
+
42
+ try {
43
+ text = readFileSync(join(dir, entry.name, "SKILL.md"), "utf8");
44
+ } catch {
45
+ continue;
46
+ }
47
+ const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text.replace(/\r\n/g, "\n"));
48
+
49
+ if (!frontmatter) continue;
50
+ const name = v.safeParse(
51
+ SkillNameSchema,
52
+ frontmatterValue(frontmatter[1]!, "name") ?? entry.name,
53
+ );
54
+
55
+ if (!name.success) continue;
56
+
57
+ items.push({
58
+ name: name.output,
59
+ description: frontmatterValue(frontmatter[1]!, "description") ?? "",
60
+ body: "",
61
+ });
62
+ }
63
+
64
+ return items;
65
+ }
66
+
67
+ /** Skills reach the composer palette through context, so no surface needs a new prop to list them. */
68
+ export const SlashSkillsContext = createContext<SlashItem[]>([]);
69
+
70
+ /**
71
+ * The action and skill names every composer paints as "/name" references. Held in
72
+ * context so each surface - the plan thread, the diff sheet, the prototype - reads
73
+ * the same palette without threading a prop.
74
+ */
75
+ export const PaletteNamesContext = createContext<ReadonlySet<string>>(new Set());
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ activeSlashToken,
4
+ insertSlashItem,
5
+ isStandaloneSlashQuery,
6
+ scoreMatch,
7
+ slashFilter,
8
+ type SlashItem,
9
+ } from "./slash-palette";
10
+
11
+ function item(name: string): SlashItem {
12
+ return { name, description: name, body: name };
13
+ }
14
+
15
+ const ITEMS: SlashItem[] = [
16
+ item("zoom-out-research-in-depth"),
17
+ item("restate-simplified"),
18
+ item("out-of-scope"),
19
+ item("lgtm"),
20
+ ];
21
+
22
+ describe("scoreMatch", () => {
23
+ test("an exact name scores highest, then a prefix, then a subsequence", () => {
24
+ const exact = scoreMatch("lgtm", "lgtm")!;
25
+ const prefix = scoreMatch("lgtm-please", "lgtm")!;
26
+ const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
27
+
28
+ expect(exact).toBeGreaterThan(prefix);
29
+ expect(prefix).toBeGreaterThan(subsequence);
30
+ });
31
+
32
+ test("a query whose characters are not all present, in order, does not match", () => {
33
+ expect(scoreMatch("restate", "zzz")).toBeNull();
34
+ expect(scoreMatch("abc", "acb")).toBeNull();
35
+ });
36
+
37
+ test("an empty query matches everything", () => {
38
+ expect(scoreMatch("anything", "")).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe("slashFilter", () => {
43
+ test("ranks a prefix match above a mere subsequence match", () => {
44
+ const ranked = slashFilter(ITEMS, "out");
45
+
46
+ expect(ranked[0]!.name).toBe("out-of-scope");
47
+ expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
48
+ });
49
+
50
+ test("drops items that do not match and returns all on an empty query", () => {
51
+ expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
52
+ expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
53
+ });
54
+ });
55
+
56
+ describe("activeSlashToken", () => {
57
+ const atEnd = (text: string) => activeSlashToken(text, text.length);
58
+
59
+ test("finds the caret's /word at the start or after a space, so each new / reopens the palette", () => {
60
+ expect(atEnd("/vitest-patt")).toBe("/vitest-patt");
61
+ expect(atEnd("use /vitest-patt")).toBe("/vitest-patt");
62
+ expect(atEnd("/zoom-out hello world /")).toBe("/");
63
+ // an underscore is a valid skill-name character, so the token spans it
64
+ expect(atEnd("run /my_skill")).toBe("/my_skill");
65
+ });
66
+
67
+ test("closes on the space that ends the token, and ignores prose", () => {
68
+ expect(atEnd("/zoom-out ")).toBeNull();
69
+ expect(atEnd("just some prose")).toBeNull();
70
+ });
71
+
72
+ test("tracks the caret, not the string end: a slash mid-draft is the active token", () => {
73
+ // caret sits right after "/imp" in "/imp done", ignoring the trailing text
74
+ expect(activeSlashToken("/imp done", 4)).toBe("/imp");
75
+ // caret inside plain prose, even with a later slash token, has no active token
76
+ expect(activeSlashToken("plain text /later", 5)).toBeNull();
77
+ });
78
+ });
79
+
80
+ describe("isStandaloneSlashQuery", () => {
81
+ test("true only when the whole draft is a bare /query, so prose ending in /name still saves", () => {
82
+ expect(isStandaloneSlashQuery("/lgtm")).toBe(true);
83
+ expect(isStandaloneSlashQuery(" /lgtm ")).toBe(true);
84
+ expect(isStandaloneSlashQuery("please inspect /tmp")).toBe(false);
85
+ expect(isStandaloneSlashQuery("just prose")).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe("insertSlashItem", () => {
90
+ test("replaces the active slash token with the reference and lands the caret after it", () => {
91
+ const result = insertSlashItem("look at /rev", 12, "review");
92
+
93
+ expect(result.text).toBe("look at /review ");
94
+ expect(result.caret).toBe("look at /review ".length);
95
+ });
96
+
97
+ test("keeps text after the caret intact", () => {
98
+ const result = insertSlashItem("/pl tail", 3, "plan");
99
+
100
+ expect(result.text).toBe("/plan tail");
101
+ expect(result.caret).toBe("/plan ".length);
102
+ });
103
+ });