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:
@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user