From 853d8229f2f225297c26a1dede5915993a12bc60 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 6 Aug 2026 15:02:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=20menu=20contextuel=20d'autocompl?= =?UTF-8?q?=C3=A9tion=20des=20slash-commands=20dans=20le=20composer=20?= =?UTF-8?q?=E2=80=94=20#163=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit À 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 --- frontend/src/adapters/agent.test.ts | 50 ++++++ frontend/src/adapters/agent.ts | 22 +++ frontend/src/adapters/mock/index.ts | 86 +++++++++++ frontend/src/domain/index.ts | 29 ++++ .../agents/CustomAgentChatView.test.tsx | 142 ++++++++++++++++++ .../features/agents/CustomAgentChatView.tsx | 126 ++++++++++++++++ frontend/src/ports/index.ts | 12 ++ 7 files changed, 467 insertions(+) diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index 216d4b2..bd39cf2 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -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", diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index e09dd9b..78189a3 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -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 { + 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 { + return invoke("execute_slash_command", { + request: { + name, + sessionId: options.sessionId ?? null, + }, + }); + } + async closeAgentChat(sessionId: string): Promise { await invoke("close_agent_session", { sessionId }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 1af2251..b26bc99 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -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(); /** Retained structured reply chunks per live chat session. */ private chatScrollback = new Map(); + 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 { + 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 { + 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 { this.chatScrollback.delete(sessionId); for (const [agentId, liveSessionId] of this.liveSessionByAgent) { diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 219fdc9..0157f5d 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -1681,6 +1681,35 @@ export type ReplyChunk = | { kind: "final"; content: string } | { kind: "error"; message: string }; +export type SlashCommandAvailability = + | { status: "available" } + | { status: "unavailable"; reason: string }; + +export type SlashCommandSource = + | "native" + | { plugin: { pluginId: string } }; + +export type NativeSlashCommand = "help" | "clean" | "profile"; + +export interface SlashCommand { + name: string; + shortDescription: string; + requiresConfirmation: boolean; + availability: SlashCommandAvailability; + source: SlashCommandSource; + native?: NativeSlashCommand; +} + +export type SlashCommandEffect = + | { kind: "help"; commands: SlashCommand[] } + | { kind: "cleanConversation"; sessionId: string; clearedChunks: number } + | { kind: "profileSwitch"; sessionId: string }; + +export interface ExecuteSlashCommandResult { + command: SlashCommand; + effect: SlashCommandEffect; +} + // --------------------------------------------------------------------------- // Paired devices + pairing code (ticket #77) // --------------------------------------------------------------------------- diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index f820f17..5df7c9d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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( + null) }, + } as unknown as Gateways} + > + + , + ); + + 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(), diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index a6825d3..c938aef 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -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): 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([]); + const [slashCommands, setSlashCommands] = useState([]); + const [slashMenuOpen, setSlashMenuOpen] = useState(false); + const [slashActiveIndex, setSlashActiveIndex] = useState(0); const [opening, setOpening] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const scrollRef = useRef(null); + const composerRef = useRef(null); const sessionRef = useRef(sessionId); sessionRef.current = currentSession; + const slashQuerySeqRef = useRef(0); const mountedRef = useRef(false); const openOrAttachCountRef = useRef(0); const selfEmittedSessionIdRef = useRef(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({ ))} )} + {slashMenuOpen && slashCommands.length > 0 && ( +
+ {slashCommands.map((command, index) => { + const available = isSlashCommandAvailable(command); + const selected = index === slashActiveIndex; + return ( + + ); + })} +
+ )}