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

@ -380,6 +380,148 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done");
});
it("shows slash-command suggestions from the gateway, filters by prefix, and inserts the selected command", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
listSlashCommands: vi.fn(async (prefix?: string) =>
[
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
{
name: "/commit",
shortDescription: "Préparer un commit",
requiresConfirmation: true,
availability: { status: "available" as const },
source: { plugin: { pluginId: "git" } },
},
].filter((command) => !prefix || command.name.startsWith(prefix)),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/c" } });
const menu = await screen.findByRole("listbox", {
name: "suggestions commandes slash",
});
expect(agent.listSlashCommands).toHaveBeenLastCalledWith("/c");
expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy();
expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy();
fireEvent.keyDown(composer, { key: "ArrowDown" });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() => expect(composer.value).toBe("/commit "));
expect(menu).toBeTruthy();
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
it("only opens slash-command suggestions at the start of an unfinished slash token", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
listSlashCommands: vi.fn(async () => [
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
]),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "hello /c" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
expect(agent.listSlashCommands).not.toHaveBeenCalled();
fireEvent.change(composer, { target: { value: "/" } });
await screen.findByRole("listbox", { name: "suggestions commandes slash" });
fireEvent.change(composer, { target: { value: "/clean now" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
});
it("pastes a clipboard image as a removable preview chip", async () => {
const agent = {
launchAgentChat: vi.fn(),