@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1039 -799
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +461 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +286 -0
  41. package/src/components/AppShell.zoom.test.tsx +94 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +151 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
package/src/share.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Publish a review session 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 { removalEntries, viewFollowing, type Annotation, type Thread } from "@cueloop/schema";
16
+ import { ThreadRecordSchema } from "@cueloop/daemon/validate";
17
+ import type { SharedMerge } from "@cueloop/daemon/client";
18
+ import * as v from "valibot";
19
+ import { copyToClipboard } from "./clipboard";
20
+
21
+ export interface ShareTarget {
22
+ host?: string;
23
+ port?: number;
24
+ }
25
+
26
+ export interface ShareResult {
27
+ /** The one line to paste: `ssh p_xxxxxxxx@host`. */
28
+ line: string;
29
+ /** Whether the line made it onto the system clipboard. */
30
+ copied: boolean;
31
+ }
32
+
33
+ /** The `p_…` share id inside a `ssh p_…@host` line, or undefined. */
34
+ export function shareIdFromLine(line: string): string | undefined {
35
+ return line.match(/^ssh (\S+)@/)?.[1];
36
+ }
37
+
38
+ /** Upload the session to the gateway and copy the resulting ssh line. */
39
+ export async function publishShare(
40
+ session: Thread,
41
+ target: ShareTarget = {},
42
+ ): Promise<ShareResult> {
43
+ const { stdout, stderr, code } = await runShareSsh(
44
+ "cueloop-share",
45
+ // the share follows one branch: collaborators see its path wherever the owner stands
46
+ packSessionBlob(viewFollowing(session)),
47
+ target,
48
+ );
49
+
50
+ if (code !== 0)
51
+ throw new Error(`gateway upload failed: ${stderr.trim() || `ssh exited ${code}`}`);
52
+ const line = stdout.trim();
53
+
54
+ if (!line.startsWith("ssh ")) throw new Error(`unexpected gateway reply: ${line || "(empty)"}`);
55
+ const copied = await copyToClipboard(line);
56
+
57
+ return { line, copied };
58
+ }
59
+
60
+ /**
61
+ * Pull a share's current session back from the gateway. The gateway lets only
62
+ * the fingerprint that uploaded it through, so collaborator notes reach the
63
+ * planner without exposing the master key.
64
+ */
65
+ export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<Thread> {
66
+ const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
67
+
68
+ if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
69
+
70
+ return v.parse(ThreadRecordSchema, JSON.parse(stdout));
71
+ }
72
+
73
+ /** A share's collaborator notes: the ones a viewer authored (author stamped). */
74
+ export function collaboratorAnnotations(session: Thread): Annotation[] {
75
+ return session.annotations.filter((annotation) => annotation.author);
76
+ }
77
+
78
+ /**
79
+ * What a pulled share hands the local merge: collaborators' notes, the
80
+ * participant registry, and the removals the share recorded, by entry id.
81
+ */
82
+ export function mergeFromShare(remote: Thread): SharedMerge {
83
+ const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
84
+
85
+ if (remote.participants) merge.participants = remote.participants;
86
+ if (remote.history) {
87
+ merge.removals = removalEntries(remote.history).map((entry) => ({
88
+ id: entry.id,
89
+ annotationId: entry.annotationId,
90
+ createdAt: entry.createdAt,
91
+ }));
92
+ }
93
+
94
+ return merge;
95
+ }
96
+
97
+ /**
98
+ * Mirror the planner's own annotations up into the share, so collaborators see
99
+ * them on their next refresh. Owner-gated at the gateway; the notes stay
100
+ * unauthored (the planner's), unioned by id.
101
+ */
102
+ export async function pushShare(
103
+ shareId: string,
104
+ annotations: Array<Omit<Annotation, "createdAt">>,
105
+ target: ShareTarget = {},
106
+ ): Promise<void> {
107
+ const { stderr, code } = await runShareSsh(
108
+ "cueloop-push",
109
+ Buffer.from(JSON.stringify({ shareId, annotations })),
110
+ target,
111
+ );
112
+
113
+ if (code !== 0) throw new Error(`gateway push failed: ${stderr.trim() || `ssh exited ${code}`}`);
114
+ }
115
+
116
+ export interface ShareWatchHandlers {
117
+ /** The whole session record, each time the share changes. */
118
+ onSession: (session: Thread) => void;
119
+ /** The stream ended, for any reason; the caller decides whether to reconnect. */
120
+ onClose: (reason: string) => void;
121
+ }
122
+
123
+ const WatchFrameSchema = v.variant("type", [
124
+ v.object({ type: v.literal("ready") }),
125
+ v.object({ type: v.literal("ping") }),
126
+ v.object({ type: v.literal("session"), session: ThreadRecordSchema }),
127
+ ]);
128
+
129
+ /**
130
+ * Follow a share live: one long-lived `cueloop-watch` ssh stream that carries
131
+ * a JSON line per change. Owner-gated at the gateway. Returns a stop handle.
132
+ */
133
+ export function watchShare(
134
+ shareId: string,
135
+ handlers: ShareWatchHandlers,
136
+ target: ShareTarget = {},
137
+ ): () => void {
138
+ const proc = Bun.spawn(shareSshCommand("cueloop-watch", target), {
139
+ stdin: "pipe",
140
+ stdout: "pipe",
141
+ stderr: "pipe",
142
+ });
143
+
144
+ proc.stdin.write(shareId);
145
+ proc.stdin.end();
146
+ void readLines(proc.stdout, (line) => {
147
+ const frame = v.safeParse(WatchFrameSchema, JSON.parse(line));
148
+
149
+ if (frame.success && frame.output.type === "session") handlers.onSession(frame.output.session);
150
+ }).catch(() => {});
151
+ void Promise.all([proc.exited, new Response(proc.stderr).text()]).then(([code, stderr]) =>
152
+ handlers.onClose(stderr.trim() || `ssh exited ${code}`),
153
+ );
154
+
155
+ return () => proc.kill();
156
+ }
157
+
158
+ /** Feed each complete line of a byte stream to `onLine`; a torn line is dropped. */
159
+ async function readLines(
160
+ stream: ReadableStream<Uint8Array>,
161
+ onLine: (line: string) => void,
162
+ ): Promise<void> {
163
+ const decoder = new TextDecoder();
164
+ let pending = "";
165
+
166
+ for await (const chunk of stream) {
167
+ pending += decoder.decode(chunk, { stream: true });
168
+ const lines = pending.split("\n");
169
+
170
+ pending = lines.pop() ?? "";
171
+ for (const line of lines) {
172
+ if (line.trim() === "") continue;
173
+ try {
174
+ onLine(line);
175
+ } catch {
176
+ // one bad line never ends the stream
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ function shareSshCommand(command: string, target: ShareTarget): string[] {
183
+ const host = target.host ?? DEFAULT_SHARE_HOST;
184
+ const port = target.port ?? DEFAULT_SHARE_PORT;
185
+
186
+ return [
187
+ "ssh",
188
+ "-p",
189
+ String(port),
190
+ "-o",
191
+ "StrictHostKeyChecking=accept-new",
192
+ `${SHARE_UPLOAD_USER}@${host}`,
193
+ command,
194
+ ];
195
+ }
196
+
197
+ /** Run one `share@host:port` ssh command with `stdin`; hand back its streams and exit code. */
198
+ async function runShareSsh(
199
+ command: string,
200
+ stdin: Buffer,
201
+ target: ShareTarget,
202
+ ): Promise<{ stdout: string; stderr: string; code: number }> {
203
+ const proc = Bun.spawn(shareSshCommand(command, target), {
204
+ stdin,
205
+ stdout: "pipe",
206
+ stderr: "pipe",
207
+ });
208
+ const [stdout, stderr, code] = await Promise.all([
209
+ new Response(proc.stdout).text(),
210
+ new Response(proc.stderr).text(),
211
+ proc.exited,
212
+ ]);
213
+
214
+ return { stdout, stderr, code };
215
+ }
@@ -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,68 @@
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[]>([]);
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { inlineSlashToken, scoreMatch, slashFilter, type SlashItem } from "./slash-palette";
3
+
4
+ function item(name: string): SlashItem {
5
+ return { name, description: name, body: name };
6
+ }
7
+
8
+ const ITEMS: SlashItem[] = [
9
+ item("zoom-out-research-in-depth"),
10
+ item("restate-simplified"),
11
+ item("out-of-scope"),
12
+ item("lgtm"),
13
+ ];
14
+
15
+ describe("scoreMatch", () => {
16
+ test("an exact name scores highest, then a prefix, then a subsequence", () => {
17
+ const exact = scoreMatch("lgtm", "lgtm")!;
18
+ const prefix = scoreMatch("lgtm-please", "lgtm")!;
19
+ const subsequence = scoreMatch("looking-good-to-me", "lgtm")!;
20
+
21
+ expect(exact).toBeGreaterThan(prefix);
22
+ expect(prefix).toBeGreaterThan(subsequence);
23
+ });
24
+
25
+ test("a query whose characters are not all present, in order, does not match", () => {
26
+ expect(scoreMatch("restate", "zzz")).toBeNull();
27
+ expect(scoreMatch("abc", "acb")).toBeNull();
28
+ });
29
+
30
+ test("an empty query matches everything", () => {
31
+ expect(scoreMatch("anything", "")).toBe(0);
32
+ });
33
+ });
34
+
35
+ describe("slashFilter", () => {
36
+ test("ranks a prefix match above a mere subsequence match", () => {
37
+ const ranked = slashFilter(ITEMS, "out");
38
+
39
+ expect(ranked[0]!.name).toBe("out-of-scope");
40
+ expect(ranked.map((entry) => entry.name)).toContain("zoom-out-research-in-depth");
41
+ });
42
+
43
+ test("drops items that do not match and returns all on an empty query", () => {
44
+ expect(slashFilter(ITEMS, "zzzz")).toEqual([]);
45
+ expect(slashFilter(ITEMS, "")).toHaveLength(ITEMS.length);
46
+ });
47
+ });
48
+
49
+ describe("inlineSlashToken", () => {
50
+ test("finds a trailing /word only when text precedes it", () => {
51
+ expect(inlineSlashToken("use /vitest-patt")).toBe("/vitest-patt");
52
+ expect(inlineSlashToken("/vitest-patt")).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The "/" quick-action palette behind a composer: the skill list a leading
3
+ * slash opens, its prefix > substring > subsequence filter, and the inline
4
+ * "/word" completion offered mid-sentence. Pure; the surfaces render it.
5
+ */
6
+
7
+ import { quickActionBody, type QuickAction } from "./config";
8
+
9
+ export interface SlashItem {
10
+ name: string;
11
+ description: string;
12
+ body: string;
13
+ }
14
+
15
+ export function slashItemsFrom(quickActions: QuickAction[]): SlashItem[] {
16
+ return quickActions.map((action) => ({
17
+ name: action.prompt
18
+ .toLowerCase()
19
+ .replace(/[^a-z0-9]+/g, "-")
20
+ .replace(/^-|-$/g, ""),
21
+ description: action.metadata ?? action.prompt,
22
+ body: quickActionBody(action),
23
+ }));
24
+ }
25
+
26
+ const WORD_BOUNDARY = /[\s\-_./:]/;
27
+
28
+ /** Order-preserving subsequence score, or null when a query character is missing; higher is better. */
29
+ export function scoreMatch(name: string, query: string): number | null {
30
+ if (query.length === 0) return 0;
31
+ const lowerName = name.toLowerCase();
32
+ const lowerQuery = query.toLowerCase();
33
+
34
+ if (lowerName === lowerQuery) return 1000;
35
+ if (lowerName.startsWith(lowerQuery)) return 500 - name.length;
36
+ let score = 0;
37
+ let cursor = 0;
38
+ let run = 0;
39
+
40
+ for (const character of lowerQuery) {
41
+ const found = lowerName.indexOf(character, cursor);
42
+
43
+ if (found === -1) return null;
44
+ run = found === cursor ? run + 1 : 0;
45
+ score += found === cursor ? 5 + run : 1;
46
+ if (found === 0 || WORD_BOUNDARY.test(lowerName[found - 1]!)) score += 10;
47
+ score -= found - cursor;
48
+ cursor = found + 1;
49
+ }
50
+
51
+ return score;
52
+ }
53
+
54
+ export function slashFilter(items: SlashItem[], query: string): SlashItem[] {
55
+ if (query.length === 0) return items;
56
+ const scored: Array<{ item: SlashItem; score: number }> = [];
57
+
58
+ for (const item of items) {
59
+ const score = scoreMatch(item.name, query);
60
+
61
+ if (score !== null) scored.push({ item, score });
62
+ }
63
+
64
+ return scored.toSorted((left, right) => right.score - left.score).map((entry) => entry.item);
65
+ }
66
+
67
+ /**
68
+ * A skill invoked mid-sentence: the trailing "/word" token when text already
69
+ * precedes it (a draft that starts with "/" is the palette, not an inline
70
+ * completion). Newline-safe, since the token may sit at the start of a new line.
71
+ */
72
+ export function inlineSlashToken(text: string): string | null {
73
+ if (text.startsWith("/")) return null;
74
+ const match = /(?:^|\s)(\/[a-zA-Z0-9-]*)$/.exec(text);
75
+
76
+ return match ? match[1]! : null;
77
+ }
78
+
79
+ export interface InlineSlash {
80
+ token: string;
81
+ suggestion: SlashItem;
82
+ }
83
+
84
+ /** The inline completion state: the trailing "/word" and its closest item, or null. */
85
+ export function resolveInlineSuggestion(
86
+ slashActive: boolean,
87
+ text: string,
88
+ items: SlashItem[],
89
+ ): InlineSlash | null {
90
+ if (slashActive) return null;
91
+ const token = inlineSlashToken(text);
92
+
93
+ if (token === null) return null;
94
+ const suggestion = slashFilter(items, token.slice(1))[0];
95
+
96
+ return suggestion ? { token, suggestion } : null;
97
+ }
98
+
99
+ /** Quick actions and user skills in one palette; a quick action wins a name collision (it expands). */
100
+ export function mergeSlashItems(actions: SlashItem[], skills: SlashItem[]): SlashItem[] {
101
+ const taken = new Set(actions.map((action) => action.name));
102
+
103
+ return [...actions, ...skills.filter((skill) => !taken.has(skill.name))];
104
+ }
@@ -0,0 +1,110 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { diffRows } from "./view-diff";
3
+ import { splitDiffRows, splitRowOffsets, type SplitRow } from "./split-diff";
4
+
5
+ const PATCH = `diff --git a/greet.ts b/greet.ts
6
+ --- a/greet.ts
7
+ +++ b/greet.ts
8
+ @@ -1,4 +1,4 @@
9
+ const name = "world";
10
+ -console.log("hi " + name);
11
+ -console.log("done");
12
+ +console.log("hello " + name);
13
+ +console.log("finished");
14
+ export {};
15
+ `;
16
+
17
+ function pairs(rows: SplitRow[]): SplitRow[] {
18
+ return rows.filter((row) => row.kind === "pair");
19
+ }
20
+
21
+ describe("splitDiffRows", () => {
22
+ test("carries the file and hunk headers through unchanged", () => {
23
+ // Act
24
+ const split = splitDiffRows(diffRows(PATCH));
25
+
26
+ // Assert
27
+ expect(split[0]).toMatchObject({ kind: "file", file: "greet.ts", text: "greet.ts" });
28
+ expect(split[1]?.kind).toBe("hunk");
29
+ });
30
+
31
+ test("a context line sits on both sides with its own line numbers", () => {
32
+ // Act
33
+ const first = pairs(splitDiffRows(diffRows(PATCH)))[0]!;
34
+
35
+ // Assert
36
+ expect(first.left).toMatchObject({ kind: "ctx", lineNumber: 1 });
37
+ expect(first.right).toMatchObject({ kind: "ctx", lineNumber: 1 });
38
+ expect(first.left?.text).toBe(first.right?.text);
39
+ });
40
+
41
+ test("a balanced change block pairs each deletion against an addition", () => {
42
+ // Act - the two deletions zip against the two additions, one per row
43
+ const changed = pairs(splitDiffRows(diffRows(PATCH))).filter(
44
+ (row) => row.left?.kind === "del" || row.right?.kind === "add",
45
+ );
46
+
47
+ // Assert
48
+ expect(changed).toHaveLength(2);
49
+ expect(changed[0]).toMatchObject({
50
+ left: { kind: "del", lineNumber: 2 },
51
+ right: { kind: "add", lineNumber: 2 },
52
+ });
53
+ expect(changed[1]).toMatchObject({
54
+ left: { kind: "del", lineNumber: 3 },
55
+ right: { kind: "add", lineNumber: 3 },
56
+ });
57
+ });
58
+
59
+ test("an unbalanced block blank-fills the shorter side", () => {
60
+ // Arrange - one deletion, three additions
61
+ const patch = `diff --git a/x.ts b/x.ts
62
+ --- a/x.ts
63
+ +++ b/x.ts
64
+ @@ -1,2 +1,4 @@
65
+ keep;
66
+ -old;
67
+ +new1;
68
+ +new2;
69
+ +new3;
70
+ `;
71
+
72
+ // Act
73
+ const changed = pairs(splitDiffRows(diffRows(patch))).filter(
74
+ (row) => row.left?.kind === "del" || row.right?.kind === "add",
75
+ );
76
+
77
+ // Assert - three rows tall, only the first has a left deletion
78
+ expect(changed).toHaveLength(3);
79
+ expect(changed[0]?.left?.kind).toBe("del");
80
+ expect(changed[1]?.left).toBeUndefined();
81
+ expect(changed[2]?.left).toBeUndefined();
82
+ expect(changed.map((row) => row.right?.text.trim())).toEqual(["new1;", "new2;", "new3;"]);
83
+ });
84
+
85
+ test("each side keeps its originating unified row for anchoring", () => {
86
+ // Act
87
+ const base = diffRows(PATCH);
88
+ const changed = pairs(splitDiffRows(base)).find((row) => row.right?.kind === "add")!;
89
+
90
+ // Assert - the split line points back at a row from the base list
91
+ expect(base[changed.right!.rowIndex]).toBe(changed.right!.row);
92
+ });
93
+ });
94
+
95
+ describe("splitRowOffsets", () => {
96
+ test("maps both sides of a pair to the same y and steps a file band by three", () => {
97
+ // Arrange
98
+ const base = diffRows(PATCH);
99
+ const split = splitDiffRows(base);
100
+
101
+ // Act
102
+ const offsets = splitRowOffsets(split);
103
+
104
+ // Assert - the file band is three rows and the hunk one, so the first context pair sits at y 4
105
+ const firstContext = split.find((row) => row.kind === "pair" && row.left?.kind === "ctx")!;
106
+
107
+ expect(offsets.get(firstContext.left!.rowIndex)).toBe(4);
108
+ expect(offsets.get(firstContext.right!.rowIndex)).toBe(4);
109
+ });
110
+ });