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
+128 -54
View File
@@ -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");
});
}); });
+1
View File
@@ -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()];