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(),

View File

@ -23,6 +23,7 @@ import type {
ReplyProgressKind,
ReplyProgressSource,
ReplyProgressStage,
SlashCommand,
} from "@/domain";
import { useGateways } from "@/app/di";
import { Button, Spinner, cn } from "@/shared";
@ -259,6 +260,16 @@ function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[]
.filter((file): file is File => Boolean(file));
}
function slashCommandPrefix(draft: string): string | null {
if (!draft.startsWith("/")) return null;
if (/\s/.test(draft)) return null;
return draft;
}
function isSlashCommandAvailable(command: SlashCommand): boolean {
return command.availability.status === "available";
}
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
@ -314,12 +325,17 @@ export function CustomAgentChatView({
const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
const [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession;
const slashQuerySeqRef = useRef(0);
const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0);
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
@ -336,6 +352,7 @@ export function CustomAgentChatView({
agent.cancelAgentChat &&
agent.closeAgentChat,
);
const slashPrefix = slashCommandPrefix(draft);
useEffect(() => {
mountedRef.current = true;
@ -349,6 +366,47 @@ export function CustomAgentChatView({
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
}, []);
useEffect(() => {
if (
!supported ||
!agent.listSlashCommands ||
slashPrefix === null ||
busy ||
opening
) {
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
return;
}
const seq = ++slashQuerySeqRef.current;
agent
.listSlashCommands(slashPrefix)
.then((commands) => {
if (slashQuerySeqRef.current !== seq) return;
setSlashCommands(commands);
setSlashMenuOpen(commands.length > 0);
const firstAvailable = commands.findIndex(isSlashCommandAvailable);
setSlashActiveIndex(firstAvailable >= 0 ? firstAvailable : 0);
})
.catch(() => {
if (slashQuerySeqRef.current !== seq) return;
setSlashCommands([]);
setSlashMenuOpen(false);
setSlashActiveIndex(0);
});
}, [agent, busy, opening, slashPrefix, supported]);
const selectSlashCommand = useCallback((command: SlashCommand) => {
if (!isSlashCommandAvailable(command)) return;
setDraft(`${command.name} `);
setSlashMenuOpen(false);
setSlashCommands([]);
setSlashActiveIndex(0);
requestAnimationFrame(() => composerRef.current?.focus());
}, []);
const publishSessionId = useCallback((nextSessionId: string | null) => {
selfEmittedSessionIdRef.current = nextSessionId;
onSessionIdRef.current(nextSessionId);
@ -795,9 +853,52 @@ export function CustomAgentChatView({
))}
</div>
)}
{slashMenuOpen && slashCommands.length > 0 && (
<div
id={`slash-command-menu-${nodeId}`}
role="listbox"
aria-label="suggestions commandes slash"
className="max-h-48 overflow-y-auto rounded-md border border-border bg-surface py-1 shadow-lg"
>
{slashCommands.map((command, index) => {
const available = isSlashCommandAvailable(command);
const selected = index === slashActiveIndex;
return (
<button
key={command.name}
type="button"
id={`slash-command-${nodeId}-${index}`}
role="option"
aria-selected={selected}
aria-disabled={!available}
className={cn(
"flex w-full min-w-0 items-start gap-3 px-2.5 py-1.5 text-left text-xs",
selected ? "bg-primary/15 text-content" : "text-content",
!available && "cursor-not-allowed opacity-55",
)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => selectSlashCommand(command)}
>
<span className="shrink-0 font-semibold">{command.name}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{command.shortDescription}
</span>
{command.requiresConfirmation && (
<span className="shrink-0 text-muted">confirm</span>
)}
</button>
);
})}
</div>
)}
<div className="flex min-w-0 items-end gap-2">
<textarea
ref={composerRef}
aria-label={`message CLI custom ${nodeId}`}
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
aria-activedescendant={
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
}
className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
@ -809,6 +910,31 @@ export function CustomAgentChatView({
onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => void pasteClipboardImages(e)}
onKeyDown={(e) => {
if (slashMenuOpen && slashCommands.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSlashActiveIndex((prev) => (prev + 1) % slashCommands.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSlashActiveIndex(
(prev) => (prev - 1 + slashCommands.length) % slashCommands.length,
);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setSlashMenuOpen(false);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const command = slashCommands[slashActiveIndex];
if (command) selectSlashCommand(command);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void send();