From bc388a46293bea293f03adb5a5ac52c75e366be1 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 8 Aug 2026 17:21:13 +0800 Subject: [PATCH 1/2] feat(tui): add runtime-backed skill autocomplete --- README.md | 19 +- package.json | 2 +- packages/zcode-tui/src/index.ts | 15 +- packages/zcode-tui/src/skills.ts | 168 ++++++++++++++ packages/zcode-tui/src/types.ts | 18 ++ .../zcode-tui/src/workspace-autocomplete.ts | 80 ++++++- scripts/check-runtime.ts | 4 +- scripts/smoke-tui-features.ts | 6 +- scripts/smoke-tui.ts | 17 ++ scripts/sync-runtime.ts | 17 +- test/fixtures/tui-features.ts | 10 +- test/skill-autocomplete.test.ts | 215 ++++++++++++++++++ test/skills.test.ts | 85 +++++++ test/sync-runtime.test.ts | 3 + 14 files changed, 646 insertions(+), 13 deletions(-) create mode 100644 packages/zcode-tui/src/skills.ts create mode 100644 test/skill-autocomplete.test.ts create mode 100644 test/skills.test.ts diff --git a/README.md b/README.md index e674ffe..6e7fdd4 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ bytes. ## Features **Editor and input.** pi-tui differential rendering with a CJK-aware -multi-line editor; slash-command and workspace-path completion; persisted +multi-line editor; slash-command, workspace-path and `$` Skill completion; persisted prompt history through ZCode's history API; `--no-color` and `NO_COLOR` support. @@ -145,6 +145,23 @@ Suggestions come from the official ZCode runtime, stay inside the current workspace and exclude common repository metadata and dependency directories. Paths containing spaces are inserted in the quoted `@"..."` form. +### Invoking skills + +Type `$` at the start of the prompt or after whitespace to open the Skill +picker. Continue typing a name, use Up/Down to choose a candidate, then press +Tab or Enter to insert it. + +```text +$audit review the current changes +Use $browser-use:control-browser to verify the page +``` + +The picker uses the official runtime's Skill catalog and inserts plugin Skills +with their qualified names. On submission, exact `$name` matches are converted +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. + ### Active-turn input While a regular agent turn is running, press `Enter` to send the current text diff --git a/package.json b/package.json index 9ed3dc3..91c3885 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-app-cli", - "version": "3.6.5-9", + "version": "3.7.3-9", "description": "Unofficial terminal client for the ZCode agent runtime", "keywords": [ "agent", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 9a6a243..42e5a7e 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -133,6 +133,7 @@ import { type ProtectedSubmission, type SelectionCommand } from "./selection-command.ts"; +import { SkillCatalog } from "./skills.ts"; import { isActiveBackgroundJob, normalizeRuntimeProjection, @@ -323,6 +324,7 @@ class ZCodeTui { private readonly editor: Editor; private readonly assistantStream: AssistantStream; private readonly notifications: TurnNotifier; + private readonly skillCatalog: SkillCatalog; private readonly done: Promise; private resolveDone!: () => void; private stopped = false; @@ -412,6 +414,7 @@ class ZCodeTui { this.modelOptions = [...(options.modelOptions ?? [])]; this.effortOptions = [...(options.effortOptions ?? [])]; this.loginRequired = options.loginRequired === true; + this.skillCatalog = new SkillCatalog(options.listSkills); this.ui = new TUI(new ProcessTerminal(), true); this.notifications = new TurnNotifier({ writeTerminal: (data) => this.ui.terminal.write(data) @@ -543,7 +546,8 @@ class ZCodeTui { new WorkspaceAutocompleteProvider( commands, this.options.workspaceDirectory ?? process.cwd(), - this.options.listWorkspacePathSuggestions + this.options.listWorkspacePathSuggestions, + this.skillCatalog ) ); this.editor.onSubmit = (text) => void this.submit(text); @@ -938,6 +942,11 @@ class ZCodeTui { return; } + const skillPrompt = input.startsWith("/") + ? undefined + : await this.skillCatalog.preparePrompt(input); + const runtimeInput = skillPrompt?.text ?? input; + this.transcript.clearSearch(); this.transcript.clearCursor(); @@ -1013,13 +1022,13 @@ class ZCodeTui { try { if (input.startsWith("/") || !this.options.sendInput) { const result = await this.options.submitPrompt( - input.startsWith("/") ? input : promptInput(input, attachments), + input.startsWith("/") ? input : promptInput(runtimeInput, attachments), callOptions ); await this.handleResult(result, true, settingTargetForCommand(input)); accepted = true; } else { - const preparedInput = promptInput(input, attachments); + const preparedInput = promptInput(runtimeInput, attachments); const outcome = queuedSubmission?.pendingInputIds?.length && this.options.promoteQueuedInput ? await this.options.promoteQueuedInput( preparedInput, diff --git a/packages/zcode-tui/src/skills.ts b/packages/zcode-tui/src/skills.ts new file mode 100644 index 0000000..77c84a3 --- /dev/null +++ b/packages/zcode-tui/src/skills.ts @@ -0,0 +1,168 @@ +import { sanitizeTerminalText, truncateGraphemes } from "./terminal-text.ts"; +import { isRecord, type ListSkills, type UnknownRecord } from "./types.ts"; + +const skillCacheMilliseconds = 2_000; +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; + +export interface SkillEntry { + description?: string; + identifier: string; + name: string; +} + +export interface PreparedSkillPrompt { + identifiers: string[]; + text: string; +} + +export class SkillCatalog { + private cached?: SkillEntry[]; + private expiresAt = 0; + private inFlight?: Promise; + + constructor(private readonly listSkills?: ListSkills) {} + + async list(): Promise { + if (!this.listSkills) return []; + if (this.cached && Date.now() < this.expiresAt) return this.cached; + if (this.inFlight) return await this.inFlight; + + const stale = this.cached; + const request = Promise.resolve() + .then(() => this.listSkills!()) + .then((result) => { + const skills = normalizeSkillEntries(result); + this.cached = skills; + this.expiresAt = Date.now() + skillCacheMilliseconds; + return skills; + }) + .catch(() => { + const fallback = stale ?? []; + this.cached = fallback; + this.expiresAt = Date.now() + skillCacheMilliseconds; + return fallback; + }); + this.inFlight = request; + try { + return await request; + } finally { + if (this.inFlight === request) this.inFlight = undefined; + } + } + + async preparePrompt(input: string): Promise { + if (!input.includes("$")) return undefined; + const identifiers = resolveSkillMentions(input, await this.list()); + return identifiers.length > 0 + ? { identifiers, text: buildSkillInvocationPrompt(identifiers, input) } + : undefined; + } +} + +export function normalizeSkillEntries(result: unknown): SkillEntry[] { + if (!isRecord(result) || !Array.isArray(result.skills)) return []; + + const skills: SkillEntry[] = []; + const seen = new Set(); + for (const candidate of result.skills) { + if (!isRecord(candidate)) continue; + const name = validSkillIdentifier(candidate.name); + if (!name) continue; + + const qualifiedName = candidate.qualifiedName === undefined + ? undefined + : validSkillIdentifier(candidate.qualifiedName); + if (candidate.qualifiedName !== undefined && !qualifiedName) continue; + + const identifier = qualifiedName ?? name; + if (seen.has(identifier)) continue; + seen.add(identifier); + const description = skillDescription(candidate); + skills.push({ + identifier, + name, + ...(description ? { description } : {}) + }); + } + return skills; +} + +export function resolveSkillMentions(input: string, skills: SkillEntry[]): string[] { + if (!input.includes("$") || skills.length === 0) return []; + + const byIdentifier = new Map(skills.map((skill) => [skill.identifier, skill])); + const byName = new Map(); + for (const skill of skills) { + const matches = byName.get(skill.name) ?? []; + matches.push(skill); + byName.set(skill.name, matches); + } + + const identifiers: string[] = []; + const seen = new Set(); + for (const match of input.matchAll(skillMentionPattern)) { + const token = match[2]; + if (!token) continue; + const exact = byIdentifier.get(token); + const aliases = byName.get(token); + const skill = exact ?? (aliases?.length === 1 ? aliases[0] : undefined); + if (skill && !seen.has(skill.identifier)) { + seen.add(skill.identifier); + identifiers.push(skill.identifier); + } + } + return identifiers; +} + +export function buildSkillInvocationPrompt( + identifiers: readonly string[], + userRequest: string +): string { + if (identifiers.length === 0 || identifiers.some((identifier) => !validSkillIdentifier(identifier))) { + return userRequest; + } + + const request = userRequest.trim(); + const requestText = request.length > 0 + ? `User request:\n${request}` + : "No additional user request was provided. Load the skill and respond according to its instructions."; + if (identifiers.length === 1) { + const identifier = identifiers[0]!; + return [ + `Use the skill named \`${identifier}\` for this turn.`, + `First call the \`Skill\` tool with name \`${identifier}\` before doing the task.`, + "After the skill content is loaded, follow its instructions and continue.", + "", + requestText + ].join("\n"); + } + + const names = identifiers.map((identifier) => `\`${identifier}\``).join(", "); + return [ + `Use the skills named ${names} for this turn.`, + `First call the \`Skill\` tool once for each of these names before doing the task: ${names}.`, + "After all skill content is loaded, follow the instructions and continue.", + "", + requestText + ].join("\n"); +} + +function validSkillIdentifier(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0 || value.length > skillIdentifierLimit) { + return undefined; + } + return skillIdentifierPattern.test(value) ? value : undefined; +} + +function skillDescription(candidate: UnknownRecord): string | undefined { + const description = [candidate.description, candidate.whenToUse] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((value) => sanitizeTerminalText(value, { preserveSgr: false })) + .join(" ") + .replace(/\s+/gu, " ") + .trim(); + return description ? truncateGraphemes(description, skillDescriptionLimit) : undefined; +} diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index dd9cf71..f1373bf 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -47,6 +47,23 @@ export type ListWorkspacePathSuggestions = ( request: WorkspacePathSuggestionRequest ) => Promise; +export interface SkillSuggestion { + name: string; + description?: string; + qualifiedName?: string; + whenToUse?: string; + source?: string; + scope?: string; +} + +export interface SkillSuggestionResult { + skills: SkillSuggestion[]; + diagnostics?: unknown[]; + totalDiscovered?: number; +} + +export type ListSkills = () => Promise; + export interface TuiOptions { initialMode?: string; initialModel?: unknown; @@ -67,6 +84,7 @@ export interface TuiOptions { stderr?: NodeJS.WriteStream; loadSessionTranscript?: () => Promise; listWorkspacePathSuggestions?: ListWorkspacePathSuggestions; + listSkills?: ListSkills; recallPreviousInput?: (skip: number) => Promise; readGoal?: () => Promise; readTodos?: () => Promise; diff --git a/packages/zcode-tui/src/workspace-autocomplete.ts b/packages/zcode-tui/src/workspace-autocomplete.ts index 3937b70..e9da9d2 100644 --- a/packages/zcode-tui/src/workspace-autocomplete.ts +++ b/packages/zcode-tui/src/workspace-autocomplete.ts @@ -6,9 +6,15 @@ import { type SlashCommand } from "@earendil-works/pi-tui"; -import { isRecord, type ListWorkspacePathSuggestions } from "./types.ts"; +import { + isRecord, + type ListSkills, + type ListWorkspacePathSuggestions +} from "./types.ts"; +import { SkillCatalog } from "./skills.ts"; const workspaceSuggestionLimit = 50; +const skillSuggestionLimit = 50; const controlCharacterPattern = /[\u0000-\u001f\u007f]/u; const windowsDrivePattern = /^[a-zA-Z]:(?:\/|$)/u; @@ -18,19 +24,29 @@ interface WorkspaceAtPrefix { quoted: boolean; } +interface SkillDollarPrefix { + prefix: string; + query: string; +} + /** * Adds official-runtime workspace suggestions to pi-tui without replacing its - * slash-command, local path or completion-editing behavior. + * slash-command, local path or completion-editing behavior. Also serves the + * `$`-prefix skill picker by delegating to the runtime `listSkills` callback. */ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { private readonly fallback: CombinedAutocompleteProvider; + private readonly skills: SkillCatalog; + public readonly triggerCharacters: string[] = ["$"]; constructor( commands: (AutocompleteItem | SlashCommand)[] | undefined, basePath: string, - private readonly listWorkspacePathSuggestions?: ListWorkspacePathSuggestions + private readonly listWorkspacePathSuggestions?: ListWorkspacePathSuggestions, + skillSource?: ListSkills | SkillCatalog ) { this.fallback = new CombinedAutocompleteProvider(commands, basePath, null); + this.skills = skillSource instanceof SkillCatalog ? skillSource : new SkillCatalog(skillSource); } async getSuggestions( @@ -40,6 +56,28 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { options: { signal: AbortSignal; force?: boolean } ): Promise { const currentLine = lines[cursorLine] ?? ""; + + const skillPrefix = extractSkillDollarPrefix(currentLine.slice(0, cursorCol)); + if (skillPrefix) { + const skills = await this.skills.list(); + if (options.signal.aborted) return null; + + const normalizedQuery = skillPrefix.query.toLowerCase(); + const items = skills + .filter((skill) => ( + normalizedQuery.length === 0 + || skill.name.toLowerCase().startsWith(normalizedQuery) + || skill.identifier.toLowerCase().startsWith(normalizedQuery) + )) + .slice(0, skillSuggestionLimit) + .map((skill) => ({ + value: `$${skill.identifier}`, + label: skill.identifier, + ...(skill.description ? { description: skill.description } : {}) + })); + return items.length > 0 ? { items, prefix: skillPrefix.prefix } : null; + } + const atPrefix = extractWorkspaceAtPrefix(currentLine.slice(0, cursorCol)); if (!atPrefix || !this.listWorkspacePathSuggestions) { return await this.fallback.getSuggestions(lines, cursorLine, cursorCol, options); @@ -69,6 +107,22 @@ export class WorkspaceAutocompleteProvider implements AutocompleteProvider { item: AutocompleteItem, prefix: string ): { lines: string[]; cursorLine: number; cursorCol: number } { + if (prefix.startsWith("$")) { + // Skill completions are simple identifiers; insert the value followed by a + // single trailing space so the user can keep typing their prompt. + const currentLine = lines[cursorLine] ?? ""; + const beforePrefix = currentLine.slice(0, cursorCol - prefix.length); + const afterCursor = currentLine.slice(cursorCol); + const suffix = /^\s/u.test(afterCursor) ? "" : " "; + const newLine = `${beforePrefix}${item.value}${suffix}${afterCursor}`; + const newLines = [...lines]; + newLines[cursorLine] = newLine; + return { + lines: newLines, + cursorLine, + cursorCol: beforePrefix.length + item.value.length + suffix.length + }; + } return this.fallback.applyCompletion(lines, cursorLine, cursorCol, item, prefix); } @@ -103,6 +157,26 @@ function extractWorkspaceAtPrefix(textBeforeCursor: string): WorkspaceAtPrefix | return { callbackToken: prefix, prefix, quoted: false }; } +function extractSkillDollarPrefix(textBeforeCursor: string): SkillDollarPrefix | undefined { + let marker = -1; + for (let index = textBeforeCursor.length - 1; index >= 0; index -= 1) { + if ( + textBeforeCursor[index] === "$" && + (index === 0 || textBeforeCursor[index - 1] === " " || textBeforeCursor[index - 1] === "\t") + ) { + marker = index; + break; + } + } + if (marker < 0) return undefined; + + const prefix = textBeforeCursor.slice(marker); + // Skill tokens are simple identifiers (and qualifiedName uses ":"); they never + // contain whitespace or quotes. Bail out to let the fallback handle the input. + if (/\s/u.test(prefix) || prefix.includes('"')) return undefined; + return { prefix, query: prefix.slice(1) }; +} + function normalizeWorkspaceSuggestions(result: unknown, preserveQuotes: boolean): AutocompleteItem[] { if (!isRecord(result) || !Array.isArray(result.items)) return []; diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 3072b07..c636f72 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -27,6 +27,7 @@ if (runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void || !runtimeSource.includes(".cancelBackgroundTask=async") || !runtimeSource.includes(".previewFileRewind=async e=>") || !runtimeSource.includes(".applyFileRewind=async e=>") + || !runtimeSource.includes(".listSkills=async()=>await") || !supportsMultiMessageFileRewind(runtimeSource) || !/messageId:[A-Za-z_$][\w$]*\.info\.id,role:"user"/u.test(runtimeSource) || !/messageId:[A-Za-z_$][\w$]*\.info\.id,role:"agent"/u.test(runtimeSource) @@ -38,7 +39,8 @@ if (runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void || !/readSessionUsage:[A-Za-z_$][\w$]*\.readSessionUsage/u.test(runtimeSource) || !/cancelBackgroundTask:[A-Za-z_$][\w$]*\.cancelBackgroundTask/u.test(runtimeSource) || !/previewFileRewind:[A-Za-z_$][\w$]*\.previewFileRewind/u.test(runtimeSource) - || !/applyFileRewind:[A-Za-z_$][\w$]*\.applyFileRewind/u.test(runtimeSource)) { + || !/applyFileRewind:[A-Za-z_$][\w$]*\.applyFileRewind/u.test(runtimeSource) + || !/listSkills:[A-Za-z_$][\w$]*\.listSkills/u.test(runtimeSource)) { throw new Error("The runtime compatibility patches are missing; run `bun run sync` again."); } diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 6b0dffe..4f36d0d 100644 --- a/scripts/smoke-tui-features.ts +++ b/scripts/smoke-tui-features.ts @@ -159,6 +159,8 @@ try { await sendAndWait("\x1b", "attachment command return", /Images · \[Image #1\] · \[Image #2\][\s\S]*↑ manage/i); await sendAndWait("inspect @ind", "workspace path suggestions", /index\.ts[\s\S]*src\/index\.ts/i); await sendAndWait("\r", "workspace path completion", /inspect @src\/index\.ts/i); + await sendAndWait("$au", "skill suggestions", /audit[\s\S]*Review technical quality\./i); + await sendAndWait("\r", "skill completion", /inspect @src\/index\.ts \$audit/i); await sendAndSettle("\x01"); await sendAndWait( "\x1b[A", @@ -172,7 +174,7 @@ try { const featureTurnStart = await sendAndWait( "\r", "submitted image turn", - /›\s*inspect @src\/index\.ts\s+\[1 image\][\s\S]*◇ Thought/i, + /›\s*inspect @src\/index\.ts \$audit\s+\[1 image\][\s\S]*◇ Thought/i, 4_000 ); const activeTurnProjection = plainText(output.slice(featureTurnStart)); @@ -335,6 +337,8 @@ for (const [label, pattern] of [ ["attachment selection", /› \[Image #2\][\s\S]*Backspace\/Delete remove/i], ["attachment navigation", /› \[Image #1\]/i], ["workspace file reference", /›\s*inspect @src\/index\.ts/i], + ["skill reference", /›\s*inspect @src\/index\.ts \$audit/i], + ["skill picker", /audit[\s\S]*Review technical quality\./i], ["pending active-turn steering", /Steering current turn · 1 waiting[\s\S]*Keep the final response concise\./i], ["committed active-turn steering", /› Keep the final response concise\./i], ["rejected steer fallback", /Steer was not accepted \(turn not steerable\); queued for the next turn\./i], diff --git a/scripts/smoke-tui.ts b/scripts/smoke-tui.ts index 0efe3fa..2d3ea9c 100755 --- a/scripts/smoke-tui.ts +++ b/scripts/smoke-tui.ts @@ -19,6 +19,7 @@ let output = ""; const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-cli-smoke-")); const configPath = join(temporaryHome, ".zcode", "cli", "config.json"); const updateCachePath = join(temporaryHome, ".zcode", "cli", "version.json"); +const smokeSkillPath = join(temporaryHome, ".agents", "skills", "smoke-review", "SKILL.md"); const availableVersion = nextBuildVersion(packageVersion); const smokeApiKey = "smoke-api-key-not-real"; const command = process.argv[2] @@ -34,10 +35,19 @@ const terminal = new Bun.Terminal({ }); await mkdir(dirname(updateCachePath), { recursive: true }); +await mkdir(dirname(smokeSkillPath), { recursive: true }); await writeFile(updateCachePath, `${JSON.stringify({ latestVersion: availableVersion, lastCheckedAt: new Date().toISOString() })}\n`); +await writeFile(smokeSkillPath, [ + "---", + "name: smoke-review", + "description: Review the runtime Skill bridge.", + "---", + "", + "Review the requested change." +].join("\n")); const child = Bun.spawn(command, { cwd: root, @@ -158,6 +168,10 @@ try { || initialConfig.provider?.zai?.options?.apiKey !== undefined) { throw new Error("The launcher created an invalid initial config.json."); } + await sendAndWait("$smoke", "runtime skill suggestions", /smoke-review[\s\S]*Review the runtime Skill bridge\./i); + await sendAndWait("\r", "runtime skill completion", /\$smoke-review/i); + terminal.write("\x15"); + await Bun.sleep(50); await sendAndWait("/login\r", "login setup picker", /Set Up Coding Plan|配置 Coding Plan/i); await sendAndWait("\x1b[B\x1b[B\r", "masked API key prompt", /Enter Z\.AI Coding Plan API Key|输入 Z\.AI Coding Plan API Key/i); await sendAndWait(smokeApiKey, "masked API key value", /\*{20,}/i); @@ -218,6 +232,9 @@ if (!plain.includes(`Update available! ${packageVersion} → ${availableVersion} if (!/custom provider/i.test(plain)) { throw new Error(`The custom-provider configuration hint was not rendered.\n${plain.slice(-4_000)}`); } +if (!/smoke-review[\s\S]*Review the runtime Skill bridge\./i.test(plain)) { + throw new Error(`The runtime Skill picker was not rendered.\n${plain.slice(-4_000)}`); +} if (!/Configured Z\.AI Coding Plan|已配置 Z\.AI Coding Plan/i.test(plain)) { throw new Error(`The masked API-key setup did not complete.\n${plain.slice(-4_000)}`); } diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index fa6ed62..3a5a5ba 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -143,6 +143,8 @@ export function patchRuntimeTuiBridge(runtime: string): string { const activeTranscriptPattern = /sessionStore\.messages\(\{sessionID:([A-Za-z_$][\w$]*)\.sessionId\}\),[A-Za-z_$][\w$]*=await \1\.sessionStore\.getSession\(\1\.sessionId\);return/u; const activeTurnSteerPattern = /(\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\2\?\.inputId,queryId:\2\?\.queryId,expectedTurnId:\2\?\.expectedTurnId,)(?:delivery:"guide",)?(?:pendingInputId:\2\?\.pendingInputId,)?input:/u; const activeTurnGuidePattern = /\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\1\?\.inputId,queryId:\1\?\.queryId,expectedTurnId:\1\?\.expectedTurnId,delivery:"guide",pendingInputId:\1\?\.pendingInputId,input:/u; + const listSkillsBridgePattern = /\.listSkills=async\(\)=>await [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\)/u; + const listSkillsOptionPattern = /listSkills:[A-Za-z_$][\w$]*\.listSkills/u; const interruptTurnMarker = ".interruptTurn=async e=>"; const queuedInputPromotionMarker = "r?.pendingInputReservationId??r?.queryId??"; const alreadyPatched = runtime.includes(".loadSessionTranscript=async()=>await(await") @@ -170,7 +172,9 @@ export function patchRuntimeTuiBridge(runtime: string): string { && /applyFileRewind:[A-Za-z_$][\w$]*\.applyFileRewind/u.test(runtime) && /interruptTurn:[A-Za-z_$][\w$]*\.interruptTurn/u.test(runtime) && /promoteQueuedInput:[A-Za-z_$][\w$]*\.promoteQueuedInput/u.test(runtime) - && /readSessionUsage:[A-Za-z_$][\w$]*\.readSessionUsage/u.test(runtime); + && /readSessionUsage:[A-Za-z_$][\w$]*\.readSessionUsage/u.test(runtime) + && listSkillsBridgePattern.test(runtime) + && listSkillsOptionPattern.test(runtime); if (alreadyPatched) return runtime; let patched = runtime; @@ -251,6 +255,14 @@ export function patchRuntimeTuiBridge(runtime: string): string { const [recallAssignment, bridge, , getApp] = assignment; const assignments: string[] = []; + if (!listSkillsBridgePattern.test(patched)) { + const listSkillsFactory = /listSkills:[A-Za-z_$][\w$]*\(\(\)=>([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\),"listSkills"\)/u + .exec(patched); + if (!listSkillsFactory) { + throw new Error("ZCode runtime is incompatible with the TUI bridge (skill-list adapter anchor missing)."); + } + assignments.push(`${bridge}.listSkills=async()=>await ${listSkillsFactory[1]}(${listSkillsFactory[2]})`); + } const interruptAssignment = `${bridge}.interruptTurn=async e=>{let t=await ${getApp}(),r=e?.reservationId??"tui-steer-interrupt",o=(Array.isArray(e?.pendingInputIds)?e.pendingInputIds:[]).filter(Boolean),n=[],i=async()=>{for(let a of n)await t.releaseQueueItemReservation?.(a,r);n=[]};try{if(t.reserveQueueItem&&t.releaseQueueItemReservation)for(let a of o)if(await t.reserveQueueItem(a,r))n.push(a);else{await i();break}let a=t.runtime?.stopActiveForegroundExecution?.({preserveQueueAutoDrainOnCancel:o.length>0&&n.length===o.length,reason:e?.reason??"TUI steer interrupt"})??{kind:"unsupported"};return a.kind!=="stopped"&&await i(),a}catch(a){await i();throw a}}`; const promotionAssignment = `${bridge}.promoteQueuedInput=async(e,t,r)=>{let o=await ${getApp}(),n=r?.pendingInputReservationId??r?.queryId??r?.inputId??"tui-promotion",i=(Array.isArray(t)?t:[t]).filter(Boolean);if(i.length===0||!o.reserveQueueItem||!o.markQueueItemPromoting||!o.releaseQueueItemReservation||!o.removeQueueItem)return ${bridge}.sendInput(e,{...r,delivery:"start_turn"});let a=[],u=!1;try{for(let l of i){if(await o.markQueueItemPromoting(l,n)){a.push(l);continue}if(!await o.reserveQueueItem(l,n))throw new Error("TUI queued input is already reserved: "+l);a.push(l);if(!await o.markQueueItemPromoting(l,n))throw new Error("TUI queued input promotion failed: "+l)}let c=await ${bridge}.sendInput(e,{...r,delivery:"start_turn"});if(c?.kind==="rejected")return c;u=!0;for(let l of a)if(!await o.removeQueueItem(l,{reason:"promoted",reservationId:n}))throw new Error("TUI queued input promotion commit failed: "+l);return c}finally{if(!u)for(let l of a)await o.releaseQueueItemReservation(l,n)}}`; if (!patched.includes(".loadSessionTranscript=async()=>await(await")) { @@ -331,6 +343,9 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!/promoteQueuedInput:[A-Za-z_$][\w$]*\.promoteQueuedInput/u.test(patched)) { optionFields.push(`promoteQueuedInput:${submitBridge}.promoteQueuedInput`); } + if (!listSkillsOptionPattern.test(patched)) { + optionFields.push(`listSkills:${submitBridge}.listSkills`); + } if (optionFields.length > 0) { patched = patched.replace(optionsAssignment, `${optionFields.join(",")},${optionsAssignment}`); } diff --git a/test/fixtures/tui-features.ts b/test/fixtures/tui-features.ts index 2de3dc2..fe73055 100644 --- a/test/fixtures/tui-features.ts +++ b/test/fixtures/tui-features.ts @@ -139,6 +139,9 @@ await runTui({ ? { items: [{ kind: "file" as const, path: "src/index.ts" }], truncated: false } : { items: [], truncated: false }; }, + listSkills: async () => ({ + skills: [{ name: "audit", description: "Review technical quality." }] + }), refreshWorkflowPanel: async () => workflowPanel(), stopWorkflow: async () => workflowPanel("cancelled"), readGoal: async () => goal, @@ -289,11 +292,14 @@ await runTui({ const attachments = Array.isArray(prompt.attachments) ? prompt.attachments : []; const image = attachments[0] as Record | undefined; if ( - promptText !== "inspect @src/index.ts" || + typeof promptText !== "string" || + !promptText.startsWith("Use the skill named `audit` for this turn.\n") || + !promptText.includes("First call the `Skill` tool with name `audit` before doing the task.\n") || + !promptText.endsWith("User request:\ninspect @src/index.ts $audit") || image?.type !== "image" || typeof image.content !== "string" ) { - throw new Error("Feature smoke prompt did not include the selected file and image attachment."); + throw new Error("Feature smoke prompt did not include the selected file, skill and image attachment."); } featureTurnActive = true; await emitRuntime(options, "turn_started", { diff --git a/test/skill-autocomplete.test.ts b/test/skill-autocomplete.test.ts new file mode 100644 index 0000000..92c38ea --- /dev/null +++ b/test/skill-autocomplete.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, test } from "bun:test"; + +import { WorkspaceAutocompleteProvider } from "../packages/zcode-tui/src/workspace-autocomplete.ts"; +import type { SkillSuggestionResult } from "../packages/zcode-tui/src/types.ts"; + +function signal(): AbortSignal { + return new AbortController().signal; +} + +describe("workspace $ skill autocomplete", () => { + test("queries the runtime skill lister and inserts a selected skill", async () => { + let calls = 0; + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => { + calls += 1; + return { + skills: [ + { name: "animate", description: "Add motion to a feature." } + ] + } satisfies SkillSuggestionResult; + } + ); + const input = "polish $anim"; + + const suggestions = await provider.getSuggestions([input], 0, input.length, { + signal: signal() + }); + + expect(calls).toBe(1); + expect(suggestions).toEqual({ + prefix: "$anim", + items: [ + { value: "$animate", label: "animate", description: "Add motion to a feature." } + ] + }); + + const completion = provider.applyCompletion( + [input], + 0, + input.length, + suggestions!.items[0]!, + suggestions!.prefix + ); + expect(completion).toEqual({ + lines: ["polish $animate "], + cursorLine: 0, + cursorCol: "polish $animate ".length + }); + }); + + test("uses qualifiedName when present so plugin skills stay disambiguable", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => + ({ + skills: [ + { + name: "control-browser", + qualifiedName: "browser-use:control-browser", + description: "Drive a browser." + } + ] + }) satisfies SkillSuggestionResult + ); + const input = "$control"; + + const suggestions = await provider.getSuggestions([input], 0, input.length, { + signal: signal() + }); + + expect(suggestions?.items[0]).toEqual({ + value: "$browser-use:control-browser", + label: "browser-use:control-browser", + description: "Drive a browser." + }); + }); + + test("filters case-insensitively on name and qualifiedName", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => + ({ + skills: [ + { name: "Distill", description: "Simplify." }, + { name: "document-skills:docx", qualifiedName: "document-skills:docx" } + ] + }) satisfies SkillSuggestionResult + ); + + const byName = await provider.getSuggestions(["$dis"], 0, 4, { signal: signal() }); + expect(byName?.items.map((item) => item.value)).toEqual(["$Distill"]); + + const byQualified = await provider.getSuggestions(["$doc"], 0, 4, { signal: signal() }); + expect(byQualified?.items.map((item) => item.value)).toEqual([ + "$document-skills:docx" + ]); + }); + + test("does not query skills when there is no $ prefix and falls back to slash commands", async () => { + let calls = 0; + const provider = new WorkspaceAutocompleteProvider( + [{ name: "help", description: "Show help" }], + process.cwd(), + undefined, + async () => { + calls += 1; + return { skills: [{ name: "animate" }] } satisfies SkillSuggestionResult; + } + ); + + const slash = await provider.getSuggestions(["/he"], 0, 3, { signal: signal() }); + expect(slash).toMatchObject({ + prefix: "/he", + items: [{ value: "help", label: "help", description: "Show help" }] + }); + expect(calls).toBe(0); + + const plain = "just a normal sentence"; + expect( + await provider.getSuggestions([plain], 0, plain.length, { signal: signal() }) + ).toBeNull(); + expect(calls).toBe(0); + }); + + test("ignores $ that is not at a token boundary", async () => { + let calls = 0; + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => { + calls += 1; + return { skills: [{ name: "animate" }] } satisfies SkillSuggestionResult; + } + ); + + // `a$b` keeps `$` mid-token (previous char is not a boundary), so it must + // not trigger the skill lister — consistent with how `@` is handled. + const input = "pay$cost"; + expect( + await provider.getSuggestions([input], 0, input.length, { signal: signal() }) + ).toBeNull(); + expect(calls).toBe(0); + }); + + test("isolates skill-lister failures from the editor", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => { + throw new Error("skill discovery unavailable"); + } + ); + + expect( + await provider.getSuggestions(["$an"], 0, 3, { signal: signal() }) + ).toBeNull(); + }); + + test("declares $ as a trigger character", () => { + const provider = new WorkspaceAutocompleteProvider([], process.cwd(), undefined, undefined); + expect(provider.triggerCharacters).toContain("$"); + }); + + test("does not duplicate whitespace after a completed mention", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => ({ skills: [{ name: "animate" }] }) + ); + const input = "$anim existing"; + const suggestions = await provider.getSuggestions([input], 0, 5, { signal: signal() }); + const completion = provider.applyCompletion( + [input], + 0, + 5, + suggestions!.items[0]!, + suggestions!.prefix + ); + + expect(completion).toEqual({ + lines: ["$animate existing"], + cursorLine: 0, + cursorCol: "$animate".length + }); + }); + + test("rejects unsafe identifiers and strips terminal controls from descriptions", async () => { + const provider = new WorkspaceAutocompleteProvider( + [], + process.cwd(), + undefined, + async () => ({ + skills: [ + { name: "unsafe\u001b]52;c;dGVzdA==\u0007" }, + { name: "safe", description: "Clear\u001b[2J screen" } + ] + }) + ); + + const suggestions = await provider.getSuggestions(["$"], 0, 1, { signal: signal() }); + expect(suggestions?.items).toEqual([ + { value: "$safe", label: "safe", description: "Clear screen" } + ]); + }); +}); diff --git a/test/skills.test.ts b/test/skills.test.ts new file mode 100644 index 0000000..3a28661 --- /dev/null +++ b/test/skills.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildSkillInvocationPrompt, + normalizeSkillEntries, + resolveSkillMentions, + SkillCatalog +} from "../packages/zcode-tui/src/skills.ts"; + +describe("explicit skill invocation", () => { + test("builds the same single-skill contract as the runtime /skill command", () => { + expect(buildSkillInvocationPrompt(["audit"], "Review this change.")).toBe([ + "Use the skill named `audit` for this turn.", + "First call the `Skill` tool with name `audit` before doing the task.", + "After the skill content is loaded, follow its instructions and continue.", + "", + "User request:", + "Review this change." + ].join("\n")); + }); + + test("resolves selected qualified names and multiple mentions in prompt order", async () => { + const catalog = new SkillCatalog(async () => ({ + skills: [ + { name: "audit" }, + { name: "control-browser", qualifiedName: "browser-use:control-browser" } + ] + })); + + const prepared = await catalog.preparePrompt( + "Use $audit and $browser-use:control-browser, then summarize." + ); + expect(prepared?.identifiers).toEqual(["audit", "browser-use:control-browser"]); + expect(prepared?.text).toContain( + "First call the `Skill` tool once for each of these names before doing the task" + ); + expect(prepared?.text).toContain( + "User request:\nUse $audit and $browser-use:control-browser, then summarize." + ); + }); + + test("uses a unique bare alias but skips ambiguous and unknown mentions", () => { + const skills = normalizeSkillEntries({ + skills: [ + { name: "audit" }, + { name: "docx", qualifiedName: "documents:docx" }, + { name: "docx", qualifiedName: "office:docx" } + ] + }); + + expect(resolveSkillMentions("$audit $missing $HOME", skills)).toEqual(["audit"]); + expect(resolveSkillMentions("$docx", skills)).toEqual([]); + expect(resolveSkillMentions("$documents:docx", skills)).toEqual(["documents:docx"]); + }); + + test("shares a short-lived discovery result between autocomplete and submission", async () => { + let calls = 0; + const catalog = new SkillCatalog(async () => { + calls += 1; + return { skills: [{ name: "audit" }] }; + }); + + expect(await catalog.list()).toEqual([{ identifier: "audit", name: "audit" }]); + expect((await catalog.preparePrompt("$audit this"))?.identifiers).toEqual(["audit"]); + expect(calls).toBe(1); + }); + + test("treats synchronous discovery failures as unavailable completion", async () => { + let calls = 0; + const catalog = new SkillCatalog(() => { + calls += 1; + throw new Error("skill discovery unavailable"); + }); + expect(await catalog.list()).toEqual([]); + expect(await catalog.preparePrompt("$audit this")).toBeUndefined(); + expect(calls).toBe(1); + }); + + test("truncates descriptions without splitting emoji graphemes", () => { + const [skill] = normalizeSkillEntries({ + skills: [{ name: "audit", description: `${"a".repeat(138)}😀xy` }] + }); + expect(skill?.description).toBe(`${"a".repeat(138)}😀…`); + }); +}); diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index d1db0a7..54e679e 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -216,6 +216,7 @@ describe("runtime synchronization", () => { 'function p(e){let t=[];for(let r of e){if(r.info.role==="user"){let l=r.text;t.push({content:l,role:"user"});continue}let n=[],s=[],u=r.text;t.push({content:u,...s.length>0?{parts:s}:{},role:"agent"})}return t}', "function c(e,t){if(t.targetMessageId)return O(e,[t.targetMessageId]);let r=P(e,t.targetCheckpointId);return r?[r]:[]}", "E.sendInput=async(A,$)=>{let c=t.runtime.getActiveTurnInfo();if(c)return t.runtime.steerTurn({commandKind:$?.commandKind,inputId:$?.inputId,queryId:$?.queryId,expectedTurnId:$?.expectedTurnId,input:A});return Kvt(await S(),D,O1(t))},", + 'listSkills:k(()=>H(e),"listSkills"),', "E.recallPreviousInput=async A=>await(await S()).recallPreviousInputHistory?.(A)??null,", "CVr(E,S,r);", "return c({recallPreviousInput:g.recallPreviousInput,sendInput:g.sendInput,submitPrompt:g})" @@ -227,6 +228,7 @@ describe("runtime synchronization", () => { const patched = patchRuntimeTuiBridge(runtimeWithApp); expect(patched).toContain("E.loadSessionTranscript=async()=>await(await S()).loadSessionTranscript?.()??[]"); + expect(patched).toContain("E.listSkills=async()=>await H(e)"); expect(patched).toContain("E.readGoal=async()=>await(await S()).readTarget?.()??null"); expect(patched).toContain("E.readTodos=async()=>await(await S()).readTodos?.()??[]"); expect(patched).toContain("E.readRuntimeProjection=async()=>{let e=await S();return e.runtime?.getProjection?.()??null}"); @@ -263,6 +265,7 @@ describe("runtime synchronization", () => { expect(patched).toContain("applyFileRewind:g.applyFileRewind"); expect(patched).toContain("interruptTurn:g.interruptTurn"); expect(patched).toContain("promoteQueuedInput:g.promoteQueuedInput"); + expect(patched).toContain("listSkills:g.listSkills"); expect(patched).toContain("sessionStore.queryTaskUsage?.({sessionID:e.sessionId})"); expect(patchRuntimeTuiBridge(patched)).toBe(patched); expect(() => patchRuntimeTuiBridge("incompatible runtime")).toThrow(/incompatible/); From 94011b01f03e06ee7edb104de6d568bdf24a6d53 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sat, 8 Aug 2026 17:30:56 +0800 Subject: [PATCH 2/2] fix(release): align package version with runtime lock --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 91c3885..9ed3dc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-app-cli", - "version": "3.7.3-9", + "version": "3.6.5-9", "description": "Unofficial terminal client for the ZCode agent runtime", "keywords": [ "agent",