@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.
- package/package.json +5 -5
- package/src/App.appearance.test.tsx +2 -2
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +2 -2
- package/src/App.prototype-markdown.test.tsx +2 -2
- package/src/App.prototype.test.tsx +2 -2
- package/src/App.readonly.test.tsx +2 -2
- package/src/App.rendering.test.tsx +2 -2
- package/src/App.submit-confirm.test.tsx +2 -2
- package/src/App.test.tsx +2 -2
- package/src/App.toast.test.tsx +3 -3
- package/src/App.tsx +240 -228
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +2 -2
- package/src/app-key-state.ts +3 -3
- package/src/app-screens.tsx +4 -4
- package/src/app-view-model.ts +6 -6
- package/src/components/AnnotatableFileView.stories.tsx +2 -2
- package/src/components/AnnotatableFileView.test.tsx +2 -2
- package/src/components/AnnotatableFileView.tsx +2 -2
- package/src/components/BareWorkbenchFileView.tsx +2 -7
- package/src/components/ChangesColumn.tsx +2 -2
- package/src/components/DiffContentView.tsx +2 -2
- package/src/components/ThreadView.selection.test.tsx +5 -5
- package/src/components/ThreadView.slash.test.tsx +49 -4
- package/src/components/ThreadView.tsx +2 -2
- package/src/components/WelcomePlayground.tsx +2 -2
- package/src/components/__snapshots__/stories.test.tsx.snap +6 -6
- package/src/components/agent-launcher.tsx +2 -2
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +32 -20
- package/src/components/session-tree.test.ts +2 -2
- package/src/components/session-tree.ts +10 -10
- package/src/components/story-fixtures.ts +3 -3
- package/src/config.test.ts +30 -1
- package/src/config.ts +19 -0
- package/src/discussions.ts +2 -2
- package/src/intent-dispatch.test.ts +3 -6
- package/src/intent-dispatch.ts +6 -6
- package/src/serve.test.ts +2 -2
- package/src/share.ts +9 -14
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.ts +10 -3
- package/src/{session-controller.curate.test.ts → thread-controller.curate.test.ts} +8 -7
- package/src/{session-controller.live-diff.test.ts → thread-controller.live-diff.test.ts} +7 -6
- package/src/{session-controller.share.test.ts → thread-controller.share.test.ts} +14 -13
- package/src/{session-controller.tree.test.ts → thread-controller.tree.test.ts} +8 -7
- package/src/{session-controller.ts → thread-controller.ts} +18 -22
- package/src/use-annotation-surface.tsx +14 -10
- package/src/view-diff.ts +2 -2
package/src/config.test.ts
CHANGED
|
@@ -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(
|
|
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
|
};
|
package/src/discussions.ts
CHANGED
|
@@ -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,
|
|
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:
|
|
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
|
|
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 "./
|
|
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",
|
package/src/intent-dispatch.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
|
11
|
-
import { isAddressed, isAgentNote, type
|
|
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 "./
|
|
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:
|
|
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:
|
|
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:
|
|
83
|
+
inbox: Thread[] | null;
|
|
84
84
|
inboxCursor: number;
|
|
85
85
|
mode: Mode;
|
|
86
|
-
session:
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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<
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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[]>([]);
|
package/src/slash-palette.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 "./
|
|
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[]):
|
|
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:
|
|
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:
|
|
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):
|
|
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():
|
|
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
|
|
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 "./
|
|
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:
|
|
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"):
|
|
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:
|
|
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
|
-
|
|
127
|
+
sessionComment: async (_id: string, annotation: Annotation) => {
|
|
127
128
|
sent = annotation;
|
|
128
129
|
|
|
129
130
|
return { ...session, annotations: [{ ...annotation, createdAt: AT }] };
|