Show skill autocomplete anywhere in prompts
This commit is contained in:
@@ -11,7 +11,13 @@ import { useAutocomplete } from "./use-autocomplete";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import { filterAndRankCommandAutocompleteEntries } from "@/utils/agent-command-autocomplete";
|
||||
import {
|
||||
applySlashCommandReplacement,
|
||||
filterAndRankCommandAutocompleteEntries,
|
||||
filterInlineSkillCommandEntries,
|
||||
findActiveSlashCommand,
|
||||
type SlashCommandRange,
|
||||
} from "@/utils/agent-command-autocomplete";
|
||||
import {
|
||||
applyFileMentionReplacement,
|
||||
findActiveFileMention,
|
||||
@@ -133,6 +139,63 @@ function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
|
||||
|
||||
type AutocompleteMode = "command" | "file" | null;
|
||||
|
||||
interface BuildAutocompleteOptionsInput {
|
||||
isVisible: boolean;
|
||||
mode: AutocompleteMode;
|
||||
commands: AgentSlashCommand[];
|
||||
isDraftContext: boolean;
|
||||
commandFilterQuery: string;
|
||||
activeSlashCommand: SlashCommandRange | null;
|
||||
activeFileMention: FileMentionRange | null;
|
||||
fileSuggestions: DirectorySuggestionEntry[];
|
||||
}
|
||||
|
||||
function buildCommandAutocompleteOptions(input: BuildAutocompleteOptionsInput) {
|
||||
if (!input.isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (input.mode === "command") {
|
||||
const providerCommands = input.commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
|
||||
const rootCommands: AvailableCommand[] = input.isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
|
||||
];
|
||||
const availableCommands =
|
||||
input.activeSlashCommand?.position === "inline"
|
||||
? filterInlineSkillCommandEntries(providerCommands)
|
||||
: rootCommands;
|
||||
const matches = filterAndRankCommandAutocompleteEntries(
|
||||
availableCommands,
|
||||
input.commandFilterQuery,
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map(mapCommandToOption);
|
||||
}
|
||||
|
||||
const activeFileMention = input.activeFileMention;
|
||||
if (input.mode === "file" && activeFileMention) {
|
||||
const orderedEntries = orderAutocompleteOptions(input.fileSuggestions);
|
||||
return orderedEntries.map((entry) => ({
|
||||
type: "workspace_entry" as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
kind: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveAutocompleteMode(args: {
|
||||
showFileAutocomplete: boolean;
|
||||
showCommandAutocomplete: boolean;
|
||||
@@ -161,6 +224,17 @@ function resolveAutocompleteIsVisible(args: {
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveCanLoadCommands(args: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
isDraftContext: boolean;
|
||||
}): boolean {
|
||||
if (!args.serverId) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(args.agentId) || args.isDraftContext;
|
||||
}
|
||||
|
||||
function resolveAutocompleteIsLoading(args: {
|
||||
mode: AutocompleteMode;
|
||||
isCommandsLoading: boolean;
|
||||
@@ -209,8 +283,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
canExecuteClientSlashCommand,
|
||||
} = input;
|
||||
|
||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : "";
|
||||
const activeSlashCommand = useMemo(
|
||||
() =>
|
||||
findActiveSlashCommand({
|
||||
text: userInput,
|
||||
cursorIndex,
|
||||
}),
|
||||
[cursorIndex, userInput],
|
||||
);
|
||||
const showCommandAutocomplete = activeSlashCommand !== null;
|
||||
const commandFilterQuery = activeSlashCommand?.query ?? "";
|
||||
|
||||
const activeFileMention = useMemo(
|
||||
() =>
|
||||
@@ -235,8 +317,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
);
|
||||
|
||||
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||
const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined;
|
||||
const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext);
|
||||
const queryDraftConfig = normalizedDraftConfig;
|
||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||
|
||||
const agentCwd = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
|
||||
@@ -309,54 +391,29 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
||||
if (!isVisible) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (mode === "command") {
|
||||
const providerCommands = commands.map(
|
||||
(command): AvailableCommand => ({ source: "provider", command }),
|
||||
);
|
||||
const clientCommandNames = new Set(CLIENT_SLASH_COMMANDS.map((command) => command.name));
|
||||
const availableCommands: AvailableCommand[] = isDraftContext
|
||||
? providerCommands
|
||||
: [
|
||||
...CLIENT_SLASH_COMMANDS.map(
|
||||
(command): AvailableCommand => ({ source: "client", command }),
|
||||
),
|
||||
...providerCommands.filter((entry) => !clientCommandNames.has(entry.command.name)),
|
||||
];
|
||||
const matches = filterAndRankCommandAutocompleteEntries(
|
||||
availableCommands,
|
||||
const options = useMemo<AgentAutocompleteOption[]>(
|
||||
() =>
|
||||
buildCommandAutocompleteOptions({
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
);
|
||||
const orderedMatches = orderAutocompleteOptions(matches);
|
||||
return orderedMatches.map(mapCommandToOption);
|
||||
}
|
||||
|
||||
if (mode === "file" && activeFileMention) {
|
||||
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []);
|
||||
return orderedEntries.map((entry) => ({
|
||||
type: "workspace_entry" as const,
|
||||
id: `${entry.kind}:${entry.path}`,
|
||||
label: entry.path,
|
||||
kind: entry.kind,
|
||||
entryPath: entry.path,
|
||||
mention: activeFileMention,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [
|
||||
activeFileMention,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
]);
|
||||
commands,
|
||||
activeSlashCommand,
|
||||
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
}),
|
||||
[
|
||||
activeFileMention,
|
||||
activeSlashCommand,
|
||||
commandFilterQuery,
|
||||
commands,
|
||||
fileSuggestionsQuery.data,
|
||||
isDraftContext,
|
||||
isVisible,
|
||||
mode,
|
||||
],
|
||||
);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
@@ -372,7 +429,20 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
}
|
||||
|
||||
if (selected.type === "client_command" || selected.type === "provider_command") {
|
||||
setUserInput(`/${selected.id} `);
|
||||
if (!activeSlashCommand) {
|
||||
setUserInput(`/${selected.id} `);
|
||||
onAutocompleteApplied?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInput = applySlashCommandReplacement({
|
||||
text: userInput,
|
||||
command: activeSlashCommand,
|
||||
commandName: selected.id,
|
||||
});
|
||||
const shouldAppendSpace =
|
||||
activeSlashCommand.position === "start" && activeSlashCommand.end === userInput.length;
|
||||
setUserInput(shouldAppendSpace ? `${nextInput} ` : nextInput);
|
||||
onAutocompleteApplied?.();
|
||||
return;
|
||||
}
|
||||
@@ -391,6 +461,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
onClientSlashCommand,
|
||||
setUserInput,
|
||||
userInput,
|
||||
activeSlashCommand,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -399,7 +470,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
options,
|
||||
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
|
||||
onSelectOption,
|
||||
onEscape: mode === "command" ? () => setUserInput("") : undefined,
|
||||
onEscape:
|
||||
mode === "command" && activeSlashCommand?.position === "start"
|
||||
? () => setUserInput("")
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const isLoading = resolveAutocompleteIsLoading({
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AgentSlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
argumentHint: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface DraftCommandConfig {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user