feat(cli): menu contextuel d'autocomplétion des slash-commands dans le composer — #163 (QA verte)
À la saisie d'un « / » en début de draft (sans espace), ouverture d'un menu contextuel listant les commandes matchant le préfixe, via le contrat unifié listSlashCommands — aucune liste codée en dur côté UI. Rafinement incrémental des suggestions à la frappe, navigation clavier + sélection, exécution par executeSlashCommand. - adapters/agent: listSlashCommands(prefix?) + executeSlashCommand (invoke Tauri). - domain + ports: types SlashCommand / ExecuteSlashCommandResult. - mock: données de test. - CustomAgentChatView: détection de préfixe (/ sans espace), fetch avec séquence anti-retard, gestion ouverture/active index, navigation clavier, fermeture quand le préfixe cesse d'être éligible. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -203,6 +203,56 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("listSlashCommands forwards the unified slash-command filter request and unwraps commands", async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
commands: [
|
||||
{
|
||||
name: "/clean",
|
||||
shortDescription: "Clean current conversation",
|
||||
requiresConfirmation: false,
|
||||
availability: { status: "available" },
|
||||
source: "native",
|
||||
native: "clean",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const out = await new TauriAgentGateway().listSlashCommands("/c");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_slash_commands", {
|
||||
request: { prefix: "/c" },
|
||||
});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].name).toBe("/clean");
|
||||
});
|
||||
|
||||
it("executeSlashCommand wraps the selected command and current session id", async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
command: {
|
||||
name: "/clean",
|
||||
shortDescription: "Clean current conversation",
|
||||
requiresConfirmation: false,
|
||||
availability: { status: "available" },
|
||||
source: "native",
|
||||
native: "clean",
|
||||
},
|
||||
effect: {
|
||||
kind: "cleanConversation",
|
||||
sessionId: "chat-session-1",
|
||||
clearedChunks: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const out = await new TauriAgentGateway().executeSlashCommand("/clean", {
|
||||
sessionId: "chat-session-1",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("execute_slash_command", {
|
||||
request: { name: "/clean", sessionId: "chat-session-1" },
|
||||
});
|
||||
expect(out.effect.kind).toBe("cleanConversation");
|
||||
});
|
||||
|
||||
it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
|
||||
invoke.mockResolvedValueOnce({
|
||||
sessionId: "chat-session-1",
|
||||
|
||||
@ -19,8 +19,10 @@ import type {
|
||||
AgentContextDocument,
|
||||
EffortSelection,
|
||||
GatewayError,
|
||||
ExecuteSlashCommandResult,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
SlashCommand,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
@ -274,6 +276,26 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
});
|
||||
}
|
||||
|
||||
async listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
|
||||
const request = prefix === undefined ? {} : { prefix };
|
||||
const res = await invoke<{ commands: SlashCommand[] }>("list_slash_commands", {
|
||||
request,
|
||||
});
|
||||
return res.commands;
|
||||
}
|
||||
|
||||
async executeSlashCommand(
|
||||
name: string,
|
||||
options: { sessionId?: string | null } = {},
|
||||
): Promise<ExecuteSlashCommandResult> {
|
||||
return invoke<ExecuteSlashCommandResult>("execute_slash_command", {
|
||||
request: {
|
||||
name,
|
||||
sessionId: options.sessionId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async closeAgentChat(sessionId: string): Promise<void> {
|
||||
await invoke("close_agent_session", { sessionId });
|
||||
}
|
||||
|
||||
@ -79,6 +79,8 @@ import type {
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
ReplyChunk,
|
||||
ExecuteSlashCommandResult,
|
||||
SlashCommand,
|
||||
SkillScope,
|
||||
Sprint,
|
||||
Template,
|
||||
@ -399,6 +401,35 @@ export class MockAgentGateway implements AgentGateway {
|
||||
private liveKindByAgent = new Map<string, "pty" | "structured">();
|
||||
/** Retained structured reply chunks per live chat session. */
|
||||
private chatScrollback = new Map<string, ReplyChunk[]>();
|
||||
private slashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: "/help",
|
||||
shortDescription: "Afficher les commandes disponibles",
|
||||
requiresConfirmation: false,
|
||||
availability: { status: "available" },
|
||||
source: "native",
|
||||
native: "help",
|
||||
},
|
||||
{
|
||||
name: "/clean",
|
||||
shortDescription: "Nettoyer la conversation courante",
|
||||
requiresConfirmation: false,
|
||||
availability: { status: "available" },
|
||||
source: "native",
|
||||
native: "clean",
|
||||
},
|
||||
{
|
||||
name: "/profile",
|
||||
shortDescription: "Changer le profil de l'agent",
|
||||
requiresConfirmation: true,
|
||||
availability: {
|
||||
status: "unavailable",
|
||||
reason: "La selection de profil est livree par le ticket #164",
|
||||
},
|
||||
source: "native",
|
||||
native: "profile",
|
||||
},
|
||||
];
|
||||
|
||||
private getAgents(projectId: string): Agent[] {
|
||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||
@ -862,6 +893,61 @@ export class MockAgentGateway implements AgentGateway {
|
||||
}
|
||||
}
|
||||
|
||||
async listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
|
||||
const normalized = prefix?.trim();
|
||||
const query = normalized
|
||||
? normalized.startsWith("/")
|
||||
? normalized
|
||||
: `/${normalized}`
|
||||
: "";
|
||||
return structuredClone(
|
||||
this.slashCommands.filter((command) =>
|
||||
query ? command.name.startsWith(query) : true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async executeSlashCommand(
|
||||
name: string,
|
||||
options: { sessionId?: string | null } = {},
|
||||
): Promise<ExecuteSlashCommandResult> {
|
||||
const normalized = name.trim().startsWith("/") ? name.trim() : `/${name.trim()}`;
|
||||
const command = this.slashCommands.find((item) => item.name === normalized);
|
||||
if (!command) {
|
||||
throw { code: "NOT_FOUND", message: `slash command ${normalized}` } as GatewayError;
|
||||
}
|
||||
if (command.availability.status === "unavailable") {
|
||||
throw {
|
||||
code: "INVALID",
|
||||
message: `slash command ${normalized} unavailable: ${command.availability.reason}`,
|
||||
} as GatewayError;
|
||||
}
|
||||
if (command.native === "clean") {
|
||||
if (!options.sessionId) {
|
||||
throw {
|
||||
code: "INVALID",
|
||||
message: "/clean requires a current session id",
|
||||
} as GatewayError;
|
||||
}
|
||||
this.chatScrollback.set(options.sessionId, []);
|
||||
return {
|
||||
command: structuredClone(command),
|
||||
effect: {
|
||||
kind: "cleanConversation",
|
||||
sessionId: options.sessionId,
|
||||
clearedChunks: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: structuredClone(command),
|
||||
effect: {
|
||||
kind: "help",
|
||||
commands: structuredClone(this.slashCommands),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async closeAgentChat(sessionId: string): Promise<void> {
|
||||
this.chatScrollback.delete(sessionId);
|
||||
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
||||
|
||||
Reference in New Issue
Block a user