Show skill autocomplete anywhere in prompts

This commit is contained in:
Mohamed Boudra
2026-06-09 17:15:03 +07:00
parent 9f41904c6f
commit c5bcec5c71
15 changed files with 393 additions and 61 deletions
@@ -1,6 +1,11 @@
import { describe, expect, it } from "vitest";
import { filterAndRankCommandAutocompleteEntries } from "./agent-command-autocomplete";
import {
applySlashCommandReplacement,
filterAndRankCommandAutocompleteEntries,
filterInlineSkillCommandEntries,
findActiveSlashCommand,
} from "./agent-command-autocomplete";
describe("filterAndRankCommandAutocompleteEntries", () => {
const entries = [
@@ -27,3 +32,81 @@ describe("filterAndRankCommandAutocompleteEntries", () => {
expect(result.map((entry) => entry.command.name)).toEqual(["exit"]);
});
});
describe("findActiveSlashCommand", () => {
it("detects a slash command token in the middle of the prompt", () => {
const text = "use /tas before implementation";
expect(
findActiveSlashCommand({
text,
cursorIndex: "use /tas".length,
}),
).toEqual({
start: 4,
end: "use /tas".length,
query: "tas",
position: "inline",
});
});
it("classifies a slash command token at the prompt start", () => {
expect(
findActiveSlashCommand({
text: "/rew",
cursorIndex: "/rew".length,
}),
).toEqual({
start: 0,
end: "/rew".length,
query: "rew",
position: "start",
});
});
it("returns null when the cursor is outside the slash token", () => {
expect(
findActiveSlashCommand({
text: "use /taste now",
cursorIndex: "use /taste now".length,
}),
).toBeNull();
});
it("returns null for slash-delimited paths", () => {
expect(
findActiveSlashCommand({
text: "read /tmp/project",
cursorIndex: "read /tmp/project".length,
}),
).toBeNull();
});
});
describe("applySlashCommandReplacement", () => {
it("replaces only the active slash token", () => {
const text = "use /tas before implementation";
expect(
applySlashCommandReplacement({
text,
command: { start: 4, end: "use /tas".length, query: "tas", position: "inline" },
commandName: "taste",
}),
).toBe("use /taste before implementation");
});
});
describe("filterInlineSkillCommandEntries", () => {
it("keeps provider skills and drops executable commands", () => {
const entries = [
{ source: "client" as const, command: { name: "clear", kind: "command" } },
{ source: "provider" as const, command: { name: "compact", kind: "command" } },
{ source: "provider" as const, command: { name: "taste", kind: "skill" } },
];
expect(filterInlineSkillCommandEntries(entries).map((entry) => entry.command.name)).toEqual([
"taste",
]);
});
});
@@ -4,9 +4,34 @@ interface CommandAutocompleteEntry {
command: {
name: string;
aliases?: readonly string[];
kind?: string;
};
}
interface InlineSkillCommandEntry extends CommandAutocompleteEntry {
source: "provider" | "client";
}
export type SlashCommandPosition = "start" | "inline";
export interface SlashCommandRange {
start: number;
end: number;
query: string;
position: SlashCommandPosition;
}
interface FindActiveSlashCommandInput {
text: string;
cursorIndex: number;
}
interface ApplySlashCommandReplacementInput {
text: string;
command: SlashCommandRange;
commandName: string;
}
interface ScoredCommandAutocompleteEntry<TEntry> {
entry: TEntry;
score: MatchScore;
@@ -46,3 +71,49 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
return scoredEntries.map((scored) => scored.entry);
}
export function filterInlineSkillCommandEntries<TEntry extends InlineSkillCommandEntry>(
entries: readonly TEntry[],
): TEntry[] {
return entries.filter((entry) => entry.source === "provider" && entry.command.kind === "skill");
}
const INVALID_SLASH_COMMAND_QUERY_CHARS = /[/\s\n\r\t"']/;
export function findActiveSlashCommand(
input: FindActiveSlashCommandInput,
): SlashCommandRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
const beforeCursor = input.text.slice(0, clampedCursor);
for (
let slashIndex = beforeCursor.lastIndexOf("/");
slashIndex >= 0;
slashIndex = slashIndex === 0 ? -1 : beforeCursor.lastIndexOf("/", slashIndex - 1)
) {
const previousCharacter = slashIndex > 0 ? input.text[slashIndex - 1] : "";
if (previousCharacter && !/\s/.test(previousCharacter)) {
continue;
}
const query = beforeCursor.slice(slashIndex + 1);
if (INVALID_SLASH_COMMAND_QUERY_CHARS.test(query)) {
continue;
}
return {
start: slashIndex,
end: clampedCursor,
query,
position: slashIndex === 0 ? "start" : "inline",
};
}
return null;
}
export function applySlashCommandReplacement(input: ApplySlashCommandReplacementInput): string {
const before = input.text.slice(0, input.command.start);
const after = input.text.slice(input.command.end);
return `${before}/${input.commandName}${after}`;
}