Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apps/web/src/__tests__/slash-command-node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
$isSlashCommandNode,
} from "@/components/chat/lexical/SlashCommandNode";
import { extractComposerMessage } from "@/components/chat/lexical/cursor-utils";
import { writeComposerContent } from "@/features/conversation/composer/draft/composer-editor-content";

function createTestEditor() {
return createEditor({
Expand Down Expand Up @@ -138,4 +139,62 @@ describe("SlashCommandNode", () => {
}],
});
});

it("serializes a stored command path into the extracted mention", () => {
const editor = createTestEditor();
editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createSlashCommandNode("deploy", "skill", {
providerId: "cursor",
kind: "skill",
nativeId: "deploy",
}, "C:/repo/.cursor/skills/deploy/SKILL.md"));
$getRoot().append(paragraph);
},
{ discrete: true },
);

expect(extractComposerMessage(editor)).toEqual({
text: "/deploy",
mentions: [{
id: "command:skill:deploy",
kind: "command",
label: "deploy",
namespace: "skill",
capabilityIdentity: {
providerId: "cursor",
kind: "skill",
nativeId: "deploy",
},
path: "C:/repo/.cursor/skills/deploy/SKILL.md",
range: { start: 0, end: 7 },
}],
});
});

it("round-trips a command mention path through writeComposerContent", async () => {
const editor = createTestEditor();
writeComposerContent(editor, "/deploy now", [{
id: "command:skill:deploy",
kind: "command",
label: "deploy",
namespace: "skill",
path: "C:/repo/.cursor/skills/deploy/SKILL.md",
range: { start: 0, end: 7 },
}]);
await new Promise<void>((resolve) => setImmediate(resolve));

expect(extractComposerMessage(editor)).toEqual({
text: "/deploy now",
mentions: [{
id: "command:skill:deploy",
kind: "command",
label: "deploy",
namespace: "skill",
path: "C:/repo/.cursor/skills/deploy/SKILL.md",
range: { start: 0, end: 7 },
}],
});
});
});
14 changes: 13 additions & 1 deletion apps/web/src/components/chat/lexical/SlashCommandNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface SerializedSlashCommandNode extends SerializedLexicalNode {
readonly commandName: string;
readonly namespace: SlashCommandNamespace;
readonly capabilityIdentity?: ProviderCapabilityIdentity;
readonly path?: string;
}

/** Valid namespace values for deserialisation fallback. */
Expand Down Expand Up @@ -67,6 +68,7 @@ export class SlashCommandNode extends DecoratorNode<JSX.Element> {
__commandName: string;
__namespace: SlashCommandNamespace;
__capabilityIdentity?: ProviderCapabilityIdentity;
__path?: string;

static getType(): string {
return "slash-command";
Expand All @@ -77,6 +79,7 @@ export class SlashCommandNode extends DecoratorNode<JSX.Element> {
node.__commandName,
node.__namespace,
node.__capabilityIdentity,
node.__path,
node.__key,
);
}
Expand All @@ -85,12 +88,14 @@ export class SlashCommandNode extends DecoratorNode<JSX.Element> {
commandName: string,
namespace: SlashCommandNamespace,
capabilityIdentity?: ProviderCapabilityIdentity,
path?: string,
key?: NodeKey,
) {
super(key);
this.__commandName = commandName;
this.__namespace = namespace;
this.__capabilityIdentity = capabilityIdentity;
this.__path = path;
}

// -- Accessors ------------------------------------------------------------
Expand All @@ -107,6 +112,10 @@ export class SlashCommandNode extends DecoratorNode<JSX.Element> {
return this.getLatest().__capabilityIdentity;
}

getPath(): string | undefined {
return this.getLatest().__path;
}

// -- Behavior -------------------------------------------------------------

isInline(): boolean {
Expand Down Expand Up @@ -137,6 +146,7 @@ export class SlashCommandNode extends DecoratorNode<JSX.Element> {
commandName: this.__commandName,
namespace: this.__namespace,
...(this.__capabilityIdentity ? { capabilityIdentity: this.__capabilityIdentity } : {}),
...(this.__path ? { path: this.__path } : {}),
version: 1,
};
}
Expand All @@ -154,6 +164,7 @@ export class SlashCommandNode extends DecoratorNode<JSX.Element> {
serializedNode.commandName,
ns,
identity.success ? identity.data : undefined,
serializedNode.path,
);
}

Expand All @@ -178,8 +189,9 @@ export function $createSlashCommandNode(
commandName: string,
namespace: SlashCommandNamespace,
capabilityIdentity?: ProviderCapabilityIdentity,
path?: string,
): SlashCommandNode {
return new SlashCommandNode(commandName, namespace, capabilityIdentity);
return new SlashCommandNode(commandName, namespace, capabilityIdentity, path);
}

/** Type guard: returns true when the node is a SlashCommandNode. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,11 @@ export function insertSlashCommandNode(
commandName: string,
namespace: SlashCommandNamespace,
capabilityIdentity?: ProviderCapabilityIdentity,
path?: string,
): void {
replaceActiveSlashTrigger(
editor,
() => $createSlashCommandNode(commandName, namespace, capabilityIdentity),
() => $createSlashCommandNode(commandName, namespace, capabilityIdentity, path),
);
}

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/lexical/cursor-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function extractComposerMessage(editor: LexicalEditor): ExtractedComposer
...(child.getCapabilityIdentity()
? { capabilityIdentity: child.getCapabilityIdentity() }
: {}),
...(child.getPath() ? { path: child.getPath() } : {}),
range: { start, end: text.length },
});
} else {
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/chat/useSlashCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface Command {
capabilityKind: ProviderCapabilityKind | "mcode";
nativeId: string;
mentionPath?: string;
/** Filesystem path of the backing skill/command file, when the catalog knows it. */
path?: string;
identity?: ProviderCapabilityIdentity;
/** Discovery scope of the backing skill entry; used to tag project-local duplicates. */
source?: SkillSource;
Expand Down Expand Up @@ -106,6 +108,7 @@ function toCommand(entry: ProviderCapabilityEntry): Command | null {
return {
...base,
namespace: "command",
path: entry.path,
};
}
// Compat-prefixed names like `claude:prototype` are ordinary skills, not
Expand All @@ -114,6 +117,7 @@ function toCommand(entry: ProviderCapabilityEntry): Command | null {
...base,
source: entry.source,
namespace: entry.source === "plugin" ? "plugin" : "skill",
path: entry.path,
};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/features/conversation/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ export function Composer({
if (cmd.action) {
removeSlashCommandTrigger(editorRef.current);
} else if (!insertSelectedPluginMention(editorRef.current, cmd)) {
insertSlashCommandNode(editorRef.current, cmd.name, cmd.namespace, cmd.identity);
insertSlashCommandNode(editorRef.current, cmd.name, cmd.namespace, cmd.identity, cmd.path);
}
}
}, [editorRef, slashCommand]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export function writeComposerContent(
mention.label,
mention.namespace,
mention.capabilityIdentity,
mention.path,
),
);
cursor = mention.range.end;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function CommentEditorComposer({
if (!editor) return;
slashCommand.onSelect(command, () => {});
if (!insertSelectedPluginMention(editor, command)) {
insertSlashCommandNode(editor, command.name, command.namespace, command.identity);
insertSlashCommandNode(editor, command.name, command.namespace, command.identity, command.path);
}
}, [editorRef, slashCommand]);

Expand Down
29 changes: 29 additions & 0 deletions packages/contracts/src/models/__tests__/mention.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,35 @@ describe("MessageMentionSchema", () => {
});
});

it("preserves a command mention path for file-linked invocations", () => {
const result = MessageMentionSchema().safeParse({
id: "command:skill:deploy",
kind: "command",
label: "deploy",
namespace: "skill",
path: "C:/repo/.agents/skills/deploy/SKILL.md",
range: { start: 0, end: 7 },
});

expect(result.success).toBe(true);
expect(result.data).toMatchObject({
path: "C:/repo/.agents/skills/deploy/SKILL.md",
});
});

it("rejects command mention paths with control characters", () => {
const result = MessageMentionSchema().safeParse({
id: "command:skill:deploy",
kind: "command",
label: "deploy",
namespace: "skill",
path: "C:/repo/.agents/skills/\ndeploy/SKILL.md",
range: { start: 0, end: 7 },
});

expect(result.success).toBe(false);
});

it("rejects unknown command namespaces", () => {
const result = MessageMentionSchema().safeParse({
id: "command:unknown:deploy",
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/models/mention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const MessageMentionSchema = lazySchema(() => z.discriminatedUnion("kind"
kind: z.literal("command"),
namespace: z.enum(["skill", "mcode", "plugin", "command"]),
capabilityIdentity: ProviderCapabilityIdentitySchema().optional(),
path: MentionPathSchema.optional(),
}),
]));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +1617,45 @@ describe("CodexProvider first turn on new session", () => {
expect(sendTurnMock.mock.calls[0][0]).toEqual([{ type: "text", text: "/goal clear" }]);
});

it("links a selected slash command to its backing file when no native channel matches", async () => {
const provider = makeProvider();

await provider.sendTurn({
turnId: "test-turn",
turnExecutionId: "test-execution",
sessionId: "mcode-command-link",
workspaceId: "workspace-test",
threadId: "command-link",
message: "/deploy staging",
mentions: [{
id: "command:skill:deploy",
kind: "command",
label: "deploy",
namespace: "skill",
capabilityIdentity: {
providerId: "codex",
kind: "skill",
nativeId: "deploy",
},
path: "C:\\skills\\deploy\\SKILL.md",
range: { start: 0, end: 7 },
}],
cwd: process.cwd(),
model: "gpt-5.4",
interactionMode: "build",
providerOptions: {},
permissionMode: "auto",
});

for (let i = 0; i < 20 && sendTurnMock.mock.calls.length === 0; i++) {
await new Promise<void>((resolve) => setImmediate(resolve));
}

expect(sendTurnMock.mock.calls[0][0]).toEqual([
{ type: "text", text: "[/deploy](C:\\skills\\deploy\\SKILL.md) staging" },
]);
});

it("runs side-channel handoff turns at low effort", async () => {
const provider = makeProvider();

Expand Down
16 changes: 11 additions & 5 deletions packages/providers/src/private/codex/codex-input-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,20 +186,26 @@ async function resolveCodexSlashInvocation(
): Promise<{ text: string; skillItem?: TurnInputPart }> {
const slash = parseCodexSlashInvocation(message);
if (!slash) return { text: message };
const selectedIdentity = selectedCommandIdentity(message, slash.requestedName, mentions);
const selectedMention = selectedCommandMention(message, slash.requestedName, mentions);
const selectedIdentity = selectedMention?.capabilityIdentity;
const candidates = matchingSkillCandidates(skills, slash.requestedName);
const selected = selectedIdentity ? candidates.find((item) => matchesCodexCapabilityIdentity(item, selectedIdentity)) : undefined;
const promptCommand = selectPromptCommand(candidates, selected, selectedIdentity, slash.requestedName);
if (promptCommand) return { text: await expandCodexPromptCommand(promptCommand, slash.args) };
const skill = selectSkill(candidates, selected, selectedIdentity);
return skill ? codexSkillInvocation(skill, slash.args) : { text: message };
if (skill) return codexSkillInvocation(skill, slash.args);
// Neither native channel matched: link the backing file so the agent can read it.
if (selectedMention?.path) {
return { text: `[/${slash.requestedName}](${selectedMention.path})${slash.args ? ` ${slash.args}` : ""}` };
}
return { text: message };
}

function selectedCommandIdentity(
function selectedCommandMention(
message: string,
requestedName: string,
mentions: readonly MessageMention[],
): ProviderCapabilityIdentity | undefined {
): Extract<MessageMention, { kind: "command" }> | undefined {
const leadingSpace = message.length - message.trimStart().length;
const commandEnd = leadingSpace + requestedName.length + 1;
return mentions.find((mention): mention is Extract<MessageMention, { kind: "command" }> => (
Expand All @@ -208,7 +214,7 @@ function selectedCommandIdentity(
&& mention.range.start === leadingSpace
&& mention.range.end === commandEnd
&& mention.capabilityIdentity?.providerId === "codex"
))?.capabilityIdentity;
));
}

function matchingSkillCandidates(skills: readonly SkillInfo[], requestedName: string): SkillInfo[] {
Expand Down
1 change: 1 addition & 0 deletions packages/providers/src/private/cursor/cursor-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ export class CursorProvider
model: context.req.model,
resume: context.resume,
attachments: context.req.attachments,
mentions: context.req.mentions,
turnId: context.req.turnId,
turnExecutionId: context.req.turnExecutionId,
deliveryAttempt: context.routing.deliveryAttempt,
Expand Down
Loading
Loading