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:
2026-08-06 15:02:40 +02:00
parent b51011d206
commit 853d8229f2
7 changed files with 467 additions and 0 deletions

View File

@ -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) {