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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
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 {
|
import {
|
||||||
applyFileMentionReplacement,
|
applyFileMentionReplacement,
|
||||||
findActiveFileMention,
|
findActiveFileMention,
|
||||||
@@ -133,6 +139,63 @@ function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption {
|
|||||||
|
|
||||||
type AutocompleteMode = "command" | "file" | null;
|
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: {
|
function resolveAutocompleteMode(args: {
|
||||||
showFileAutocomplete: boolean;
|
showFileAutocomplete: boolean;
|
||||||
showCommandAutocomplete: boolean;
|
showCommandAutocomplete: boolean;
|
||||||
@@ -161,6 +224,17 @@ function resolveAutocompleteIsVisible(args: {
|
|||||||
return false;
|
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: {
|
function resolveAutocompleteIsLoading(args: {
|
||||||
mode: AutocompleteMode;
|
mode: AutocompleteMode;
|
||||||
isCommandsLoading: boolean;
|
isCommandsLoading: boolean;
|
||||||
@@ -209,8 +283,16 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
canExecuteClientSlashCommand,
|
canExecuteClientSlashCommand,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
const activeSlashCommand = useMemo(
|
||||||
const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : "";
|
() =>
|
||||||
|
findActiveSlashCommand({
|
||||||
|
text: userInput,
|
||||||
|
cursorIndex,
|
||||||
|
}),
|
||||||
|
[cursorIndex, userInput],
|
||||||
|
);
|
||||||
|
const showCommandAutocomplete = activeSlashCommand !== null;
|
||||||
|
const commandFilterQuery = activeSlashCommand?.query ?? "";
|
||||||
|
|
||||||
const activeFileMention = useMemo(
|
const activeFileMention = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -235,8 +317,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
);
|
);
|
||||||
|
|
||||||
const isDraftContext = normalizedDraftConfig !== undefined;
|
const isDraftContext = normalizedDraftConfig !== undefined;
|
||||||
const queryDraftConfig = isDraftContext ? normalizedDraftConfig : undefined;
|
const queryDraftConfig = normalizedDraftConfig;
|
||||||
const canLoadCommands = Boolean(serverId) && (Boolean(agentId) || isDraftContext);
|
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||||
|
|
||||||
const agentCwd = useSessionStore(
|
const agentCwd = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
|
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
|
||||||
@@ -309,54 +391,29 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const options = useMemo<AgentAutocompleteOption[]>(() => {
|
const options = useMemo<AgentAutocompleteOption[]>(
|
||||||
if (!isVisible) {
|
() =>
|
||||||
return [];
|
buildCommandAutocompleteOptions({
|
||||||
}
|
activeFileMention,
|
||||||
|
|
||||||
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,
|
|
||||||
commandFilterQuery,
|
commandFilterQuery,
|
||||||
);
|
commands,
|
||||||
const orderedMatches = orderAutocompleteOptions(matches);
|
activeSlashCommand,
|
||||||
return orderedMatches.map(mapCommandToOption);
|
fileSuggestions: fileSuggestionsQuery.data ?? [],
|
||||||
}
|
isDraftContext,
|
||||||
|
isVisible,
|
||||||
if (mode === "file" && activeFileMention) {
|
mode,
|
||||||
const orderedEntries = orderAutocompleteOptions(fileSuggestionsQuery.data ?? []);
|
}),
|
||||||
return orderedEntries.map((entry) => ({
|
[
|
||||||
type: "workspace_entry" as const,
|
activeFileMention,
|
||||||
id: `${entry.kind}:${entry.path}`,
|
activeSlashCommand,
|
||||||
label: entry.path,
|
commandFilterQuery,
|
||||||
kind: entry.kind,
|
commands,
|
||||||
entryPath: entry.path,
|
fileSuggestionsQuery.data,
|
||||||
mention: activeFileMention,
|
isDraftContext,
|
||||||
}));
|
isVisible,
|
||||||
}
|
mode,
|
||||||
|
],
|
||||||
return [];
|
);
|
||||||
}, [
|
|
||||||
activeFileMention,
|
|
||||||
commandFilterQuery,
|
|
||||||
commands,
|
|
||||||
fileSuggestionsQuery.data,
|
|
||||||
isDraftContext,
|
|
||||||
isVisible,
|
|
||||||
mode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const onSelectOption = useCallback(
|
const onSelectOption = useCallback(
|
||||||
(option: AutocompleteOption) => {
|
(option: AutocompleteOption) => {
|
||||||
@@ -372,7 +429,20 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selected.type === "client_command" || selected.type === "provider_command") {
|
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?.();
|
onAutocompleteApplied?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -391,6 +461,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
onClientSlashCommand,
|
onClientSlashCommand,
|
||||||
setUserInput,
|
setUserInput,
|
||||||
userInput,
|
userInput,
|
||||||
|
activeSlashCommand,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,7 +470,10 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
options,
|
options,
|
||||||
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
|
query: mode === "command" ? commandFilterQuery : fileFilterQuery,
|
||||||
onSelectOption,
|
onSelectOption,
|
||||||
onEscape: mode === "command" ? () => setUserInput("") : undefined,
|
onEscape:
|
||||||
|
mode === "command" && activeSlashCommand?.position === "start"
|
||||||
|
? () => setUserInput("")
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const isLoading = resolveAutocompleteIsLoading({
|
const isLoading = resolveAutocompleteIsLoading({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface AgentSlashCommand {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
argumentHint: string;
|
argumentHint: string;
|
||||||
|
kind?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DraftCommandConfig {
|
export interface DraftCommandConfig {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { filterAndRankCommandAutocompleteEntries } from "./agent-command-autocomplete";
|
import {
|
||||||
|
applySlashCommandReplacement,
|
||||||
|
filterAndRankCommandAutocompleteEntries,
|
||||||
|
filterInlineSkillCommandEntries,
|
||||||
|
findActiveSlashCommand,
|
||||||
|
} from "./agent-command-autocomplete";
|
||||||
|
|
||||||
describe("filterAndRankCommandAutocompleteEntries", () => {
|
describe("filterAndRankCommandAutocompleteEntries", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
@@ -27,3 +32,81 @@ describe("filterAndRankCommandAutocompleteEntries", () => {
|
|||||||
expect(result.map((entry) => entry.command.name)).toEqual(["exit"]);
|
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: {
|
command: {
|
||||||
name: string;
|
name: string;
|
||||||
aliases?: readonly 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> {
|
interface ScoredCommandAutocompleteEntry<TEntry> {
|
||||||
entry: TEntry;
|
entry: TEntry;
|
||||||
score: MatchScore;
|
score: MatchScore;
|
||||||
@@ -46,3 +71,49 @@ export function filterAndRankCommandAutocompleteEntries<TEntry extends CommandAu
|
|||||||
|
|
||||||
return scoredEntries.map((scored) => scored.entry);
|
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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { SessionInboundMessageSchema } from "./messages.js";
|
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
|
||||||
|
|
||||||
describe("list_commands_request schema", () => {
|
describe("list_commands_request schema", () => {
|
||||||
test("accepts legacy agent-only payload", () => {
|
test("accepts legacy agent-only payload", () => {
|
||||||
@@ -49,4 +49,61 @@ describe("list_commands_request schema", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("preserves command kind metadata in responses", () => {
|
||||||
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
|
type: "list_commands_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent-123",
|
||||||
|
requestId: "req-123",
|
||||||
|
error: null,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "taste",
|
||||||
|
description: "Apply code taste",
|
||||||
|
argumentHint: "",
|
||||||
|
kind: "skill",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("list_commands_response");
|
||||||
|
if (parsed.type !== "list_commands_response") {
|
||||||
|
throw new Error("Expected list_commands_response message");
|
||||||
|
}
|
||||||
|
expect(parsed.payload.commands).toEqual([
|
||||||
|
{
|
||||||
|
name: "taste",
|
||||||
|
description: "Apply code taste",
|
||||||
|
argumentHint: "",
|
||||||
|
kind: "skill",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to command for unknown future command kinds", () => {
|
||||||
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
|
type: "list_commands_response",
|
||||||
|
payload: {
|
||||||
|
agentId: "agent-123",
|
||||||
|
requestId: "req-123",
|
||||||
|
error: null,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: "future-command",
|
||||||
|
description: "Future command kind",
|
||||||
|
argumentHint: "",
|
||||||
|
kind: "future-kind",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.type).toBe("list_commands_response");
|
||||||
|
if (parsed.type !== "list_commands_response") {
|
||||||
|
throw new Error("Expected list_commands_response message");
|
||||||
|
}
|
||||||
|
expect(parsed.payload.commands[0]?.kind).toBe("command");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3557,6 +3557,7 @@ const AgentSlashCommandSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
argumentHint: z.string(),
|
argumentHint: z.string(),
|
||||||
|
kind: z.enum(["command", "skill"]).optional().catch("command"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListCommandsResponseSchema = z.object({
|
export const ListCommandsResponseSchema = z.object({
|
||||||
|
|||||||
@@ -481,6 +481,8 @@ export interface AgentRuntimeInfo {
|
|||||||
extra?: AgentMetadata;
|
extra?: AgentMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentSlashCommandKind = "command" | "skill";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a slash command available in an agent session.
|
* Represents a slash command available in an agent session.
|
||||||
* Commands are executed by sending them as prompts with / prefix.
|
* Commands are executed by sending them as prompts with / prefix.
|
||||||
@@ -489,6 +491,7 @@ export interface AgentSlashCommand {
|
|||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
argumentHint: string;
|
argumentHint: string;
|
||||||
|
kind?: AgentSlashCommandKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListPersistedAgentsOptions {
|
export interface ListPersistedAgentsOptions {
|
||||||
|
|||||||
@@ -1486,11 +1486,13 @@ describe("ACPAgentSession slash commands", () => {
|
|||||||
name: "research_codebase",
|
name: "research_codebase",
|
||||||
description: "Search the workspace for relevant files",
|
description: "Search the workspace for relevant files",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_plan",
|
name: "create_plan",
|
||||||
description: "Draft a plan for the requested work",
|
description: "Draft a plan for the requested work",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -1499,11 +1501,13 @@ describe("ACPAgentSession slash commands", () => {
|
|||||||
name: "research_codebase",
|
name: "research_codebase",
|
||||||
description: "Search the workspace for relevant files",
|
description: "Search the workspace for relevant files",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_plan",
|
name: "create_plan",
|
||||||
description: "Draft a plan for the requested work",
|
description: "Draft a plan for the requested work",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1989,6 +1989,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
name: command.name,
|
name: command.name,
|
||||||
description: command.description,
|
description: command.description,
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
}));
|
}));
|
||||||
this.settleCommandsReady();
|
this.settleCommandsReady();
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -2081,6 +2081,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
name: cmd.name,
|
name: cmd.name,
|
||||||
description: cmd.description,
|
description: cmd.description,
|
||||||
argumentHint: cmd.argumentHint,
|
argumentHint: cmd.argumentHint,
|
||||||
|
kind: "command",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -725,6 +725,7 @@ describe("Codex app-server provider", () => {
|
|||||||
name: "shipper",
|
name: "shipper",
|
||||||
description: "Ship changes carefully.",
|
description: "Ship changes carefully.",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "skill",
|
||||||
});
|
});
|
||||||
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
|
expect(workspaceGitService.resolveRepoRoot).toHaveBeenCalledWith(cwd);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -983,6 +984,7 @@ describe("Codex app-server provider", () => {
|
|||||||
name: "paseo",
|
name: "paseo",
|
||||||
description: "Shared orchestration skill.",
|
description: "Shared orchestration skill.",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "skill",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1890,6 +1892,7 @@ describe("Codex app-server provider", () => {
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
description: "Summarize conversation to prevent hitting the context limit",
|
description: "Summarize conversation to prevent hitting the context limit",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
});
|
});
|
||||||
|
|
||||||
const handler = session.tryHandleOutOfBand?.("/compact");
|
const handler = session.tryHandleOutOfBand?.("/compact");
|
||||||
|
|||||||
@@ -631,6 +631,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
|||||||
name: `prompts:${name}`,
|
name: `prompts:${name}`,
|
||||||
description,
|
description,
|
||||||
argumentHint,
|
argumentHint,
|
||||||
|
kind: "command",
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -695,6 +696,7 @@ export async function listCodexSkills(
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "skill",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3949,6 +3951,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
name: skill.name,
|
name: skill.name,
|
||||||
description: skill.description,
|
description: skill.description,
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "skill" as const,
|
||||||
}));
|
}));
|
||||||
const fallbackSkills =
|
const fallbackSkills =
|
||||||
appServerSkills.length === 0
|
appServerSkills.length === 0
|
||||||
@@ -3959,6 +3962,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
description: "Summarize conversation to prevent hitting the context limit",
|
description: "Summarize conversation to prevent hitting the context limit",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (this.goalsEnabled) {
|
if (this.goalsEnabled) {
|
||||||
@@ -3966,6 +3970,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
name: "goal",
|
name: "goal",
|
||||||
description: "Set, pause, resume, or clear the agent's goal",
|
description: "Set, pause, resume, or clear the agent's goal",
|
||||||
argumentHint: "[<objective>|pause|resume|clear]",
|
argumentHint: "[<objective>|pause|resume|clear]",
|
||||||
|
kind: "command",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return [...builtin, ...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
|
return [...builtin, ...appServerSkills, ...fallbackSkills, ...prompts].sort((a, b) =>
|
||||||
|
|||||||
@@ -252,8 +252,18 @@ const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
|
|||||||
const OPENCODE_METADATA_CONCURRENCY = 4;
|
const OPENCODE_METADATA_CONCURRENCY = 4;
|
||||||
const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY);
|
const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY);
|
||||||
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||||
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
{
|
||||||
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
|
name: "compact",
|
||||||
|
description: "Compact the current session",
|
||||||
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "summarize",
|
||||||
|
description: "Compact the current session",
|
||||||
|
argumentHint: "",
|
||||||
|
kind: "command",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
|
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
|
||||||
"headers timeout",
|
"headers timeout",
|
||||||
@@ -1720,6 +1730,7 @@ async function listOpenCodeCommandsFromSdk(
|
|||||||
name: cmd.name,
|
name: cmd.name,
|
||||||
description: cmd.description ?? "",
|
description: cmd.description ?? "",
|
||||||
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
argumentHint: cmd.hints?.length ? cmd.hints.join(" ") : "",
|
||||||
|
kind: cmd.source === "skill" ? "skill" : "command",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -830,15 +830,17 @@ describe("PiRpcAgentClient", () => {
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
description: "Manually compact the session context",
|
description: "Manually compact the session context",
|
||||||
argumentHint: "[instructions]",
|
argumentHint: "[instructions]",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "autocompact",
|
name: "autocompact",
|
||||||
description: "Toggle automatic context compaction",
|
description: "Toggle automatic context compaction",
|
||||||
argumentHint: "[on|off|toggle]",
|
argumentHint: "[on|off|toggle]",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
{ name: "review", description: "Review changes", argumentHint: "" },
|
{ name: "review", description: "Review changes", argumentHint: "", kind: "command" },
|
||||||
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
|
{ name: "fix-tests", description: "Fix tests", argumentHint: "", kind: "command" },
|
||||||
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
|
{ name: "skill:docs", description: "Read docs", argumentHint: "", kind: "skill" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -852,11 +854,13 @@ describe("PiRpcAgentClient", () => {
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
description: "Manually compact the session context",
|
description: "Manually compact the session context",
|
||||||
argumentHint: "[instructions]",
|
argumentHint: "[instructions]",
|
||||||
|
kind: "command",
|
||||||
});
|
});
|
||||||
await expect(session.listCommands()).resolves.toContainEqual({
|
await expect(session.listCommands()).resolves.toContainEqual({
|
||||||
name: "autocompact",
|
name: "autocompact",
|
||||||
description: "Toggle automatic context compaction",
|
description: "Toggle automatic context compaction",
|
||||||
argumentHint: "[on|off|toggle]",
|
argumentHint: "[on|off|toggle]",
|
||||||
|
kind: "command",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -872,11 +876,13 @@ describe("PiRpcAgentClient", () => {
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
description: "Compact from RPC",
|
description: "Compact from RPC",
|
||||||
argumentHint: "[instructions]",
|
argumentHint: "[instructions]",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "autocompact",
|
name: "autocompact",
|
||||||
description: "Auto compact from RPC",
|
description: "Auto compact from RPC",
|
||||||
argumentHint: "[on|off|toggle]",
|
argumentHint: "[on|off|toggle]",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type AgentSession,
|
type AgentSession,
|
||||||
type AgentSessionConfig,
|
type AgentSessionConfig,
|
||||||
type AgentSlashCommand,
|
type AgentSlashCommand,
|
||||||
|
type AgentSlashCommandKind,
|
||||||
type AgentStreamEvent,
|
type AgentStreamEvent,
|
||||||
type AgentUsage,
|
type AgentUsage,
|
||||||
type ListPersistedAgentsOptions,
|
type ListPersistedAgentsOptions,
|
||||||
@@ -101,14 +102,23 @@ const PI_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
|||||||
name: "compact",
|
name: "compact",
|
||||||
description: "Manually compact the session context",
|
description: "Manually compact the session context",
|
||||||
argumentHint: "[instructions]",
|
argumentHint: "[instructions]",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "autocompact",
|
name: "autocompact",
|
||||||
description: "Toggle automatic context compaction",
|
description: "Toggle automatic context compaction",
|
||||||
argumentHint: "[on|off|toggle]",
|
argumentHint: "[on|off|toggle]",
|
||||||
|
kind: "command",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function mapPiCommandKind(source: PiRpcSlashCommand["source"]): AgentSlashCommandKind {
|
||||||
|
if (source === "skill") {
|
||||||
|
return "skill";
|
||||||
|
}
|
||||||
|
return "command";
|
||||||
|
}
|
||||||
|
|
||||||
const PI_CAPABILITIES: AgentCapabilityFlags = {
|
const PI_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -1190,6 +1200,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
name: command.name,
|
name: command.name,
|
||||||
description: command.description ?? command.source,
|
description: command.description ?? command.source,
|
||||||
argumentHint: knownCommand?.argumentHint ?? "",
|
argumentHint: knownCommand?.argumentHint ?? "",
|
||||||
|
kind: mapPiCommandKind(command.source),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return [...mappedCommands.values()];
|
return [...mappedCommands.values()];
|
||||||
|
|||||||
Reference in New Issue
Block a user