merge(cli): intègre feature/163-slash-command-autocomplete — menu slash-commands #163 (QA verte)

UX d'autocomplétion slash dans le composer custom, consommant le contrat
unifié de #162 (aucune liste codée en dur). Débloque #164 (/profile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 15:02:49 +02:00
7 changed files with 467 additions and 0 deletions

View File

@ -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 () => { it("launchAgentChat returns a handle only when launch_agent confirms cellKind chat", async () => {
invoke.mockResolvedValueOnce({ invoke.mockResolvedValueOnce({
sessionId: "chat-session-1", sessionId: "chat-session-1",

View File

@ -19,8 +19,10 @@ import type {
AgentContextDocument, AgentContextDocument,
EffortSelection, EffortSelection,
GatewayError, GatewayError,
ExecuteSlashCommandResult,
ReplyChunk, ReplyChunk,
ResumableAgent, ResumableAgent,
SlashCommand,
TerminalSession, TerminalSession,
} from "@/domain"; } from "@/domain";
import type { import type {
@ -274,6 +276,26 @@ export class TauriAgentGateway implements AgentGateway {
}); });
} }
async listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
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<ExecuteSlashCommandResult> {
return invoke<ExecuteSlashCommandResult>("execute_slash_command", {
request: {
name,
sessionId: options.sessionId ?? null,
},
});
}
async closeAgentChat(sessionId: string): Promise<void> { async closeAgentChat(sessionId: string): Promise<void> {
await invoke("close_agent_session", { sessionId }); await invoke("close_agent_session", { sessionId });
} }

View File

@ -79,6 +79,8 @@ import type {
ServerExposureSettings, ServerExposureSettings,
Skill, Skill,
ReplyChunk, ReplyChunk,
ExecuteSlashCommandResult,
SlashCommand,
SkillScope, SkillScope,
Sprint, Sprint,
Template, Template,
@ -399,6 +401,35 @@ export class MockAgentGateway implements AgentGateway {
private liveKindByAgent = new Map<string, "pty" | "structured">(); private liveKindByAgent = new Map<string, "pty" | "structured">();
/** Retained structured reply chunks per live chat session. */ /** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>(); 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[] { private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []); 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> { async closeAgentChat(sessionId: string): Promise<void> {
this.chatScrollback.delete(sessionId); this.chatScrollback.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) { for (const [agentId, liveSessionId] of this.liveSessionByAgent) {

View File

@ -1681,6 +1681,35 @@ export type ReplyChunk =
| { kind: "final"; content: string } | { kind: "final"; content: string }
| { kind: "error"; message: 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) // Paired devices + pairing code (ticket #77)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -380,6 +380,148 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done"); 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 () => { it("pastes a clipboard image as a removable preview chip", async () => {
const agent = { const agent = {
launchAgentChat: vi.fn(), launchAgentChat: vi.fn(),

View File

@ -23,6 +23,7 @@ import type {
ReplyProgressKind, ReplyProgressKind,
ReplyProgressSource, ReplyProgressSource,
ReplyProgressStage, ReplyProgressStage,
SlashCommand,
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di"; import { useGateways } from "@/app/di";
import { Button, Spinner, cn } from "@/shared"; import { Button, Spinner, cn } from "@/shared";
@ -259,6 +260,16 @@ function clipboardImageFiles(event: ClipboardEvent<HTMLTextAreaElement>): File[]
.filter((file): file is File => Boolean(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[] { function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
if (!isReplyRecord(raw)) { if (!isReplyRecord(raw)) {
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }]; return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
@ -314,12 +325,17 @@ export function CustomAgentChatView({
const [externalSessionId, setExternalSessionId] = useState(sessionId); const [externalSessionId, setExternalSessionId] = useState(sessionId);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [attachments, setAttachments] = useState<AttachmentDraft[]>([]); 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 [opening, setOpening] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const sessionRef = useRef<string | null>(sessionId); const sessionRef = useRef<string | null>(sessionId);
sessionRef.current = currentSession; sessionRef.current = currentSession;
const slashQuerySeqRef = useRef(0);
const mountedRef = useRef(false); const mountedRef = useRef(false);
const openOrAttachCountRef = useRef(0); const openOrAttachCountRef = useRef(0);
const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined); const selfEmittedSessionIdRef = useRef<string | null | undefined>(undefined);
@ -336,6 +352,7 @@ export function CustomAgentChatView({
agent.cancelAgentChat && agent.cancelAgentChat &&
agent.closeAgentChat, agent.closeAgentChat,
); );
const slashPrefix = slashCommandPrefix(draft);
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@ -349,6 +366,47 @@ export function CustomAgentChatView({
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false); 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) => { const publishSessionId = useCallback((nextSessionId: string | null) => {
selfEmittedSessionIdRef.current = nextSessionId; selfEmittedSessionIdRef.current = nextSessionId;
onSessionIdRef.current(nextSessionId); onSessionIdRef.current(nextSessionId);
@ -795,9 +853,52 @@ export function CustomAgentChatView({
))} ))}
</div> </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"> <div className="flex min-w-0 items-end gap-2">
<textarea <textarea
ref={composerRef}
aria-label={`message CLI custom ${nodeId}`} aria-label={`message CLI custom ${nodeId}`}
aria-controls={slashMenuOpen ? `slash-command-menu-${nodeId}` : undefined}
aria-activedescendant={
slashMenuOpen ? `slash-command-${nodeId}-${slashActiveIndex}` : undefined
}
className={cn( className={cn(
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none", "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", "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
@ -809,6 +910,31 @@ export function CustomAgentChatView({
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onPaste={(e) => void pasteClipboardImages(e)} onPaste={(e) => void pasteClipboardImages(e)}
onKeyDown={(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) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
void send(); void send();

View File

@ -76,6 +76,8 @@ import type {
ResolvedAgentPermissions, ResolvedAgentPermissions,
ResolvedAgentSystemPermissions, ResolvedAgentSystemPermissions,
ReplyChunk, ReplyChunk,
SlashCommand,
ExecuteSlashCommandResult,
ServerExposurePreview, ServerExposurePreview,
ServerExposureSettings, ServerExposureSettings,
Skill, Skill,
@ -305,6 +307,16 @@ export interface AgentGateway {
onChunk: (chunk: ReplyChunk) => void, onChunk: (chunk: ReplyChunk) => void,
options?: SendAgentChatOptions, options?: SendAgentChatOptions,
): Promise<void>; ): Promise<void>;
/**
* Lists/filter the unified slash-command registry for the custom structured
* CLI. The backend owns command metadata and future plugin contributions.
*/
listSlashCommands?(prefix?: string): Promise<SlashCommand[]>;
/** Executes a selected slash command and returns the planned UI effect. */
executeSlashCommand?(
name: string,
options?: { sessionId?: string | null },
): Promise<ExecuteSlashCommandResult>;
/** Interrupts only the current turn of a live structured session. */ /** Interrupts only the current turn of a live structured session. */
cancelAgentChat?(sessionId: string): Promise<void>; cancelAgentChat?(sessionId: string): Promise<void>;
/** Shuts a live structured session down. */ /** Shuts a live structured session down. */