@cueloop/client 0.1.0-alpha.69 → 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 (53) hide show
  1. package/package.json +5 -5
  2. package/src/App.appearance.test.tsx +2 -2
  3. package/src/App.autoclose.test.tsx +2 -2
  4. package/src/App.diff.test.tsx +2 -2
  5. package/src/App.export.test.tsx +2 -2
  6. package/src/App.plan-review.test.tsx +2 -2
  7. package/src/App.prototype-markdown.test.tsx +2 -2
  8. package/src/App.prototype.test.tsx +2 -2
  9. package/src/App.readonly.test.tsx +2 -2
  10. package/src/App.rendering.test.tsx +2 -2
  11. package/src/App.submit-confirm.test.tsx +2 -2
  12. package/src/App.test.tsx +2 -2
  13. package/src/App.toast.test.tsx +3 -3
  14. package/src/App.tsx +240 -228
  15. package/src/App.walk.test.tsx +2 -2
  16. package/src/App.workbench.test.tsx +2 -2
  17. package/src/app-key-state.ts +3 -3
  18. package/src/app-screens.tsx +4 -4
  19. package/src/app-view-model.ts +6 -6
  20. package/src/components/AnnotatableFileView.stories.tsx +2 -2
  21. package/src/components/AnnotatableFileView.test.tsx +2 -2
  22. package/src/components/AnnotatableFileView.tsx +2 -2
  23. package/src/components/BareWorkbenchFileView.tsx +2 -7
  24. package/src/components/ChangesColumn.tsx +2 -2
  25. package/src/components/DiffContentView.tsx +2 -2
  26. package/src/components/ThreadView.selection.test.tsx +5 -5
  27. package/src/components/ThreadView.slash.test.tsx +49 -4
  28. package/src/components/ThreadView.tsx +2 -2
  29. package/src/components/WelcomePlayground.tsx +2 -2
  30. package/src/components/__snapshots__/stories.test.tsx.snap +6 -6
  31. package/src/components/agent-launcher.tsx +2 -2
  32. package/src/components/quick-actions-editor.test.tsx +49 -0
  33. package/src/components/quick-actions-editor.tsx +32 -20
  34. package/src/components/session-tree.test.ts +2 -2
  35. package/src/components/session-tree.ts +10 -10
  36. package/src/components/story-fixtures.ts +3 -3
  37. package/src/config.test.ts +30 -1
  38. package/src/config.ts +19 -0
  39. package/src/discussions.ts +2 -2
  40. package/src/intent-dispatch.test.ts +3 -6
  41. package/src/intent-dispatch.ts +6 -6
  42. package/src/serve.test.ts +2 -2
  43. package/src/share.ts +9 -14
  44. package/src/skills.test.ts +74 -0
  45. package/src/skills.ts +68 -0
  46. package/src/slash-palette.ts +10 -3
  47. package/src/{session-controller.curate.test.ts → thread-controller.curate.test.ts} +8 -7
  48. package/src/{session-controller.live-diff.test.ts → thread-controller.live-diff.test.ts} +7 -6
  49. package/src/{session-controller.share.test.ts → thread-controller.share.test.ts} +14 -13
  50. package/src/{session-controller.tree.test.ts → thread-controller.tree.test.ts} +8 -7
  51. package/src/{session-controller.ts → thread-controller.ts} +18 -22
  52. package/src/use-annotation-surface.tsx +14 -10
  53. package/src/view-diff.ts +2 -2
@@ -142,6 +142,25 @@ describe("loadConfig", () => {
142
142
  }
143
143
  });
144
144
 
145
+ test("[skills] path overrides the default and a malformed value never discards the config", () => {
146
+ const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-skills-"));
147
+ const path = join(dir, "config.toml");
148
+
149
+ try {
150
+ writeFileSync(path, `[skills]\npath = "/custom/skills"\n`);
151
+ expect(loadConfig({ userConfigPath: path }).skillsPath).toBe("/custom/skills");
152
+
153
+ // a wrong-typed path falls back to the default without dropping the sibling [keys] setting
154
+ writeFileSync(path, `[skills]\npath = 123\n\n[keys]\ncomment = "z"\n`);
155
+ const config = loadConfig({ userConfigPath: path });
156
+
157
+ expect(config.keys["comment"]).toEqual(["z"]);
158
+ expect(config.skillsPath).toContain(".agents");
159
+ } finally {
160
+ rmSync(dir, { recursive: true, force: true });
161
+ }
162
+ });
163
+
145
164
  test('[ui] diff_view loads the pre-rename "unified" value as "stacked"', () => {
146
165
  // Arrange - an upgrade must not flip a user who had persisted the old spelling
147
166
  const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-diff-"));
@@ -372,7 +391,7 @@ describe("quick actions ([[actions]])", () => {
372
391
 
373
392
  // Assert
374
393
  expect(config.actions).toEqual(DEFAULT_QUICK_ACTIONS);
375
- expect(config.actions).toHaveLength(7);
394
+ expect(config.actions).toHaveLength(8);
376
395
  });
377
396
 
378
397
  test("configured actions replace the defaults, with optional metadata", () => {
@@ -515,3 +534,13 @@ describe("[authors] rename map", () => {
515
534
  rmSync(dir, { recursive: true, force: true });
516
535
  });
517
536
  });
537
+
538
+ describe("the /lgtm default quick action", () => {
539
+ test("expands to a terse approving note", () => {
540
+ const lgtm = DEFAULT_QUICK_ACTIONS.find((action) => action.prompt.toLowerCase() === "lgtm");
541
+
542
+ expect(lgtm).toBeDefined();
543
+ // the reference is /lgtm, so the label is the prompt; it expands like every action (label, then note)
544
+ expect(quickActionBody(lgtm!)).toBe("LGTM\n\nThis looks good to me.");
545
+ });
546
+ });
package/src/config.ts CHANGED
@@ -70,6 +70,10 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
70
70
  metadata:
71
71
  "Follow the nearest existing pattern in this codebase rather than introducing a new one.",
72
72
  },
73
+ {
74
+ prompt: "LGTM",
75
+ metadata: "This looks good to me.",
76
+ },
73
77
  ];
74
78
 
75
79
  /** The comment body a quick action expands to: the prompt, then its system prompt when set. */
@@ -109,6 +113,8 @@ export interface CueloopConfig {
109
113
  authors: Record<string, string>;
110
114
  /** Marker-popover quick actions ([[actions]] tables); the 5 defaults when unset. */
111
115
  actions: QuickAction[];
116
+ /** Directory of user-level skills surfaced in the "/" palette ([skills] path); ~/.agents/skills default. */
117
+ skillsPath: string;
112
118
  integrations: IntegrationsConfig;
113
119
  /** Opt-in experimental features ([experimental] table); all default off. */
114
120
  experimental: ExperimentalConfig;
@@ -144,6 +150,15 @@ export const DEFAULT_KEYS: CueloopConfig["keys"] = {
144
150
  walk: ["w"],
145
151
  };
146
152
 
153
+ const SkillsSectionSchema = v.object({ path: v.optional(v.string()) });
154
+
155
+ function skillsPathFrom(
156
+ raw: v.InferOutput<typeof SkillsSectionSchema> | undefined,
157
+ fallback: string,
158
+ ): string {
159
+ return raw?.path?.trim() ? raw.path.trim() : fallback;
160
+ }
161
+
147
162
  const ConfigDocumentSchema = v.object({
148
163
  actions: v.optional(v.array(v.unknown())),
149
164
  authors: v.optional(v.unknown()),
@@ -152,6 +167,7 @@ const ConfigDocumentSchema = v.object({
152
167
  theme: v.optional(v.unknown()),
153
168
  ui: v.optional(v.unknown()),
154
169
  experimental: v.optional(v.unknown()),
170
+ skills: v.fallback(v.optional(SkillsSectionSchema), undefined),
155
171
  });
156
172
 
157
173
  const QuickActionSchema = v.object({
@@ -236,6 +252,7 @@ function layer(
236
252
  ui: { ...base.ui },
237
253
  authors: { ...base.authors },
238
254
  actions: [...base.actions],
255
+ skillsPath: base.skillsPath,
239
256
  integrations: { obsidian: { ...base.integrations.obsidian } },
240
257
  experimental: { ...base.experimental },
241
258
  };
@@ -247,6 +264,7 @@ function layer(
247
264
  const experimental = v.safeParse(ExperimentalSchema, raw.experimental);
248
265
 
249
266
  if (actions) out.actions = actions;
267
+ out.skillsPath = skillsPathFrom(raw.skills, base.skillsPath);
250
268
  if (authors.success) {
251
269
  for (const [id, value] of Object.entries(authors.output)) {
252
270
  const name = v.safeParse(v.string(), value);
@@ -295,6 +313,7 @@ export function loadConfig(
295
313
  },
296
314
  authors: {},
297
315
  actions: [...DEFAULT_QUICK_ACTIONS],
316
+ skillsPath: join(homedir(), ".agents", "skills"),
298
317
  integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
299
318
  experimental: { prototypePixels: false },
300
319
  };
@@ -5,7 +5,7 @@
5
5
  * plan thread view and the diff sheet group the same way.
6
6
  */
7
7
 
8
- import type { Annotation, ReviewSession } from "@cueloop/schema";
8
+ import type { Annotation, Thread } from "@cueloop/schema";
9
9
  import type { Mark } from "./view-plan";
10
10
  import { comparePositions, type TextSpan } from "./thread-selection";
11
11
 
@@ -29,7 +29,7 @@ export function spanKey(span: TextSpan): string {
29
29
  * read as one conversation); replies join the discussion their replyTo names, or
30
30
  * stand as roots when that root is gone.
31
31
  */
32
- export function discussionsFrom(session: ReviewSession, marks: Map<number, Mark[]>): Discussion[] {
32
+ export function discussionsFrom(session: Thread, marks: Map<number, Mark[]>): Discussion[] {
33
33
  const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
34
34
  const spanOf = new Map<string, TextSpan>();
35
35
 
@@ -7,9 +7,9 @@
7
7
 
8
8
  import { describe, expect, mock, test } from "bun:test";
9
9
  import type { SetStateAction } from "react";
10
- import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
10
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
11
11
  import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
12
- import type { ControllerSnapshot, CurationItem, ReviewController } from "./session-controller";
12
+ import type { ControllerSnapshot, CurationItem, ReviewController } from "./thread-controller";
13
13
  import type { DisplayBlock } from "./view-plan";
14
14
  import type { TreeRow } from "./tree-view";
15
15
 
@@ -28,10 +28,7 @@ function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation
28
28
  };
29
29
  }
30
30
 
31
- function sessionWith(
32
- annotations: Annotation[],
33
- overrides: Partial<ReviewSession> = {},
34
- ): ReviewSession {
31
+ function sessionWith(annotations: Annotation[], overrides: Partial<Thread> = {}): Thread {
35
32
  return {
36
33
  schemaVersion: SCHEMA_VERSION,
37
34
  id: "ses_1",
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { Dispatch, MutableRefObject, SetStateAction } from "react";
11
- import { isAddressed, isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
11
+ import { isAddressed, isAgentNote, type Thread, type VerdictKind } from "@cueloop/schema";
12
12
  import { displayText, spanKey, startSpan, type DisplayBlock, type SpanState } from "./view-plan";
13
13
  import type { DiffRow } from "./view-diff";
14
- import type { ReviewController } from "./session-controller";
14
+ import type { ReviewController } from "./thread-controller";
15
15
  import type { Intent } from "./keymap";
16
16
  import type { TreeRow } from "./tree-view";
17
17
  import { quickActionBody, type QuickAction } from "./config";
@@ -54,13 +54,13 @@ export function activeSpanState(mode: Mode): SpanState | null {
54
54
  * annotation a revision already addressed is settled - it neither blocks the
55
55
  * verdict default nor re-enters the next feedback document.
56
56
  */
57
- export function reviewerAnnotations(session: ReviewSession) {
57
+ export function reviewerAnnotations(session: Thread) {
58
58
  return session.annotations.filter(
59
59
  (annotation) => !isAgentNote(annotation) && !isAddressed(annotation),
60
60
  );
61
61
  }
62
62
 
63
- export function defaultVerdict(session: ReviewSession): VerdictKind {
63
+ export function defaultVerdict(session: Thread): VerdictKind {
64
64
  return reviewerAnnotations(session).length || session.workingCopy !== undefined
65
65
  ? "request_changes"
66
66
  : "approve";
@@ -80,10 +80,10 @@ export interface IntentDispatchDeps {
80
80
  display: DisplayBlock[];
81
81
  rows: DiffRow[];
82
82
  cursor: number;
83
- inbox: ReviewSession[] | null;
83
+ inbox: Thread[] | null;
84
84
  inboxCursor: number;
85
85
  mode: Mode;
86
- session: ReviewSession | null;
86
+ session: Thread | null;
87
87
  focusedAnnotationId: string | undefined;
88
88
  /** The curation item selected for undo, if any. */
89
89
  selectedCurationId: string | undefined;
package/src/serve.test.ts CHANGED
@@ -10,14 +10,14 @@ import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { Client } from "ssh2";
12
12
  import { DaemonServer } from "@cueloop/daemon";
13
- import type { ReviewSession } from "@cueloop/schema";
13
+ import type { Thread } from "@cueloop/schema";
14
14
  import { serveClient, type ServeHandle } from "./serve";
15
15
 
16
16
  const PLAN = "# Rollout Plan\n\nShip the store move behind a flag.\n";
17
17
 
18
18
  let home: string;
19
19
  let daemon: DaemonServer;
20
- let session: ReviewSession;
20
+ let session: Thread;
21
21
  let handle: ServeHandle;
22
22
 
23
23
  beforeEach(async () => {
package/src/share.ts CHANGED
@@ -12,13 +12,8 @@ import {
12
12
  SHARE_UPLOAD_USER,
13
13
  packSessionBlob,
14
14
  } from "@cueloop/daemon/share-blob";
15
- import {
16
- removalEntries,
17
- viewFollowing,
18
- type Annotation,
19
- type ReviewSession,
20
- } from "@cueloop/schema";
21
- import { SessionRecordSchema } from "@cueloop/daemon/validate";
15
+ import { removalEntries, viewFollowing, type Annotation, type Thread } from "@cueloop/schema";
16
+ import { ThreadRecordSchema } from "@cueloop/daemon/validate";
22
17
  import type { SharedMerge } from "@cueloop/daemon/client";
23
18
  import * as v from "valibot";
24
19
  import { copyToClipboard } from "./clipboard";
@@ -42,7 +37,7 @@ export function shareIdFromLine(line: string): string | undefined {
42
37
 
43
38
  /** Upload the session to the gateway and copy the resulting ssh line. */
44
39
  export async function publishShare(
45
- session: ReviewSession,
40
+ session: Thread,
46
41
  target: ShareTarget = {},
47
42
  ): Promise<ShareResult> {
48
43
  const { stdout, stderr, code } = await runShareSsh(
@@ -67,16 +62,16 @@ export async function publishShare(
67
62
  * the fingerprint that uploaded it through, so collaborator notes reach the
68
63
  * planner without exposing the master key.
69
64
  */
70
- export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<ReviewSession> {
65
+ export async function pullShare(shareId: string, target: ShareTarget = {}): Promise<Thread> {
71
66
  const { stdout, stderr, code } = await runShareSsh("cueloop-pull", Buffer.from(shareId), target);
72
67
 
73
68
  if (code !== 0) throw new Error(`gateway pull failed: ${stderr.trim() || `ssh exited ${code}`}`);
74
69
 
75
- return v.parse(SessionRecordSchema, JSON.parse(stdout));
70
+ return v.parse(ThreadRecordSchema, JSON.parse(stdout));
76
71
  }
77
72
 
78
73
  /** A share's collaborator notes: the ones a viewer authored (author stamped). */
79
- export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
74
+ export function collaboratorAnnotations(session: Thread): Annotation[] {
80
75
  return session.annotations.filter((annotation) => annotation.author);
81
76
  }
82
77
 
@@ -84,7 +79,7 @@ export function collaboratorAnnotations(session: ReviewSession): Annotation[] {
84
79
  * What a pulled share hands the local merge: collaborators' notes, the
85
80
  * participant registry, and the removals the share recorded, by entry id.
86
81
  */
87
- export function mergeFromShare(remote: ReviewSession): SharedMerge {
82
+ export function mergeFromShare(remote: Thread): SharedMerge {
88
83
  const merge: SharedMerge = { annotations: collaboratorAnnotations(remote) };
89
84
 
90
85
  if (remote.participants) merge.participants = remote.participants;
@@ -120,7 +115,7 @@ export async function pushShare(
120
115
 
121
116
  export interface ShareWatchHandlers {
122
117
  /** The whole session record, each time the share changes. */
123
- onSession: (session: ReviewSession) => void;
118
+ onSession: (session: Thread) => void;
124
119
  /** The stream ended, for any reason; the caller decides whether to reconnect. */
125
120
  onClose: (reason: string) => void;
126
121
  }
@@ -128,7 +123,7 @@ export interface ShareWatchHandlers {
128
123
  const WatchFrameSchema = v.variant("type", [
129
124
  v.object({ type: v.literal("ready") }),
130
125
  v.object({ type: v.literal("ping") }),
131
- v.object({ type: v.literal("session"), session: SessionRecordSchema }),
126
+ v.object({ type: v.literal("session"), session: ThreadRecordSchema }),
132
127
  ]);
133
128
 
134
129
  /**
@@ -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[]>([]);
@@ -81,17 +81,24 @@ export interface InlineSlash {
81
81
  suggestion: SlashItem;
82
82
  }
83
83
 
84
- /** The inline completion state: the trailing "/word" and its closest skill, or null. */
84
+ /** The inline completion state: the trailing "/word" and its closest item, or null. */
85
85
  export function resolveInlineSuggestion(
86
86
  slashActive: boolean,
87
87
  text: string,
88
- quickActions: QuickAction[],
88
+ items: SlashItem[],
89
89
  ): InlineSlash | null {
90
90
  if (slashActive) return null;
91
91
  const token = inlineSlashToken(text);
92
92
 
93
93
  if (token === null) return null;
94
- const suggestion = slashFilter(slashItemsFrom(quickActions), token.slice(1))[0];
94
+ const suggestion = slashFilter(items, token.slice(1))[0];
95
95
 
96
96
  return suggestion ? { token, suggestion } : null;
97
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
+ }
@@ -6,11 +6,11 @@ import {
6
6
  SCHEMA_VERSION,
7
7
  type DiffFileContents,
8
8
  type HunkRejection,
9
- type ReviewSession,
9
+ type Thread,
10
10
  } from "@cueloop/schema";
11
11
  import { curateDiff } from "@cueloop/daemon/curate";
12
12
  import type { SessionClient } from "@cueloop/daemon/client";
13
- import { createReviewController } from "./session-controller";
13
+ import { createReviewController } from "./thread-controller";
14
14
 
15
15
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
16
16
  index 111..222 100644
@@ -32,7 +32,7 @@ const FILES: DiffFileContents[] = [
32
32
  },
33
33
  ];
34
34
 
35
- function diffSession(files?: DiffFileContents[]): ReviewSession {
35
+ function diffSession(files?: DiffFileContents[]): Thread {
36
36
  return {
37
37
  schemaVersion: SCHEMA_VERSION,
38
38
  id: "ses_diff",
@@ -54,7 +54,7 @@ const unimplemented = (member: string) => () =>
54
54
  Promise.reject(new Error(`fakeClient does not implement ${member}`));
55
55
 
56
56
  /** A fake client that records the working copy the controller writes. */
57
- function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClient {
57
+ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
58
58
  let session = initial;
59
59
 
60
60
  return {
@@ -62,6 +62,7 @@ function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClien
62
62
  subscribe: async () => {},
63
63
  sessionGet: async () => session,
64
64
  sessionList: async () => [session],
65
+ sessionComment: unimplemented("sessionComment"),
65
66
  sessionAnnotate: unimplemented("sessionAnnotate"),
66
67
  sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
67
68
  sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
@@ -126,7 +127,7 @@ function fakeClient(initial: ReviewSession, sink: WorkingCopySink): SessionClien
126
127
 
127
128
  const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
128
129
 
129
- async function connected(session: ReviewSession) {
130
+ async function connected(session: Thread) {
130
131
  const sink: WorkingCopySink = {};
131
132
  const client = fakeClient(session, sink);
132
133
  const controller = createReviewController({
@@ -302,7 +303,7 @@ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
302
303
  // the working copy with the "Second paragraph." block cut out
303
304
  const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
304
305
 
305
- function planSession(workingCopy?: string): ReviewSession {
306
+ function planSession(workingCopy?: string): Thread {
306
307
  return {
307
308
  schemaVersion: SCHEMA_VERSION,
308
309
  id: "ses_plan",
@@ -371,7 +372,7 @@ const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
371
372
  + private items = new Map();
372
373
  `;
373
374
 
374
- function tailSession(): ReviewSession {
375
+ function tailSession(): Thread {
375
376
  return {
376
377
  ...diffSession(),
377
378
  artifact: {
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  import { describe, expect, mock, test } from "bun:test";
9
- import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
9
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
10
10
  import type { SessionClient } from "@cueloop/daemon/client";
11
- import { createReviewController, type ShareTransport } from "./session-controller";
11
+ import { createReviewController, type ShareTransport } from "./thread-controller";
12
12
  import { mergeFromShare } from "./share";
13
13
 
14
14
  const AT = "2026-01-01T00:00:00.000Z";
@@ -31,7 +31,7 @@ const FILES = [
31
31
  ];
32
32
 
33
33
  const shareTransport: ShareTransport = {
34
- publish: mock(async (session: ReviewSession) => ({
34
+ publish: mock(async (session: Thread) => ({
35
35
  line: `ssh p_${session.id}@cueloop.dev`,
36
36
  copied: true,
37
37
  })),
@@ -43,7 +43,7 @@ const shareTransport: ShareTransport = {
43
43
  mergeFromShare,
44
44
  };
45
45
 
46
- function planSession(id = "ses_plan", repoRoot = "/repo"): ReviewSession {
46
+ function planSession(id = "ses_plan", repoRoot = "/repo"): Thread {
47
47
  return {
48
48
  schemaVersion: SCHEMA_VERSION,
49
49
  id,
@@ -60,12 +60,13 @@ function planSession(id = "ses_plan", repoRoot = "/repo"): ReviewSession {
60
60
  const unimplemented = (member: string) => () =>
61
61
  Promise.reject(new Error(`fakeClient does not implement ${member}`));
62
62
 
63
- function fakeClient(session: ReviewSession): SessionClient {
63
+ function fakeClient(session: Thread): SessionClient {
64
64
  return {
65
65
  onEvent: () => () => {},
66
66
  subscribe: async () => {},
67
67
  sessionGet: async () => session,
68
68
  sessionList: async () => [session],
69
+ sessionComment: unimplemented("sessionComment"),
69
70
  sessionAnnotate: unimplemented("sessionAnnotate"),
70
71
  sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
71
72
  sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
@@ -123,7 +124,7 @@ describe("live working-tree diff for a non-diff thread", () => {
123
124
  let sent: Annotation | undefined;
124
125
  const client = {
125
126
  ...fakeClient(session),
126
- sessionAnnotate: async (_id: string, annotation: Annotation) => {
127
+ sessionComment: async (_id: string, annotation: Annotation) => {
127
128
  sent = annotation;
128
129
 
129
130
  return { ...session, annotations: [{ ...annotation, createdAt: AT }] };