From a210c71c2629febb42333ac6ea3713e2f600db60 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 1 Nov 2026 08:29:40 +0800 Subject: [PATCH] fix(tui): surface skills in slash command completion --- README.md | 8 ++ packages/zcode-tui/src/index.ts | 13 +++- packages/zcode-tui/src/skills.ts | 19 +++++ packages/zcode-tui/src/types.ts | 1 + .../zcode-tui/src/workspace-autocomplete.ts | 24 +++++- test/README.md | 2 + test/skill-completion.test.ts | 75 +++++++++++++++++++ test/tui/fixtures/skills.ts | 15 ++++ test/tui/skills.test.ts | 19 +++++ 9 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 test/skill-completion.test.ts create mode 100644 test/tui/fixtures/skills.ts create mode 100644 test/tui/skills.test.ts diff --git a/README.md b/README.md index 02ad5925..45bd0603 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,14 @@ into a request that loads each selected Skill through the runtime's `Skill` tool before carrying out the visible user request. Unknown `$` tokens remain ordinary prompt text. +Skills also appear in `/` command completion, marked `Skill` with their +descriptions. Type `/` and filter by name, or enter `/ ` +directly (for example, `/audit review the current changes`). These shortcuts +use the runtime's `/skill ` route. Duplicate identifiers use the +first discovered entry; plugin Skills retain their qualified names. Built-in +commands, their aliases and custom commands take precedence over skill +shortcuts with the same name; use `$name` or `/skill name` for those Skills. + Use `@plugin` when the whole Plugin is relevant, including its MCP servers or Subagents. Use `$plugin:skill` when one exact Skill must be loaded before the task starts. diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 26ad46fe..02c522be 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -1210,7 +1210,8 @@ class ZCodeTui { workspaceDirectory, this.options.listWorkspacePathSuggestions, this.skillCatalog, - this.options.listPluginReferences ?? createRuntimePluginReferenceLister(workspaceDirectory) + this.options.listPluginReferences ?? createRuntimePluginReferenceLister(workspaceDirectory), + (this.options.slashCommands ?? []).flatMap((command) => command.aliases ?? []) ) ); editor.onSubmit = (text) => void this.submit(text); @@ -1823,7 +1824,13 @@ class ZCodeTui { const skillPrompt = input.startsWith("/") ? undefined : await this.skillCatalog.preparePrompt(input); - const runtimeInput = skillPrompt?.text ?? input; + const skillCommand = input.startsWith("/") + ? await this.skillCatalog.prepareSlashCommand(input, [ + ...this.autocompleteCommands().map((command) => command.name), + ...(this.options.slashCommands ?? []).flatMap((command) => command.aliases ?? []) + ]) + : undefined; + const runtimeInput = skillCommand ?? skillPrompt?.text ?? input; this.transcript.clearSearch(); this.transcript.clearCursor(); @@ -1921,7 +1928,7 @@ class ZCodeTui { try { if (input.startsWith("/") || !this.options.sendInput) { const result = await this.options.submitPrompt( - input.startsWith("/") ? input : promptInput(runtimeInput, attachments), + input.startsWith("/") ? runtimeInput : promptInput(runtimeInput, attachments), callOptions ); await this.handleResult(result, true, settingTargetForCommand(input)); diff --git a/packages/zcode-tui/src/skills.ts b/packages/zcode-tui/src/skills.ts index 77c84a34..c383d38e 100644 --- a/packages/zcode-tui/src/skills.ts +++ b/packages/zcode-tui/src/skills.ts @@ -6,6 +6,7 @@ const skillDescriptionLimit = 140; const skillIdentifierLimit = 256; const skillIdentifierPattern = /^[A-Za-z0-9._-]+(?::[A-Za-z0-9._-]+)*$/u; const skillMentionPattern = /(^|\s)\$([A-Za-z0-9._:-]+)/gu; +const reservedSlashCommandNames = ["skill", "quit", "workflow"]; export interface SkillEntry { description?: string; @@ -60,6 +61,24 @@ export class SkillCatalog { ? { identifiers, text: buildSkillInvocationPrompt(identifiers, input) } : undefined; } + + async listSlashCommands(reservedNames: readonly string[]): Promise { + const reserved = new Set([...reservedSlashCommandNames, ...reservedNames].map((name) => name.toLowerCase())); + return (await this.list()).filter((skill) => !reserved.has(skill.identifier.toLowerCase())); + } + + async prepareSlashCommand(input: string, reservedNames: readonly string[]): Promise { + const match = /^\/([^\s/]+)(?:\s+([\s\S]*))?$/u.exec(input); + if (!match) return undefined; + if ([...reservedSlashCommandNames, ...reservedNames].some((name) => name.toLowerCase() === match[1]!.toLowerCase())) { + return undefined; + } + const skill = (await this.listSlashCommands(reservedNames)) + .find((entry) => entry.identifier === match[1]); + if (!skill) return undefined; + const task = match[2]?.trim(); + return `/skill ${skill.identifier}${task ? ` ${task}` : ""}`; + } } export function normalizeSkillEntries(result: unknown): SkillEntry[] { diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index 4c826519..1b09e269 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -2,6 +2,7 @@ export type UnknownRecord = Record; export interface SlashCommandOption { name?: string; + aliases?: string[]; description?: string; summary?: string; inputHint?: string; diff --git a/packages/zcode-tui/src/workspace-autocomplete.ts b/packages/zcode-tui/src/workspace-autocomplete.ts index 236d44a8..3ad6d5e6 100644 --- a/packages/zcode-tui/src/workspace-autocomplete.ts +++ b/packages/zcode-tui/src/workspace-autocomplete.ts @@ -48,11 +48,12 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { public readonly triggerCharacters: string[] = ["$"]; constructor( - commands: (AutocompleteItem | SlashCommand)[] | undefined, - basePath: string, + private readonly commands: (AutocompleteItem | SlashCommand)[] = [], + private readonly basePath: string, private readonly listWorkspacePathSuggestions?: ListWorkspacePathSuggestions, skillSource?: ListSkills | SkillCatalog, - pluginSource?: ListPluginReferences | PluginReferenceCatalog + pluginSource?: ListPluginReferences | PluginReferenceCatalog, + private readonly reservedCommandNames: readonly string[] = [] ) { this.fallback = new CombinedAutocompleteProvider(commands, basePath, null); this.skills = skillSource instanceof SkillCatalog ? skillSource : new SkillCatalog(skillSource); @@ -69,6 +70,23 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { ): Promise { const currentLine = lines[cursorLine] ?? ""; + const commandText = currentLine.slice(0, cursorCol).trimStart(); + if (!options.force && /^\/[^\s/]*$/u.test(commandText)) { + const skills = await this.skills.listSlashCommands([ + ...this.reservedCommandNames, + ...this.commands.map((command) => "name" in command ? command.name : command.value) + ]); + if (options.signal.aborted) return null; + const provider = new CombinedAutocompleteProvider([ + ...this.commands, + ...skills.map((skill) => ({ + name: skill.identifier, + description: skill.description ? `Skill: ${skill.description}` : "Skill" + })) + ], this.basePath, null); + return await provider.getSuggestions(lines, cursorLine, cursorCol, options); + } + const skillPrefix = extractSkillDollarPrefix(currentLine.slice(0, cursorCol)); if (skillPrefix) { const skills = await this.skills.list(); diff --git a/test/README.md b/test/README.md index b6a2c6c1..a6356cf0 100644 --- a/test/README.md +++ b/test/README.md @@ -48,6 +48,7 @@ Counts describe this reset, not an assertion that future suites must have a fixe | `permission-request-queue.test.ts` | Permissions overlap, survive cancellation, or stop processing after one failure. | | `attachments.test.ts` | Malformed image data reaches the runtime. | | `rewind.test.ts` | Rewind selects the wrong message or admits an invalid workspace operation. | +| `skill-completion.test.ts` | Discovered skills disappear from slash completion, duplicate entries surface, or skill shortcuts shadow commands. | | `release-contract.test.ts` | Package and runtime versions diverge, installer verification is weakened, or a lagging manifest downgrades the lock. | | `shell-security.test.ts` | The shell formatter's known unbounded-precision denial of service returns. | @@ -84,6 +85,7 @@ large process-count stress runs are not part of this baseline. | File | Failure this must prevent | | --- | --- | | `input-actions.test.ts` | Keyboard queue operations lose drafts or attach an image to a different question. | +| `skills.test.ts` | Slash skill selection fails to complete or dispatch through the native skill route. | | `permission-request-queue.test.ts` | The actual TUI cannot answer concurrent permissions in order. | | `startup.test.ts` | Input submits before initialization, cancelled startup reopens the UI, or a failure leaves raw mode enabled. | | `terminal-exit.test.ts` | Regular/fullscreen exit or cancellation leaks input into the parent shell or returns the wrong status. | diff --git a/test/skill-completion.test.ts b/test/skill-completion.test.ts new file mode 100644 index 00000000..3f7e5f5a --- /dev/null +++ b/test/skill-completion.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { SkillCatalog } from "../packages/zcode-tui/src/skills.ts"; +import { WorkspaceAutocompleteProvider } from "../packages/zcode-tui/src/workspace-autocomplete.ts"; + +test("slash completion discovers and deduplicates skills without shadowing commands or aliases", async () => { + const provider = new WorkspaceAutocompleteProvider( + [{ name: "deploy", description: "Custom command" }, { name: "effort" }], + process.cwd(), undefined, + async () => ({ skills: [ + ...Array.from({ length: 111 }, (_, index) => ({ name: `example-${index}` })), + { name: "orchestration", description: "Coordinate work" }, + { name: "orchestration", description: "Duplicate from shared root" }, + { name: "audit", qualifiedName: "plugin:audit", description: "Review changes" }, + { name: "deploy" }, { name: "EFFORT" }, { name: "variant" }, { name: "quit" } + ] }), undefined, ["variant"] + ); + const options = { signal: new AbortController().signal }; + const all = await provider.getSuggestions(["/"], 0, 1, options); + expect(all?.items.filter((item) => item.value === "orchestration")).toEqual([ + { value: "orchestration", label: "orchestration", description: "Skill: Coordinate work" } + ]); + expect(all?.items.find((item) => item.value === "deploy")?.description).toBe("Custom command"); + expect(all?.items.map((item) => item.value)).not.toContain("EFFORT"); + expect(all?.items.map((item) => item.value)).not.toContain("variant"); + expect(all?.items.map((item) => item.value)).not.toContain("quit"); + const filtered = await provider.getSuggestions(["/orch"], 0, 5, options); + expect(filtered?.items.map((item) => item.value)).toEqual(["orchestration"]); + expect(provider.applyCompletion([" /orch"], 0, 7, filtered!.items[0]!, filtered!.prefix).lines) + .toEqual([" /orchestration "]); + const qualified = await provider.getSuggestions(["/plugin:a"], 0, 9, options); + expect(qualified?.items[0]?.value).toBe("plugin:audit"); +}); + +test("skill discovery failure preserves slash commands and cancellation drops results", async () => { + const provider = new WorkspaceAutocompleteProvider( + [{ name: "help" }], process.cwd(), undefined, + async () => { throw new Error("Unavailable catalog"); } + ); + const controller = new AbortController(); + expect((await provider.getSuggestions(["/"], 0, 1, { signal: controller.signal }))?.items[0]?.value) + .toBe("help"); + controller.abort(); + expect(await provider.getSuggestions(["/"], 0, 1, { signal: controller.signal })).toBeNull(); +}); + +test("skill slash discovery preserves dollar skills and workspace mentions", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], process.cwd(), async () => ({ items: [{ kind: "file", path: "src/main.ts" }], truncated: false }), + async () => ({ skills: [{ name: "audit", qualifiedName: "plugin:audit" }] }) + ); + const options = { signal: new AbortController().signal }; + expect((await provider.getSuggestions(["$audit"], 0, 6, options))?.items[0]?.value).toBe("$plugin:audit"); + expect((await provider.getSuggestions(["@src"], 0, 4, options))?.items[0]?.value).toBe("@src/main.ts"); + expect(await provider.getSuggestions(["/plugin:audit task"], 0, 18, options)).toBeNull(); +}); + +test("skill shortcuts use the native skill route and leave unknown or reserved commands unchanged", async () => { + let discoveryCalls = 0; + const catalog = new SkillCatalog(async () => { + discoveryCalls += 1; + return { skills: [ + { name: "orchestration" }, { name: "audit", qualifiedName: "plugin:audit" }, + { name: "deploy" }, { name: "variant" }, { name: "skill" }, { name: "quit" }, { name: "workflow" } + ] }; + }); + const reserved = ["deploy", "variant"]; + for (const input of ["/deploy task", "/VARIANT high", "/variant high", "/skill audit task", "/quit", "/workflow task", "text /orchestration"]) { + expect(await catalog.prepareSlashCommand(input, reserved)).toBeUndefined(); + } + expect(discoveryCalls).toBe(0); + expect(await catalog.prepareSlashCommand("/orchestration", reserved)).toBe("/skill orchestration"); + expect(await catalog.prepareSlashCommand("/plugin:audit review\nthese changes", reserved)) + .toBe("/skill plugin:audit review\nthese changes"); + expect(await catalog.prepareSlashCommand("/unknown task", reserved)).toBeUndefined(); +}); diff --git a/test/tui/fixtures/skills.ts b/test/tui/fixtures/skills.ts new file mode 100644 index 00000000..063c0589 --- /dev/null +++ b/test/tui/fixtures/skills.ts @@ -0,0 +1,15 @@ +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; + +await runTui({ + initialModel: "scenario/model", + workspaceDirectory: process.cwd(), + slashCommands: [{ name: "deploy", description: "Custom deploy command", aliases: ["ship"] }], + listSkills: async () => ({ skills: [ + { name: "orchestration", description: "Coordinate skill tasks" }, + { name: "orchestration", description: "Duplicate skill" }, + { name: "audit", qualifiedName: "plugin:audit", description: "Review code" }, + { name: "deploy" }, { name: "ship" } + ] }), + loadSessionTranscript: async () => [{ role: "agent", content: "Skill scenario ready" }], + submitPrompt: async (input) => ({ response: `Runtime received: ${String(input)}` }) +}); diff --git a/test/tui/skills.test.ts b/test/tui/skills.test.ts new file mode 100644 index 00000000..e1a44df6 --- /dev/null +++ b/test/tui/skills.test.ts @@ -0,0 +1,19 @@ +import { test } from "bun:test"; +import { join } from "node:path"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +test("slash skills are discoverable, complete through the keyboard and invoke the native skill route", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-slash-skills-" }); + await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/skills.ts")], workspace }); + await session.waitForScreen("ready", /Skill scenario ready/u); + await session.sendAndWait("/orch", "skill suggestion", /Skill: Coordinate skill tasks/u); + session.send("\t"); + await session.waitForScreen("completed shortcut", /\/orchestration/u); + await session.sendAndWait("coordinate work\r", "native skill dispatch", /Runtime received: \/skill orchestration coordinate work/u); + await session.sendAndWait("/plugin:audit review code\r", "typed qualified skill", /Runtime received: \/skill plugin:audit review code/u); + await session.sendAndWait("/deploy task\r", "custom command precedence", /Runtime received: \/deploy task/u); + await session.sendAndWait("/ship task\r", "alias precedence", /Runtime received: \/ship task/u); + await session.sendAndWait("/unknown task\r", "unknown command preserved", /Runtime received: \/unknown task/u); + await session.exit(); +}, 20_000);