diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 26d88a1962c1..b30f0e4170b4 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -12155,6 +12155,7 @@ public struct CommandEntry: Codable, Sendable { public let description: String public let category: AnyCodable? public let source: AnyCodable + public let skillmodelvisible: Bool? public let scope: AnyCodable public let acceptsargs: Bool public let args: [[String: AnyCodable]]? @@ -12166,6 +12167,7 @@ public struct CommandEntry: Codable, Sendable { description: String, category: AnyCodable? = nil, source: AnyCodable, + skillmodelvisible: Bool? = nil, scope: AnyCodable, acceptsargs: Bool, args: [[String: AnyCodable]]? = nil) @@ -12176,6 +12178,7 @@ public struct CommandEntry: Codable, Sendable { self.description = description self.category = category self.source = source + self.skillmodelvisible = skillmodelvisible self.scope = scope self.acceptsargs = acceptsargs self.args = args @@ -12188,6 +12191,7 @@ public struct CommandEntry: Codable, Sendable { case description case category case source + case skillmodelvisible = "skillModelVisible" case scope case acceptsargs = "acceptsArgs" case args diff --git a/docs/docs_map.md b/docs/docs_map.md index 588babdd0330..e65a0aa51124 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -10611,6 +10611,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Per-agent vs shared skills - H2: Agent allowlists - H2: Plugins and skills + - H2: Reference a skill in a prompt - H2: Skill Workshop - H2: Installing from ClawHub - H2: Security diff --git a/docs/tools/skills.md b/docs/tools/skills.md index ebbfcc08e1da..d49efa1ab201 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -144,6 +144,34 @@ skill overrides them. Gate a plugin skill's own eligibility via See [Plugins](/tools/plugin) and [Tools](/tools) for the full plugin system. +## Reference a skill in a prompt + +Type `$` in the Control UI composer to search the skills available to the +current agent. Selecting a result inserts its stable command name, for example +`$release_notes`, without replacing the rest of your message. A prompt can +reference more than one skill: + +```text +Use $github and $release_notes to summarize this change for the release. +``` + +OpenClaw resolves these references against the current agent's eligible, +user-invocable, model-visible skills and tells the model to read each referenced `SKILL.md` +before acting. A single message can reference up to eight distinct skills; +OpenClaw returns a visible error instead of ignoring extra references. The `$` +form is composable prompt text; `/release_notes ...` +remains the standalone command form and may use direct tool dispatch when the +skill declares `command-dispatch: tool`. Common uppercase shell variables such +as `$HOME`, `$PATH`, and `$EDITOR` remain ordinary text; use lowercase +`$home`, `$path`, or `$editor` to reference skills with those names. + +Skills with `disable-model-invocation: true` stay out of the `$` picker because +their instructions are intentionally absent from the model's prompt. Invoke +those explicitly with their standalone slash command instead. + +`$` references are interpreted on WebChat/Control UI turns. Other messaging +channels keep `$name` as ordinary text; use the skill's slash command there. + ## Skill Workshop [Skill Workshop](/tools/skill-workshop) is a proposal queue between the agent diff --git a/packages/gateway-protocol/src/schema/commands.ts b/packages/gateway-protocol/src/schema/commands.ts index f1b01534b7c0..2dbe1adc0c9a 100644 --- a/packages/gateway-protocol/src/schema/commands.ts +++ b/packages/gateway-protocol/src/schema/commands.ts @@ -88,6 +88,8 @@ export const CommandEntrySchema = closedObject({ description: Type.String({ maxLength: COMMAND_DESCRIPTION_MAX_LENGTH }), category: Type.Optional(CommandCategorySchema), source: CommandSourceSchema, + /** Whether a skill command is also present in the model-visible skill catalog. */ + skillModelVisible: Type.Optional(Type.Boolean()), scope: CommandScopeSchema, acceptsArgs: Type.Boolean(), args: Type.Optional(Type.Array(CommandArgSchema, { maxItems: COMMAND_ARGS_MAX_ITEMS })), diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index aa0f2870e518..1be3d5123b47 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -779,6 +779,143 @@ describe("handleInlineActions", () => { expect(commandArgs.skillCommands).toEqual(skillCommands); }); + it("keeps normal prompt text while making $ skill references explicit to the model", async () => { + const typing = createTypingController(); + const original = "Review this plan with $office_hours and $release_notes."; + const ctx = buildTestCtx({ + Body: original, + CommandBody: original, + Provider: "webchat", + Surface: "webchat", + }); + const skillCommands: SkillCommandSpec[] = [ + { + name: "office_hours", + skillName: "office-hours", + description: "Engineering office hours", + }, + { + name: "release_notes", + skillName: "release-notes", + description: "Draft release notes", + }, + ]; + + const result = await handleInlineActions( + createHandleInlineActionsInput({ + ctx, + typing, + cleanedBody: original, + command: { + isAuthorizedSender: true, + rawBodyNormalized: original, + commandBodyNormalized: original, + }, + overrides: { + allowTextCommands: true, + cfg: { commands: { text: true } }, + skillCommands, + }, + }), + ); + + expect(result.kind).toBe("continue"); + if (result.kind !== "continue") { + throw new Error("expected referenced skills to continue to the model"); + } + expect(result.cleanedBody).toBe( + [ + "Use the following explicitly referenced skills for this request. Read each skill's SKILL.md before acting:", + "- office-hours", + "- release-notes", + "", + "User request:", + original, + ].join("\n"), + ); + expect(ctx.Body).toBe(result.cleanedBody); + expect(handleCommandsMock).not.toHaveBeenCalled(); + }); + + it("returns a visible error instead of silently dropping excess skill references", async () => { + const typing = createTypingController(); + const skillCommands: SkillCommandSpec[] = Array.from({ length: 9 }, (_, index) => ({ + name: `skill_${index + 1}`, + skillName: `skill-${index + 1}`, + description: `Skill ${index + 1}`, + })); + const original = skillCommands.map((skill) => `$${skill.name}`).join(" "); + const ctx = buildTestCtx({ + Body: original, + CommandBody: original, + Provider: "webchat", + Surface: "webchat", + }); + + const result = await handleInlineActions( + createHandleInlineActionsInput({ + ctx, + typing, + cleanedBody: original, + command: { + isAuthorizedSender: true, + rawBodyNormalized: original, + commandBodyNormalized: original, + }, + overrides: { + allowTextCommands: true, + cfg: { commands: { text: true } }, + skillCommands, + }, + }), + ); + + expect(result).toEqual({ + kind: "reply", + reply: { text: "Too many skill references. Use at most 8 skills in one message." }, + }); + expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(handleCommandsMock).not.toHaveBeenCalled(); + }); + + it("keeps $ skill references literal on message channels", async () => { + const typing = createTypingController(); + const original = "Review with $office_hours."; + const ctx = buildTestCtx({ Body: original, CommandBody: original }); + + const result = await handleInlineActions( + createHandleInlineActionsInput({ + ctx, + typing, + cleanedBody: original, + command: { + isAuthorizedSender: true, + rawBodyNormalized: original, + commandBodyNormalized: original, + }, + overrides: { + allowTextCommands: true, + cfg: { commands: { text: true } }, + skillCommands: [ + { + name: "office_hours", + skillName: "office-hours", + description: "Engineering office hours", + modelVisible: true, + }, + ], + }, + }), + ); + + expect(result.kind).toBe("continue"); + if (result.kind !== "continue") { + throw new Error("expected message-channel text to continue unchanged"); + } + expect(result.cleanedBody).toBe(original); + expect(ctx.Body).toBe(original); + }); + it("reloads preloaded skill commands when final exec overrides are present", async () => { const typing = createTypingController(); handleCommandsMock.mockResolvedValue({ shouldContinue: false, reply: { text: "done" } }); diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 1980f0923f65..e96d04712305 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -14,10 +14,13 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { + hasSkillReferenceCandidate, listReservedChatSlashCommandNames, resolveSkillCommandInvocation, + resolveSkillReferenceInvocations, } from "../../skills/discovery/chat-commands.js"; import type { SkillCommandSpec } from "../../skills/types.js"; +import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { markCommandReplyForDelivery } from "../reply-payload.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import type { @@ -69,6 +72,7 @@ const commandsRuntimeLoader = createLazyImportLoader( () => import("./commands.runtime.js"), ); let builtinSlashCommands: Set | null = null; +const MAX_EXPLICIT_SKILL_REFERENCES = 8; function loadSkillCommandsRuntime(): Promise { return skillCommandsRuntimeLoader.load(); @@ -114,6 +118,26 @@ function resolveSlashCommandName(commandBodyNormalized: string): string | null { return name ? name : null; } +function applyExplicitSkillReferences( + body: string, + skillCommands: SkillCommandSpec[], +): { body: string; overflow: boolean; skills: SkillCommandSpec[] } { + const resolved = resolveSkillReferenceInvocations({ text: body, skillCommands }); + const overflow = resolved.length > MAX_EXPLICIT_SKILL_REFERENCES; + const skills = resolved.slice(0, MAX_EXPLICIT_SKILL_REFERENCES); + if (skills.length === 0) { + return { body, overflow, skills }; + } + const instruction = [ + "Use the following explicitly referenced skills for this request. Read each skill's SKILL.md before acting:", + ...skills.map((skill) => `- ${skill.skillName}`), + "", + "User request:", + body, + ].join("\n"); + return { body: instruction, overflow, skills }; +} + function expandBundleCommandPromptTemplate(template: string, args?: string): string { const normalizedArgs = normalizeOptionalString(args) || ""; const rendered = template.includes("$ARGUMENTS") @@ -323,11 +347,16 @@ export async function handleInlineActions(params: { } const slashCommandName = resolveSlashCommandName(command.commandBodyNormalized); + const hasSkillReferences = + command.isAuthorizedSender && + ctx.Surface === INTERNAL_MESSAGE_CHANNEL && + hasSkillReferenceCandidate(initialCleanedBody); const shouldLoadSkillCommands = allowTextCommands && - slashCommandName !== null && - // `/skill …` needs the full skill command list. - (slashCommandName === "skill" || !getBuiltinSlashCommands().has(slashCommandName)); + (hasSkillReferences || + (slashCommandName !== null && + // `/skill …` needs the full skill command list. + (slashCommandName === "skill" || !getBuiltinSlashCommands().has(slashCommandName)))); const canReusePreloadedSkillCommands = execOverrides === undefined; const skillCommands = shouldLoadSkillCommands && @@ -489,6 +518,34 @@ export async function handleInlineActions(params: { sessionCtx.BodyStripped = cleanedBody; } + if ( + hasSkillReferences && + !skillInvocation && + resolveSlashCommandName(cleanedBody) === null && + skillCommands.length > 0 + ) { + const referenced = applyExplicitSkillReferences(cleanedBody, skillCommands); + if (referenced.overflow) { + typing.cleanup(); + return { + kind: "reply", + reply: markCommandReplyForDelivery({ + text: `Too many skill references. Use at most ${MAX_EXPLICIT_SKILL_REFERENCES} skills in one message.`, + }), + }; + } + if (referenced.skills.length > 0) { + cleanedBody = referenced.body; + ctx.Body = cleanedBody; + ctx.agentText = cleanedBody; + ctx.BodyForAgent = cleanedBody; + sessionCtx.Body = cleanedBody; + sessionCtx.agentText = cleanedBody; + sessionCtx.BodyForAgent = cleanedBody; + sessionCtx.BodyStripped = cleanedBody; + } + } + const handleInlineStatus = !isDirectiveOnly({ directives, diff --git a/src/gateway/server-methods/commands-list-result.ts b/src/gateway/server-methods/commands-list-result.ts index b79f74640b18..674db1d1cd40 100644 --- a/src/gateway/server-methods/commands-list-result.ts +++ b/src/gateway/server-methods/commands-list-result.ts @@ -219,7 +219,7 @@ export function buildCommandsListResult(params: { const skillCommands = listSkillCommandsForAgents({ cfg: params.cfg, agentIds: [params.agentId] }); const chatCommands = listChatCommandsForConfig(params.cfg, { skillCommands }); - const skillKeys = new Set(skillCommands.map((sc) => `skill:${sc.skillName}`)); + const skillsByKey = new Map(skillCommands.map((skill) => [`skill:${skill.skillName}`, skill])); const commands: CommandEntry[] = []; @@ -234,15 +234,11 @@ export function buildCommandsListResult(params: { ) { continue; } - commands.push( - mapCommand( - cmd, - skillKeys.has(cmd.key) ? "skill" : "native", - includeArgs, - nameSurface, - provider, - ), - ); + const skill = skillsByKey.get(cmd.key); + commands.push({ + ...mapCommand(cmd, skill ? "skill" : "native", includeArgs, nameSurface, provider), + ...(skill ? { skillModelVisible: skill.modelVisible !== false } : {}), + }); } commands.push(...buildPluginCommandEntries({ provider, nameSurface, cfg: params.cfg })); diff --git a/src/gateway/server-methods/commands.test.ts b/src/gateway/server-methods/commands.test.ts index 28c84779c0f1..16bda54eb545 100644 --- a/src/gateway/server-methods/commands.test.ts +++ b/src/gateway/server-methods/commands.test.ts @@ -11,6 +11,7 @@ const mockSkillCommands = [ skillName: "code-review", name: "code_review", description: "Run code review", + modelVisible: true, acceptsArgs: true, }, ]; @@ -372,6 +373,7 @@ describe("commands.list handler", () => { const commands = listCommands(); const skill = commands.find((c) => c.name === "code_review"); expect(skill?.source).toBe("skill"); + expect(skill?.skillModelVisible).toBe(true); expect(skill?.category).toBe("tools"); }); diff --git a/src/skills/discovery/chat-command-invocation.ts b/src/skills/discovery/chat-command-invocation.ts index 88e2b50475c7..90daa73a19bc 100644 --- a/src/skills/discovery/chat-command-invocation.ts +++ b/src/skills/discovery/chat-command-invocation.ts @@ -59,6 +59,67 @@ function findSkillCommand( }); } +function skillReferenceMatches(text: string): IterableIterator { + return text.matchAll(/\$([-a-zA-Z0-9_:]+)/gu); +} + +function isEscapedReference(text: string, index: number): boolean { + let backslashes = 0; + for (let cursor = index - 1; cursor >= 0 && text[cursor] === "\\"; cursor -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + +function isShellVariableReference(name: string): boolean { + return !/[a-z]/u.test(name); +} + +/** Returns true when text may contain an explicit `$skill-name` reference. */ +export function hasSkillReferenceCandidate(text: string): boolean { + for (const match of skillReferenceMatches(text)) { + const name = match[1]?.replace(/:+$/gu, ""); + const index = match.index; + if ( + name && + index !== undefined && + !isEscapedReference(text, index) && + !isShellVariableReference(name) + ) { + return true; + } + } + return false; +} + +/** Resolves explicit `$skill-name` references against the current eligible skill commands. */ +export function resolveSkillReferenceInvocations(params: { + text: string; + skillCommands: SkillCommandSpec[]; +}): SkillCommandSpec[] { + const resolved: SkillCommandSpec[] = []; + const seen = new Set(); + for (const match of skillReferenceMatches(params.text)) { + const name = match[1]?.replace(/:+$/gu, ""); + const index = match.index; + if ( + !name || + index === undefined || + isEscapedReference(params.text, index) || + isShellVariableReference(name) + ) { + continue; + } + const command = findSkillCommand(params.skillCommands, name); + if (!command || command.modelVisible === false || seen.has(command.name)) { + continue; + } + seen.add(command.name); + resolved.push(command); + } + return resolved; +} + export function resolveSkillCommandInvocation(params: { commandBodyNormalized: string; skillCommands: SkillCommandSpec[]; diff --git a/src/skills/discovery/chat-commands.test.ts b/src/skills/discovery/chat-commands.test.ts index 5264908f6ab5..005ee0328b0c 100644 --- a/src/skills/discovery/chat-commands.test.ts +++ b/src/skills/discovery/chat-commands.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites let listSkillCommandsForAgents: typeof import("./chat-commands.js").listSkillCommandsForAgents; let listSkillCommandsForWorkspace: typeof import("./chat-commands.js").listSkillCommandsForWorkspace; let resolveSkillCommandInvocation: typeof import("./chat-commands.js").resolveSkillCommandInvocation; +let resolveSkillReferenceInvocations: typeof import("./chat-commands.js").resolveSkillReferenceInvocations; const tempDirs: string[] = []; const resolveNodeExecEligibilityMock = vi.hoisted(() => @@ -161,8 +162,12 @@ vi.mock("./agent-filter.js", () => ({ })); beforeAll(async () => { - ({ listSkillCommandsForAgents, listSkillCommandsForWorkspace, resolveSkillCommandInvocation } = - await import("./chat-commands.js")); + ({ + listSkillCommandsForAgents, + listSkillCommandsForWorkspace, + resolveSkillCommandInvocation, + resolveSkillReferenceInvocations, + } = await import("./chat-commands.js")); }); afterAll(async () => { @@ -229,6 +234,83 @@ describe("resolveSkillCommandInvocation", () => { }); }); +describe("resolveSkillReferenceInvocations", () => { + const skillCommands = [ + { name: "demo_skill", skillName: "demo-skill", description: "Demo" }, + { name: "release_notes", skillName: "Release Notes", description: "Release notes" }, + ]; + + it("resolves and deduplicates composable skill references", () => { + expect( + resolveSkillReferenceInvocations({ + text: "Use $demo_skill with $release-notes, then check $demo_skill again.", + skillCommands, + }).map((command) => command.name), + ).toEqual(["demo_skill", "release_notes"]); + }); + + it("keeps trailing prose punctuation outside the skill reference", () => { + expect( + resolveSkillReferenceInvocations({ + text: "Use $demo_skill: then continue.", + skillCommands, + }).map((command) => command.name), + ).toEqual(["demo_skill"]); + }); + + it("does not fall back to a shorter skill from a trailing hyphen", () => { + expect( + resolveSkillReferenceInvocations({ + text: "Use $demo_skill- later.", + skillCommands, + }), + ).toEqual([]); + }); + + it("ignores common shell variables, escaped references, and unknown names", () => { + expect( + resolveSkillReferenceInvocations({ + text: String.raw`Keep $HOME and \$demo_skill literal; $unknown is not installed.`, + skillCommands, + }), + ).toEqual([]); + }); + + it("keeps lowercase skill names that overlap common shell variables", () => { + expect( + resolveSkillReferenceInvocations({ + text: "Use $home but keep $HOME and $EDITOR literal.", + skillCommands: [{ name: "home", skillName: "home", description: "Home automation" }], + }).map((command) => command.name), + ).toEqual(["home"]); + }); + + it("treats only odd backslash runs as escaping a reference", () => { + expect( + resolveSkillReferenceInvocations({ + text: String.raw`Ignore \$demo_skill but resolve \\$demo_skill.`, + skillCommands, + }).map((command) => command.name), + ).toEqual(["demo_skill"]); + }); + + it("excludes slash-only skills that are hidden from the model prompt", () => { + expect( + resolveSkillReferenceInvocations({ + text: "Use $hidden_skill.", + skillCommands: [ + { + name: "hidden_skill", + skillName: "hidden-skill", + description: "Slash only", + modelVisible: false, + }, + ], + }), + ).toEqual([]); + }); +}); + describe("listSkillCommandsForAgents", () => { it("deduplicates by skillName across agents, keeping the first registration", async () => { const { mainWorkspace, researchWorkspace } = diff --git a/src/skills/discovery/chat-commands.ts b/src/skills/discovery/chat-commands.ts index ccf93c8285a5..2a9870950d10 100644 --- a/src/skills/discovery/chat-commands.ts +++ b/src/skills/discovery/chat-commands.ts @@ -18,8 +18,10 @@ import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js"; import { listReservedChatSlashCommandNames } from "./chat-command-invocation.js"; import { buildWorkspaceSkillCommandSpecs } from "./command-specs.js"; export { + hasSkillReferenceCandidate, listReservedChatSlashCommandNames, resolveSkillCommandInvocation, + resolveSkillReferenceInvocations, } from "./chat-command-invocation.js"; export function listSkillCommandsForWorkspace(params: { diff --git a/src/skills/discovery/command-specs.ts b/src/skills/discovery/command-specs.ts index 3f4ba5b7dc82..8ac915560223 100644 --- a/src/skills/discovery/command-specs.ts +++ b/src/skills/discovery/command-specs.ts @@ -15,7 +15,7 @@ import { } from "../loading/workspace.js"; import type { SkillEligibilityContext, SkillCommandSpec, SkillEntry } from "../types.js"; import { resolveEffectiveAgentSkillFilter } from "./agent-filter.js"; -import { filterUserInvocableSkillEntries } from "./skill-index.js"; +import { filterUserInvocableSkillEntries, isSkillPromptVisible } from "./skill-index.js"; const skillsLogger = createSubsystemLogger("skills"); const skillCommandDebugOnce = createDedupeCache({ ttlMs: 0, maxSize: 1024 }); @@ -176,6 +176,7 @@ export function buildWorkspaceSkillCommandSpecs( skillFile: canonicalizePath(entry.skill.filePath), skillName: rawName, description, + modelVisible: isSkillPromptVisible(entry), skillSource: resolveSkillTelemetrySource(entry.skill), ...(dispatch ? { dispatch } : {}), }); @@ -207,6 +208,7 @@ export function buildWorkspaceSkillCommandSpecs( name: unique, skillName: entry.rawName, description: entry.description, + modelVisible: false, promptTemplate: entry.promptTemplate, sourceFilePath: entry.sourceFilePath, }); diff --git a/src/skills/discovery/skill-index.ts b/src/skills/discovery/skill-index.ts index 8fbf081d54f0..b106e9589106 100644 --- a/src/skills/discovery/skill-index.ts +++ b/src/skills/discovery/skill-index.ts @@ -38,7 +38,7 @@ function isSkillRuntimeVisible(entry: SkillEntry): boolean { return entry.exposure?.includeInRuntimeRegistry ?? true; } -function isSkillPromptVisible(entry: SkillEntry): boolean { +export function isSkillPromptVisible(entry: SkillEntry): boolean { if (entry.exposure) { return entry.exposure.includeInAvailableSkillsPrompt ?? true; } diff --git a/src/skills/types.ts b/src/skills/types.ts index fd8b67563d61..25a9e1a9e28c 100644 --- a/src/skills/types.ts +++ b/src/skills/types.ts @@ -66,6 +66,8 @@ export type SkillCommandSpec = { skillFile?: string; skillName: string; description: string; + /** Whether the model can resolve this skill from its available-skills prompt. */ + modelVisible?: boolean; /** Bounded source label used for diagnostics. */ skillSource?: SkillTelemetrySource; /** Localized descriptions for native command surfaces that support them. */ diff --git a/ui/src/components/web-awesome-migration.node.test.ts b/ui/src/components/web-awesome-migration.node.test.ts index 31af81238eba..211e76aebfe4 100644 --- a/ui/src/components/web-awesome-migration.node.test.ts +++ b/ui/src/components/web-awesome-migration.node.test.ts @@ -48,6 +48,7 @@ describe("Web Awesome control ownership", () => { // This inventory tracks literal ARIA roles, not Web Awesome elements that own roles internally. expect(await matchingFiles(/<[a-z][^>]*\srole=["'](?:combobox|listbox|option)["']/u)).toEqual([ "components/command-palette.ts", + "pages/chat/components/chat-composer-skill-menu.ts", "pages/chat/components/chat-composer-slash-menu.ts", "pages/chat/components/chat-model-controls.ts", ]); diff --git a/ui/src/e2e/chat-skill-references.e2e.test.ts b/ui/src/e2e/chat-skill-references.e2e.test.ts new file mode 100644 index 000000000000..d9537b8b6a7b --- /dev/null +++ b/ui/src/e2e/chat-skill-references.e2e.test.ts @@ -0,0 +1,137 @@ +// Control UI E2E tests cover composable skill references in the chat composer. +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let server: ControlUiE2eServer; +let browser: Browser; + +describeControlUiE2e("Control UI skill references", () => { + beforeAll(async () => { + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + try { + server = await startControlUiE2eServer(); + } catch (error) { + await browser.close(); + throw error; + } + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("references multiple skills inside a normal prompt and sends the visible tokens", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { width: 1280, height: 900 } } } + : {}), + }); + const page = await context.newPage(); + const commands = [ + { + acceptsArgs: true, + description: "Pre-commit and ship code review.", + name: "autoreview", + scope: "both", + source: "skill", + skillModelVisible: true, + textAliases: ["/autoreview"], + }, + { + acceptsArgs: true, + description: "Build and review technical documentation.", + name: "technical_documentation", + scope: "both", + source: "skill", + skillModelVisible: true, + textAliases: ["/technical_documentation"], + }, + { + acceptsArgs: false, + description: "Show gateway status.", + name: "status", + scope: "both", + source: "native", + textAliases: ["/status"], + }, + ]; + const gateway = await installMockGateway(page, { + deferredMethods: ["chat.send"], + methodResponses: { + "chat.startup": { + agentsList: { + agents: [{ id: "main", name: "OpenClaw" }], + defaultId: "main", + mainKey: "main", + scope: "agent", + }, + messages: [], + metadata: { commands, models: [] }, + sessionId: "skill-reference-session", + thinkingLevel: null, + }, + "commands.list": { commands }, + }, + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await gateway.waitForRequest("chat.startup"); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await composer.fill("Review this with $auto"); + + const picker = page.getByRole("listbox", { name: "Skill references" }); + await picker.waitFor({ state: "visible" }); + await expect.poll(() => picker.getByRole("option").count()).toBe(1); + await expect + .poll(() => picker.getByRole("option").first().textContent()) + .toContain("$autoreview"); + await composer.press("Enter"); + await expect.poll(() => composer.inputValue()).toBe("Review this with $autoreview "); + + await composer.fill(`${await composer.inputValue()}and $technical`); + await expect.poll(() => picker.getByRole("option").count()).toBe(1); + await composer.press("Tab"); + await expect + .poll(() => composer.inputValue()) + .toBe("Review this with $autoreview and $technical_documentation "); + + if (artifactDir) { + await page.screenshot({ + path: path.join(artifactDir, "skill-references-selected.png"), + fullPage: true, + }); + } + + await page.getByRole("button", { name: "Send message" }).click(); + const request = await gateway.waitForRequest("chat.send"); + expect((request.params as { message?: unknown }).message).toBe( + "Review this with $autoreview and $technical_documentation", + ); + + await composer.fill("Print $HOME"); + await expect.poll(() => picker.count()).toBe(0); + await composer.fill("/"); + await page.getByRole("listbox", { name: "Slash commands" }).waitFor({ state: "visible" }); + await expect.poll(() => page.getByRole("option", { name: /\/status/u }).count()).toBe(1); + } finally { + await context.close(); + } + }); +}); diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index b5e9387807c0..25c3812a55e2 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -449,6 +449,27 @@ "path": "ui/src/pages/channels/view.nostr.ts", "text": "NIP-05" }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts", + "text": "Enter" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts", + "text": "Esc" + }, + { + "count": 1, + "kind": "html-text", + "name": "text", + "path": "ui/src/pages/chat/components/chat-composer-skill-menu.ts", + "text": "Tab" + }, { "count": 2, "kind": "html-text", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 11bad9bd2bd5..376d2580949a 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4724,6 +4724,11 @@ export const en: TranslationMap = { tools: "Tools", }, }, + skills: { + menu: "Skill references", + label: "Skills", + loading: "Loading skills…", + }, splitView: { open: "Open split view", splitRight: "Split right", diff --git a/ui/src/lib/chat/commands.browser-import.test.ts b/ui/src/lib/chat/commands.browser-import.test.ts index c0078a0e392b..71a1f77235c0 100644 --- a/ui/src/lib/chat/commands.browser-import.test.ts +++ b/ui/src/lib/chat/commands.browser-import.test.ts @@ -32,6 +32,7 @@ describe("slash command browser import", () => { executeLocal: true, argOptions: undefined, tier: "essential", + source: "native", }); }); diff --git a/ui/src/lib/chat/commands.test.ts b/ui/src/lib/chat/commands.test.ts index eb125cf5c89f..f4d36aaf111d 100644 --- a/ui/src/lib/chat/commands.test.ts +++ b/ui/src/lib/chat/commands.test.ts @@ -5,6 +5,7 @@ import { buildFallbackSlashCommands, buildSlashCommandsFromEntries, getRemoteCommandEntries, + getSkillCommandCompletions, parseSlashCommand, replaceSlashCommands, SLASH_COMMANDS, @@ -161,6 +162,7 @@ describe("parseSlashCommand", () => { textAliases: ["/prose"], description: "Draft polished prose.", source: "skill", + skillModelVisible: true, scope: "both", acceptsArgs: true, }, @@ -178,8 +180,62 @@ describe("parseSlashCommand", () => { expectRecordFields(requireCommandByName("prose"), "prose command", { key: "prose", executeLocal: false, + source: "skill", + skillModelVisible: true, }); expectParsedSlash("/dock_discord", { name: "dock-discord" }, ""); + expect(getSkillCommandCompletions("pro").map((command) => command.name)).toEqual(["prose"]); + }); + + it("normalizes hyphenated skill reference queries", () => { + applyRemoteEntries([ + { + name: "release_notes", + textAliases: ["/release_notes"], + description: "Draft release notes.", + source: "skill", + skillModelVisible: true, + scope: "both", + acceptsArgs: true, + }, + ]); + + expect(getSkillCommandCompletions("release-n").map((command) => command.name)).toEqual([ + "release_notes", + ]); + }); + + it("keeps model-hidden skills in slash commands but out of $ completions", () => { + applyRemoteEntries([ + { + name: "hidden_skill", + textAliases: ["/hidden_skill"], + description: "Slash-only skill.", + source: "skill", + skillModelVisible: false, + scope: "both", + acceptsArgs: true, + }, + ]); + + expectParsedSlash("/hidden_skill", { name: "hidden_skill" }, ""); + expect(getSkillCommandCompletions("hidden")).toEqual([]); + }); + + it("fails closed when an older gateway omits skill visibility metadata", () => { + applyRemoteEntries([ + { + name: "legacy_skill", + textAliases: ["/legacy_skill"], + description: "Legacy skill command.", + source: "skill", + scope: "both", + acceptsArgs: true, + }, + ]); + + expectParsedSlash("/legacy_skill", { name: "legacy_skill" }, ""); + expect(getSkillCommandCompletions("legacy")).toEqual([]); }); it("does not let remote commands collide with reserved local commands", () => { diff --git a/ui/src/lib/chat/commands.ts b/ui/src/lib/chat/commands.ts index ca2b6f29419f..707880ab243b 100644 --- a/ui/src/lib/chat/commands.ts +++ b/ui/src/lib/chat/commands.ts @@ -29,6 +29,8 @@ export type SlashCommandDef = { shortcut?: string; /** Progressive disclosure tier. Defaults to "standard" when omitted. */ tier?: SlashCommandTier; + source?: "native" | "plugin" | "skill"; + skillModelVisible?: boolean; }; type LocalArgChoice = string | { value: string; label: string }; @@ -45,6 +47,8 @@ type CommandLike = { }>; category?: string; tier?: string; + source?: "native" | "plugin" | "skill"; + skillModelVisible?: boolean; }; const REMOTE_SLASH_IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u; @@ -239,18 +243,25 @@ function toSlashCommand( if (!name) { return null; } + const resolvedSource = command.source ?? (source === "local" ? "native" : undefined); return { key: command.key, name, aliases: getSlashAliases(command).filter((alias) => alias !== name), description: COMMAND_DESCRIPTION_OVERRIDES[command.key] ?? command.description, - descriptionKey: COMMAND_DESCRIPTION_KEYS[command.key], + ...(COMMAND_DESCRIPTION_KEYS[command.key] + ? { descriptionKey: COMMAND_DESCRIPTION_KEYS[command.key] } + : {}), args: COMMAND_ARGS_OVERRIDES[command.key] ?? formatArgs(command), icon: mapIcon(command), category: mapCategory(command), executeLocal: source === "local" && LOCAL_COMMANDS.has(command.key), argOptions: getArgOptions(command), tier: source === "local" ? mapTier(command) : "standard", + ...(resolvedSource ? { source: resolvedSource } : {}), + ...(command.skillModelVisible !== undefined + ? { skillModelVisible: command.skillModelVisible } + : {}), }; } @@ -381,6 +392,12 @@ function normalizeCommandEntry( description: clampText(entry.description, MAX_REMOTE_DESCRIPTION_LENGTH), ...(args.length > 0 ? { args } : {}), category: typeof entry.category === "string" ? entry.category : undefined, + source: + entry.source === "native" || entry.source === "plugin" || entry.source === "skill" + ? entry.source + : undefined, + skillModelVisible: + typeof entry.skillModelVisible === "boolean" ? entry.skillModelVisible : undefined, }; } @@ -485,6 +502,22 @@ export function getSlashCommandCompletions( }); } +export function getSkillCommandCompletions(filter: string): SlashCommandDef[] { + const lower = normalizeLowercaseStringOrEmpty(filter); + const normalized = lower.replace(/-/gu, "_"); + return SLASH_COMMANDS.filter( + (command) => command.source === "skill" && command.skillModelVisible === true, + ) + .filter( + (command) => + !lower || + command.name.startsWith(lower) || + command.name.replace(/-/gu, "_").startsWith(normalized) || + normalizeLowercaseStringOrEmpty(getSlashCommandDescription(command)).includes(lower), + ) + .toSorted((left, right) => left.name.localeCompare(right.name)); +} + /** Count of commands hidden by tier filtering (for "Show N more" UI). */ export function getHiddenCommandCount(): number { return SLASH_COMMANDS.filter((cmd) => (cmd.tier ?? "standard") === "power").length; diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index dd78ceb3593a..cdad38310305 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -14,7 +14,11 @@ import type { ExecApprovalRequest } from "../../app/exec-approval.ts"; import type { UiSettings } from "../../app/settings.ts"; import { i18n, t } from "../../i18n/index.ts"; import type { ChatAttachment, ChatQueueItem } from "../../lib/chat/chat-types.ts"; -import { SLASH_COMMANDS } from "../../lib/chat/commands.ts"; +import { + buildFallbackSlashCommands, + replaceSlashCommands, + SLASH_COMMANDS, +} from "../../lib/chat/commands.ts"; import { createSessionCapability, type SessionCapability } from "../../lib/sessions/index.ts"; import type { SessionPatchOptions } from "../../lib/sessions/patch.ts"; import { @@ -1842,6 +1846,7 @@ afterEach(() => { renderMessageGroupMock.mockClear(); assistantAttachmentRenderVersionMock.value = 0; resetChatViewState(); + replaceSlashCommands(buildFallbackSlashCommands()); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -3255,6 +3260,491 @@ describe("chat slash menu accessibility", () => { expect(onSlashIntent).toHaveBeenCalledTimes(1); }); + it("hydrates the skill catalog once per active $ reference", async () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Prose skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const onSlashIntent = vi.fn(async () => undefined); + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + onSlashIntent, + }), + ), + container, + ); + }; + const type = async (value: string) => { + const textarea = container.querySelector("textarea")!; + textarea.value = value; + textarea.setSelectionRange(value.length, value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + await Promise.resolve(); + await Promise.resolve(); + }; + renderCurrent(); + + await type("Use $"); + await type("Use $p"); + await type("Use $pro"); + + expect(onSlashIntent).toHaveBeenCalledOnce(); + }); + + it("opens a skill picker for $ references anywhere in a normal prompt", async () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Draft polished prose.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const onDraftChange = vi.fn((next: string) => { + draft = next; + }); + const onSlashIntent = vi.fn(async () => undefined); + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange, + onRequestUpdate: renderCurrent, + onSlashIntent, + }), + ), + container, + ); + }; + renderCurrent(); + + const textarea = container.querySelector("textarea")!; + textarea.value = "Polish this with $pro:"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + await Promise.resolve(); + await Promise.resolve(); + + const listbox = container.querySelector("#chat-single-skill-menu-listbox"); + const renderedTextarea = container.querySelector("textarea"); + expect(listbox?.getAttribute("aria-label")).toBe("Skill references"); + expect(listbox?.querySelector(".slash-menu-name")?.textContent).toBe("$prose"); + expect(renderedTextarea?.getAttribute("aria-controls")).toBe("chat-single-skill-menu-listbox"); + expect(renderedTextarea?.getAttribute("aria-expanded")).toBe("true"); + expect(onSlashIntent).toHaveBeenCalledOnce(); + }); + + it("fills a selected $ skill without submitting the surrounding prompt", async () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Draft polished prose.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const onDraftChange = vi.fn((next: string) => { + draft = next; + }); + const onSend = vi.fn(); + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange, + onRequestUpdate: renderCurrent, + onSend, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = "Polish this with $pro:"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + + keydownComposer(container, "Enter"); + + expect(draft).toBe("Polish this with $prose:"); + expect(onSend).not.toHaveBeenCalled(); + expect(container.querySelector(".skill-menu")).toBeNull(); + await Promise.resolve(); + const completed = container.querySelector("textarea"); + expect(completed?.selectionStart).toBe("Polish this with $prose:".length); + }); + + it("consumes a trailing hyphen from an incomplete skill query", () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "release_notes", + name: "release_notes", + description: "Draft release notes.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = "Use $release-"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + + keydownComposer(container, "Enter"); + + expect(draft).toBe("Use $release_notes "); + }); + + it("does not treat common uppercase shell variables as skill references", () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "home", + name: "home", + description: "Home skill.", + source: "skill", + skillModelVisible: true, + }, + { + key: "editor", + name: "editor", + description: "Editor skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + }), + ), + container, + ); + }; + renderCurrent(); + for (const variable of ["HOME", "EDITOR"]) { + const textarea = container.querySelector("textarea")!; + textarea.value = `Inspect $${variable}`; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + expect(container.querySelector(".skill-menu")).toBeNull(); + } + }); + + it("does not offer skill references inside a slash-command draft", () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Prose skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = "/status $pro"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + + expect(container.querySelector(".skill-menu")).toBeNull(); + }); + + it("does not submit an incomplete skill reference while the catalog is loading", () => { + replaceSlashCommands(buildFallbackSlashCommands()); + const refresh = createDeferred(); + let draft = ""; + const onSend = vi.fn(); + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + onSend, + onSlashIntent: () => refresh.promise, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = "Use $pro"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + expect(container.querySelector(".skill-menu")?.textContent).toContain("Loading skills"); + const send = container.querySelector(".chat-send-btn"); + expect(send?.disabled).toBe(true); + + keydownComposer(container, "Enter"); + send?.click(); + + expect(onSend).not.toHaveBeenCalled(); + expect(draft).toBe("Use $pro"); + }); + + it("keeps skill keyboard navigation and selection on the same highlighted item", () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "alpha", + name: "alpha", + description: "Alpha skill.", + source: "skill", + skillModelVisible: true, + }, + { + key: "beta", + name: "beta", + description: "Beta skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = "Use $"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + + keydownComposer(container, "ArrowDown"); + expect( + container.querySelector(".skill-menu .slash-menu-item--active .slash-menu-name")?.textContent, + ).toBe("$beta"); + keydownComposer(container, "Enter"); + + expect(draft).toBe("Use $beta "); + expect(container.querySelector(".skill-menu")).toBeNull(); + }); + + it("does not reopen a dismissed skill picker after a slow refresh", async () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Prose skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + const refresh = createDeferred(); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + onSlashIntent: () => refresh.promise, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = "$pro"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + expect(container.querySelector(".skill-menu")?.textContent).toContain("Loading skills"); + expect(container.querySelectorAll(".skill-menu [role='option']")).toHaveLength(0); + + keydownComposer(container, "Escape"); + expect(container.querySelector(".skill-menu")).toBeNull(); + refresh.resolve(); + await refresh.promise; + await Promise.resolve(); + + expect(container.querySelector(".skill-menu")).toBeNull(); + }); + + it("closes a stale skill picker when the caret leaves its token", () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Prose skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + }), + ), + container, + ); + }; + renderCurrent(); + let textarea = container.querySelector("textarea")!; + textarea.value = "Use $pro then continue"; + textarea.setSelectionRange("Use $pro".length, "Use $pro".length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + expect(container.querySelector(".skill-menu")).not.toBeNull(); + + textarea = container.querySelector("textarea")!; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new Event("select", { bubbles: true })); + + expect(container.querySelector(".skill-menu")).toBeNull(); + }); + + it("matches backend escape parity and absorbs rejected skill refreshes", async () => { + replaceSlashCommands([ + ...buildFallbackSlashCommands(), + { + key: "prose", + name: "prose", + description: "Prose skill.", + source: "skill", + skillModelVisible: true, + }, + ]); + let draft = ""; + const container = document.createElement("div"); + const renderCurrent = () => { + render( + renderChat( + createChatProps({ + draft, + getDraft: () => draft, + onDraftChange: (next) => { + draft = next; + }, + onRequestUpdate: renderCurrent, + onSlashIntent: async () => { + throw new Error("catalog unavailable"); + }, + }), + ), + container, + ); + }; + renderCurrent(); + const textarea = container.querySelector("textarea")!; + textarea.value = String.raw`Use \\$pro`; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + await Promise.resolve(); + await Promise.resolve(); + + expect(container.querySelector(".skill-menu")).not.toBeNull(); + }); + it("does not reopen slash suggestions when command hydration finishes after plain typing", async () => { let draft = ""; const hydration = createDeferred(); @@ -3526,7 +4016,7 @@ describe("chat slash menu accessibility", () => { expect(wrapper?.hasAttribute("aria-haspopup")).toBe(false); expect(wrapper?.hasAttribute("aria-controls")).toBe(false); expect(textarea?.hasAttribute("role")).toBe(false); - expect(textarea?.hasAttribute("aria-expanded")).toBe(false); + expect(textarea?.getAttribute("aria-expanded")).toBe("true"); expect(textarea?.hasAttribute("aria-haspopup")).toBe(false); expect(textarea?.getAttribute("aria-controls")).toBe("chat-single-slash-menu-listbox"); expect(textarea?.getAttribute("aria-autocomplete")).toBe("list"); diff --git a/ui/src/pages/chat/components/chat-composer-skill-menu.ts b/ui/src/pages/chat/components/chat-composer-skill-menu.ts new file mode 100644 index 000000000000..31c6a7dac626 --- /dev/null +++ b/ui/src/pages/chat/components/chat-composer-skill-menu.ts @@ -0,0 +1,292 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { icons } from "../../../components/icons.ts"; +import { t } from "../../../i18n/index.ts"; +import { + getSkillCommandCompletions, + getSlashCommandDescription, + type SlashCommandDef, +} from "../../../lib/chat/commands.ts"; +import { paneDomId } from "./chat-composer-slash-menu.ts"; +import { commitComposerDraft, getChatComposerState } from "./chat-composer-state.ts"; +import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts"; + +const SKILL_MENTION_CHAR = /[-a-zA-Z0-9_:]/u; + +type SkillMentionTarget = { + start: number; + end: number; + query: string; +}; + +function isEscapedReference(value: string, dollar: number): boolean { + let backslashes = 0; + for (let cursor = dollar - 1; cursor >= 0 && value[cursor] === "\\"; cursor -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + +function findSkillMentionTarget(value: string, caret: number): SkillMentionTarget | null { + const safeCaret = Math.max(0, Math.min(caret, value.length)); + let start = safeCaret; + while (start > 0 && SKILL_MENTION_CHAR.test(value[start - 1] ?? "")) { + start -= 1; + } + if (start === 0 || value[start - 1] !== "$") { + return null; + } + const dollar = start - 1; + if (isEscapedReference(value, dollar)) { + return null; + } + let end = safeCaret; + while (end < value.length && SKILL_MENTION_CHAR.test(value[end] ?? "")) { + end += 1; + } + let referenceEnd = end; + while (referenceEnd > start && value[referenceEnd - 1] === ":") { + referenceEnd -= 1; + } + const query = value.slice(start, referenceEnd); + if (query.length > 0 && !/[a-z]/u.test(query)) { + return null; + } + return { start: dollar, end: referenceEnd, query }; +} + +function hasVisibleSkillMenuState(state: ChatComposerState): boolean { + return ( + state.skillMenuOpen || + state.skillMenuItems.length > 0 || + state.skillMenuTarget !== null || + state.skillCommandRefreshPending + ); +} + +export function resetSkillMenuState(state: ChatComposerState): void { + state.skillCommandRefreshGeneration += 1; + state.skillCommandRefreshPending = false; + state.skillCommandRefreshTargetStart = null; + state.skillMenuOpen = false; + state.skillMenuItems = []; + state.skillMenuIndex = 0; + state.skillMenuTarget = null; +} + +function closeSkillMenuIfNeeded(state: ChatComposerState, requestUpdate: () => void): void { + if (!hasVisibleSkillMenuState(state)) { + return; + } + resetSkillMenuState(state); + requestUpdate(); +} + +function requestSkillCommandRefresh( + props: ChatComposerProps, + requestUpdate: () => void, + getCurrentValue: () => string, + getCurrentCaret: () => number, +): void { + const state = getChatComposerState(props.paneId); + if (!props.onSlashIntent || state.skillCommandRefreshPending) { + return; + } + const refresh = props.onSlashIntent(); + if (!refresh || typeof refresh.then !== "function") { + return; + } + const generation = state.skillCommandRefreshGeneration + 1; + state.skillCommandRefreshGeneration = generation; + state.skillCommandRefreshPending = true; + void Promise.resolve(refresh) + .catch(() => undefined) + .finally(() => { + if (state.skillCommandRefreshGeneration !== generation) { + return; + } + state.skillCommandRefreshPending = false; + updateSkillMenu( + getCurrentValue(), + getCurrentCaret(), + requestUpdate, + props, + { skipRefresh: true }, + getCurrentValue, + getCurrentCaret, + ); + }); +} + +export function updateSkillMenu( + value: string, + caret: number, + requestUpdate: () => void, + props: ChatComposerProps, + opts: { skipRefresh?: boolean } = {}, + getCurrentValue: () => string = () => value, + getCurrentCaret: () => number = () => caret, +): void { + const state = getChatComposerState(props.paneId); + if (value.trimStart().startsWith("/")) { + closeSkillMenuIfNeeded(state, requestUpdate); + return; + } + const target = findSkillMentionTarget(value, caret); + if (!target) { + closeSkillMenuIfNeeded(state, requestUpdate); + return; + } + if (!opts.skipRefresh && state.skillCommandRefreshTargetStart !== target.start) { + state.skillCommandRefreshTargetStart = target.start; + requestSkillCommandRefresh(props, requestUpdate, getCurrentValue, getCurrentCaret); + } + const items = getSkillCommandCompletions(target.query); + state.skillMenuTarget = target; + state.skillMenuItems = items; + state.skillMenuIndex = Math.min(state.skillMenuIndex, Math.max(0, items.length - 1)); + state.skillMenuOpen = items.length > 0 || state.skillCommandRefreshPending; + requestUpdate(); +} + +function skillOptionId(paneId: string, command: SlashCommandDef): string { + const name = command.name.replace(/[^a-z0-9_-]+/giu, "-").replace(/^-+|-+$/gu, ""); + return paneDomId(paneId, `skill-option-${name || "skill"}`); +} + +export function isSkillMenuVisible(state: ChatComposerState): boolean { + return ( + state.skillMenuOpen && (state.skillMenuItems.length > 0 || state.skillCommandRefreshPending) + ); +} + +export function getActiveSkillMenuOptionId( + state: ChatComposerState, + paneId: string, +): string | null { + if (!isSkillMenuVisible(state) || state.skillCommandRefreshPending) { + return null; + } + const command = state.skillMenuItems[state.skillMenuIndex]; + return command ? skillOptionId(paneId, command) : null; +} + +export function getActiveSkillMenuOptionLabel(state: ChatComposerState): string { + if (state.skillCommandRefreshPending) { + return ""; + } + const command = state.skillMenuItems[state.skillMenuIndex]; + return command ? `$${command.name} ${getSlashCommandDescription(command)}` : ""; +} + +export function scrollActiveSkillMenuOptionIntoView( + state: ChatComposerState, + paneId: string, +): void { + const activeId = getActiveSkillMenuOptionId(state, paneId); + if (!activeId) { + return; + } + requestAnimationFrame(() => { + const activeOption = document.getElementById(activeId); + const menu = activeOption?.closest(".skill-menu"); + if (!activeOption || !menu) { + return; + } + const menuBounds = menu.getBoundingClientRect(); + const optionBounds = activeOption.getBoundingClientRect(); + if (optionBounds.top < menuBounds.top) { + menu.scrollTop -= menuBounds.top - optionBounds.top; + } else if (optionBounds.bottom > menuBounds.bottom) { + menu.scrollTop += optionBounds.bottom - menuBounds.bottom; + } + }); +} + +export function selectSkillMention( + command: SlashCommandDef, + props: ChatComposerProps, + requestUpdate: () => void, +): void { + const state = getChatComposerState(props.paneId); + if (state.skillCommandRefreshPending) { + return; + } + const current = state.composerTextarea?.value ?? props.getDraft?.() ?? props.draft; + const currentCaret = + state.composerTextarea?.selectionStart ?? state.skillMenuTarget?.end ?? current.length; + const target = findSkillMentionTarget(current, currentCaret); + if (!target) { + resetSkillMenuState(state); + requestUpdate(); + return; + } + const suffix = target.end === current.length ? " " : ""; + const replacement = `$${command.name}${suffix}`; + const next = `${current.slice(0, target.start)}${replacement}${current.slice(target.end)}`; + const retainedBeforeCaret = Math.max(0, currentCaret - target.end); + const nextCaret = target.start + replacement.length + retainedBeforeCaret; + commitComposerDraft(props, next); + resetSkillMenuState(state); + requestUpdate(); + queueMicrotask(() => { + const textarea = state.composerTextarea; + if (!textarea) { + return; + } + textarea.focus({ preventScroll: true }); + textarea.setSelectionRange(nextCaret, nextCaret); + }); +} + +export function renderSkillMenu( + requestUpdate: () => void, + props: ChatComposerProps, +): TemplateResult | typeof nothing { + const state = getChatComposerState(props.paneId); + if (!isSkillMenuVisible(state)) { + return nothing; + } + const listboxId = paneDomId(props.paneId, "skill-menu-listbox"); + return html` +
+ ${state.skillCommandRefreshPending || state.skillMenuItems.length === 0 + ? html`
+
${t("chat.skills.loading")}
+
` + : html`
+
${t("chat.skills.label")}
+ ${state.skillMenuItems.map( + (command, index) => html` +
event.preventDefault()} + @click=${() => selectSkillMention(command, props, requestUpdate)} + @mouseenter=${() => { + state.skillMenuIndex = index; + requestUpdate(); + }} + > + ${icons.zap} + $${command.name} + ${getSlashCommandDescription(command)} +
+ `, + )} +
`} + +
+ `; +} diff --git a/ui/src/pages/chat/components/chat-composer-state.ts b/ui/src/pages/chat/components/chat-composer-state.ts index 968b0eb08282..2aefe964de97 100644 --- a/ui/src/pages/chat/components/chat-composer-state.ts +++ b/ui/src/pages/chat/components/chat-composer-state.ts @@ -14,6 +14,13 @@ function createChatComposerState(): ChatComposerState { slashMenuArgItems: [], slashMenuExpanded: false, slashCommandRefreshPending: false, + skillMenuOpen: false, + skillMenuItems: [], + skillMenuIndex: 0, + skillMenuTarget: null, + skillCommandRefreshPending: false, + skillCommandRefreshGeneration: 0, + skillCommandRefreshTargetStart: null, composerComposing: false, composingDraft: null, composerInputIntentKey: null, diff --git a/ui/src/pages/chat/components/chat-composer-types.ts b/ui/src/pages/chat/components/chat-composer-types.ts index 02cf09785495..eb69719557a1 100644 --- a/ui/src/pages/chat/components/chat-composer-types.ts +++ b/ui/src/pages/chat/components/chat-composer-types.ts @@ -133,6 +133,12 @@ type ComposingDraft = { value: string; }; +type SkillMenuTarget = { + start: number; + end: number; + query: string; +}; + export type ChatComposerState = { slashMenuOpen: boolean; slashMenuItems: SlashCommandDef[]; @@ -142,6 +148,13 @@ export type ChatComposerState = { slashMenuArgItems: string[]; slashMenuExpanded: boolean; slashCommandRefreshPending: boolean; + skillMenuOpen: boolean; + skillMenuItems: SlashCommandDef[]; + skillMenuIndex: number; + skillMenuTarget: SkillMenuTarget | null; + skillCommandRefreshPending: boolean; + skillCommandRefreshGeneration: number; + skillCommandRefreshTargetStart: number | null; composerComposing: boolean; composingDraft: ComposingDraft | null; composerInputIntentKey: string | null; diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index 2cea25d9749f..56ec39407b9b 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -26,6 +26,7 @@ import { import { renderChatGoal } from "./chat-composer-goal.ts"; import { renderChatComposerPlusMenu } from "./chat-composer-plus-menu.ts"; import { renderChatQueue } from "./chat-composer-queue.ts"; +import { renderSkillMenu } from "./chat-composer-skill-menu.ts"; import { renderSlashMenu } from "./chat-composer-slash-menu.ts"; import { commitComposerDraft } from "./chat-composer-state.ts"; import { @@ -58,6 +59,7 @@ type ChatComposerViewContext = { handleKeyDown: (event: KeyboardEvent) => void; handleBeforeInput: (event: InputEvent) => void; handleInput: (event: InputEvent) => void; + handleSelect: (event: Event) => void; draftKey: string; handleCompositionEnd: (event: CompositionEvent) => void; handleBlur: (event: FocusEvent) => void; @@ -65,6 +67,7 @@ type ChatComposerViewContext = { runControlsProps: ChatRunControlsProps; mirrorCameraPreview: boolean; slashMenuVisible: boolean; + skillMenuVisible: boolean; activeSlashMenuOptionId: string | null; activeSlashMenuOptionLabel: string; slashMenuListboxId: string; @@ -92,6 +95,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) { handleKeyDown, handleBeforeInput, handleInput, + handleSelect, draftKey, handleCompositionEnd, handleBlur, @@ -99,6 +103,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) { runControlsProps, mirrorCameraPreview, slashMenuVisible, + skillMenuVisible, activeSlashMenuOptionId, activeSlashMenuOptionLabel, slashMenuListboxId, @@ -188,6 +193,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ` : nothing} ${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing} + ${skillMenuVisible ? renderSkillMenu(requestUpdate, props) : nothing} ${renderAttachmentPreview(props)} ${props.replyTarget ? html` @@ -363,7 +369,12 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ?disabled=${!canCompose} ?readonly=${dictation?.locksComposer === true} aria-autocomplete="list" - aria-controls=${ifDefined(slashMenuVisible ? slashMenuListboxId : undefined)} + aria-controls=${ifDefined( + slashMenuVisible || skillMenuVisible ? slashMenuListboxId : undefined, + )} + aria-expanded=${ifDefined( + slashMenuVisible || skillMenuVisible ? "true" : undefined, + )} aria-activedescendant=${ifDefined(activeSlashMenuOptionId ?? undefined)} aria-describedby=${slashMenuAnnouncementId} aria-keyshortcuts=${sendShortcut === "enter" @@ -372,6 +383,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) { @keydown=${handleKeyDown} @beforeinput=${handleBeforeInput} @input=${handleInput} + @select=${handleSelect} @compositionstart=${(event: CompositionEvent) => { state.composerComposing = true; state.composingDraft = { diff --git a/ui/src/pages/chat/components/chat-composer.ts b/ui/src/pages/chat/components/chat-composer.ts index 1c9a25ee88d0..9b559e37f17b 100644 --- a/ui/src/pages/chat/components/chat-composer.ts +++ b/ui/src/pages/chat/components/chat-composer.ts @@ -16,6 +16,15 @@ import { restoreHistoryCaret, scheduleTextareaHeightAdjustment, } from "./chat-composer-dom.ts"; +import { + getActiveSkillMenuOptionId, + getActiveSkillMenuOptionLabel, + isSkillMenuVisible, + resetSkillMenuState, + scrollActiveSkillMenuOptionIntoView, + selectSkillMention, + updateSkillMenu, +} from "./chat-composer-skill-menu.ts"; import { exportMarkdown, getActiveSlashMenuOptionId, @@ -47,42 +56,68 @@ import { createGatewayQuestionPanelProps } from "./chat-question-card.ts"; export { isChatRunWorking, resetChatComposerState } from "./chat-composer-state.ts"; -function handleSlashMenuKeyDown( +function handleComposerMenuKeyDown( event: KeyboardEvent, state: ChatComposerState, items: readonly T[], paneId: string, requestUpdate: () => void, onSelect: (item: T, submit: boolean) => void, + scrollActive: (state: ChatComposerState, paneId: string) => void, + menu: "slash" | "skill" = "slash", ): boolean { + if (event.key === "Escape") { + event.preventDefault(); + if (menu === "skill") { + resetSkillMenuState(state); + } else { + state.slashMenuOpen = false; + resetSlashMenuState(state); + } + requestUpdate(); + return true; + } + if (items.length === 0) { + if ( + menu === "skill" && + state.skillCommandRefreshPending && + ["ArrowDown", "ArrowUp", "Enter", "Tab"].includes(event.key) + ) { + event.preventDefault(); + return true; + } + return false; + } + const getIndex = () => (menu === "skill" ? state.skillMenuIndex : state.slashMenuIndex); + const setIndex = (index: number) => { + if (menu === "skill") { + state.skillMenuIndex = index; + } else { + state.slashMenuIndex = index; + } + }; switch (event.key) { case "ArrowDown": event.preventDefault(); - state.slashMenuIndex = (state.slashMenuIndex + 1) % items.length; + setIndex((getIndex() + 1) % items.length); requestUpdate(); - scrollActiveSlashMenuOptionIntoView(state, paneId); + scrollActive(state, paneId); return true; case "ArrowUp": event.preventDefault(); - state.slashMenuIndex = (state.slashMenuIndex - 1 + items.length) % items.length; + setIndex((getIndex() - 1 + items.length) % items.length); requestUpdate(); - scrollActiveSlashMenuOptionIntoView(state, paneId); + scrollActive(state, paneId); return true; case "Tab": case "Enter": { event.preventDefault(); - const item = items[state.slashMenuIndex]; + const item = items[getIndex()]; if (item !== undefined) { onSelect(item, event.key === "Enter"); } return true; } - case "Escape": - event.preventDefault(); - state.slashMenuOpen = false; - resetSlashMenuState(state); - requestUpdate(); - return true; default: return false; } @@ -259,6 +294,7 @@ export function renderChatComposer(props: ChatComposerProps) { // slash commands are live controls and must not execute against stale state. const canSubmitDraft = (draft: string) => canCompose && + !(state.skillMenuOpen && state.skillCommandRefreshPending) && (props.getPendingAttachmentReads?.() ?? props.pendingAttachmentReads ?? 0) === 0 && (props.connected || !draft.trimStart().startsWith("/")); @@ -286,6 +322,23 @@ export function renderChatComposer(props: ChatComposerProps) { return; } + if (props.connected && state.skillMenuOpen) { + if ( + handleComposerMenuKeyDown( + event, + state, + state.skillCommandRefreshPending ? [] : state.skillMenuItems, + props.paneId, + requestUpdate, + (command) => selectSkillMention(command, props, requestUpdate), + scrollActiveSkillMenuOptionIntoView, + "skill", + ) + ) { + return; + } + } + if ( props.connected && state.slashMenuOpen && @@ -293,13 +346,14 @@ export function renderChatComposer(props: ChatComposerProps) { state.slashMenuArgItems.length > 0 ) { if ( - handleSlashMenuKeyDown( + handleComposerMenuKeyDown( event, state, state.slashMenuArgItems, props.paneId, requestUpdate, (arg, submit) => selectSlashArg(arg, props, requestUpdate, submit), + scrollActiveSlashMenuOptionIntoView, ) ) { return; @@ -308,7 +362,7 @@ export function renderChatComposer(props: ChatComposerProps) { if (props.connected && state.slashMenuOpen && state.slashMenuItems.length > 0) { if ( - handleSlashMenuKeyDown( + handleComposerMenuKeyDown( event, state, state.slashMenuItems, @@ -318,6 +372,7 @@ export function renderChatComposer(props: ChatComposerProps) { submit ? selectSlashCommand(command, props, requestUpdate) : tabCompleteSlashCommand(command, props, requestUpdate), + scrollActiveSlashMenuOptionIntoView, ) ) { return; @@ -370,6 +425,15 @@ export function renderChatComposer(props: ChatComposerProps) { adjustTextareaHeight(target); commitComposerDraft(props, target.value); updateSlashMenu(target.value, requestUpdate, props, {}, () => target.value); + updateSkillMenu( + target.value, + target.selectionStart, + requestUpdate, + props, + {}, + () => target.value, + () => target.selectionStart, + ); requestUpdate(); }; const handleBeforeInput = (event: InputEvent) => { @@ -402,6 +466,18 @@ export function renderChatComposer(props: ChatComposerProps) { syncComposerValue(target); props.onTypingChange?.(Boolean(target.value.trim())); }; + const handleSelect = (event: Event) => { + const target = event.target as HTMLTextAreaElement; + updateSkillMenu( + target.value, + target.selectionStart, + requestUpdate, + props, + {}, + () => target.value, + () => target.selectionStart, + ); + }; const handleCompositionEnd = (event: CompositionEvent) => { state.composerComposing = false; if (state.composingDraft?.key === draftKey) { @@ -594,9 +670,20 @@ export function renderChatComposer(props: ChatComposerProps) { ?.getSettings?.().facingMode; const mirrorCameraPreview = cameraFacingMode !== "environment"; const slashMenuVisible = props.connected && canCompose && isSlashMenuVisible(state); - const activeSlashMenuOptionId = getActiveSlashMenuOptionId(state, props.paneId); - const activeSlashMenuOptionLabel = getActiveSlashMenuOptionLabel(state); - const slashMenuListboxId = paneDomId(props.paneId, "slash-menu-listbox"); + const skillMenuVisible = props.connected && canCompose && isSkillMenuVisible(state); + if (!skillMenuVisible && state.skillMenuOpen && !state.skillCommandRefreshPending) { + resetSkillMenuState(state); + } + const activeSlashMenuOptionId = skillMenuVisible + ? getActiveSkillMenuOptionId(state, props.paneId) + : getActiveSlashMenuOptionId(state, props.paneId); + const activeSlashMenuOptionLabel = skillMenuVisible + ? getActiveSkillMenuOptionLabel(state) + : getActiveSlashMenuOptionLabel(state); + const slashMenuListboxId = paneDomId( + props.paneId, + skillMenuVisible ? "skill-menu-listbox" : "slash-menu-listbox", + ); const slashMenuAnnouncementId = paneDomId(props.paneId, "slash-active-announcement"); return renderChatComposerView({ @@ -618,6 +705,7 @@ export function renderChatComposer(props: ChatComposerProps) { handleKeyDown, handleBeforeInput, handleInput, + handleSelect, draftKey, handleCompositionEnd, handleBlur, @@ -625,6 +713,7 @@ export function renderChatComposer(props: ChatComposerProps) { runControlsProps, mirrorCameraPreview, slashMenuVisible, + skillMenuVisible, activeSlashMenuOptionId, activeSlashMenuOptionLabel, slashMenuListboxId,