Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `/<skill-name> <task>`
directly (for example, `/audit review the current changes`). These shortcuts
use the runtime's `/skill <name> <task>` 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.
Expand Down
13 changes: 10 additions & 3 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand Down
19 changes: 19 additions & 0 deletions packages/zcode-tui/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +61,24 @@ export class SkillCatalog {
? { identifiers, text: buildSkillInvocationPrompt(identifiers, input) }
: undefined;
}

async listSlashCommands(reservedNames: readonly string[]): Promise<SkillEntry[]> {
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<string | undefined> {
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[] {
Expand Down
1 change: 1 addition & 0 deletions packages/zcode-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type UnknownRecord = Record<string, unknown>;

export interface SlashCommandOption {
name?: string;
aliases?: string[];
description?: string;
summary?: string;
inputHint?: string;
Expand Down
24 changes: 21 additions & 3 deletions packages/zcode-tui/src/workspace-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -69,6 +70,23 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider {
): Promise<AutocompleteSuggestions | null> {
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();
Expand Down
2 changes: 2 additions & 0 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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. |
Expand Down
75 changes: 75 additions & 0 deletions test/skill-completion.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
15 changes: 15 additions & 0 deletions test/tui/fixtures/skills.ts
Original file line number Diff line number Diff line change
@@ -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)}` })
});
19 changes: 19 additions & 0 deletions test/tui/skills.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading