mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
424c36a2d5
commit
7fa95e2656
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user