feat(ui): add composable skill references (#116330)

* feat(ui): add composable skill references

* fix(ui): align skill reference CI contracts

* fix(ui): scope skill references to WebChat
This commit is contained in:
Peter Steinberger
2026-07-30 04:07:02 -07:00
committed by GitHub
parent 424c36a2d5
commit 7fa95e2656
27 changed files with 1571 additions and 38 deletions
@@ -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
+1
View File
@@ -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
+28
View File
@@ -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
@@ -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 })),
@@ -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" } });
@@ -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<CommandsRuntime>(
() => import("./commands.runtime.js"),
);
let builtinSlashCommands: Set<string> | null = null;
const MAX_EXPLICIT_SKILL_REFERENCES = 8;
function loadSkillCommandsRuntime(): Promise<SkillCommandsRuntime> {
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,
@@ -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 }));
@@ -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");
});
@@ -59,6 +59,67 @@ function findSkillCommand(
});
}
function skillReferenceMatches(text: string): IterableIterator<RegExpMatchArray> {
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<string>();
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[];
+84 -2
View File
@@ -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 } =
+2
View File
@@ -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: {
+3 -1
View File
@@ -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,
});
+1 -1
View File
@@ -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;
}
+2
View File
@@ -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. */
@@ -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",
]);
@@ -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();
}
});
});
+21
View File
@@ -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",
+5
View File
@@ -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",
@@ -32,6 +32,7 @@ describe("slash command browser import", () => {
executeLocal: true,
argOptions: undefined,
tier: "essential",
source: "native",
});
});
+56
View File
@@ -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", () => {
+34 -1
View File
@@ -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;
+492 -2
View File
@@ -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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLElement>("#chat-single-skill-menu-listbox");
const renderedTextarea = container.querySelector<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<void>();
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<HTMLTextAreaElement>("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<HTMLButtonElement>(".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<HTMLTextAreaElement>("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<void>();
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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<HTMLTextAreaElement>("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<void>();
@@ -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");
@@ -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<HTMLElement>(".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`
<div
id=${listboxId}
class="slash-menu skill-menu"
role="listbox"
aria-label=${t("chat.skills.menu")}
>
${state.skillCommandRefreshPending || state.skillMenuItems.length === 0
? html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.loading")}</div>
</div>`
: html`<div class="slash-menu-group">
<div class="slash-menu-group__label">${t("chat.skills.label")}</div>
${state.skillMenuItems.map(
(command, index) => html`
<div
id=${skillOptionId(props.paneId, command)}
class="slash-menu-item ${index === state.skillMenuIndex
? "slash-menu-item--active"
: ""}"
role="option"
aria-selected=${index === state.skillMenuIndex}
@mousedown=${(event: MouseEvent) => event.preventDefault()}
@click=${() => selectSkillMention(command, props, requestUpdate)}
@mouseenter=${() => {
state.skillMenuIndex = index;
requestUpdate();
}}
>
<span class="slash-menu-icon">${icons.zap}</span>
<span class="slash-menu-name">$${command.name}</span>
<span class="slash-menu-desc">${getSlashCommandDescription(command)}</span>
</div>
`,
)}
</div>`}
<div class="slash-menu-footer">
<kbd></kbd> ${t("chat.commands.navigate")} <kbd>Tab</kbd> ${t("chat.commands.fill")}
<kbd>Enter</kbd> ${t("chat.commands.select")} <kbd>Esc</kbd> ${t("chat.commands.close")}
</div>
</div>
`;
}
@@ -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,
@@ -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;
@@ -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) {
</div>`
: 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 = {
+106 -17
View File
@@ -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<T>(
function handleComposerMenuKeyDown<T>(
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,