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:
@ -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",
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user