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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
type ProtectedSubmission,
type SelectionCommand
} from "./selection-command.ts";
import { SkillCatalog } from "./skills.ts";
import {
isActiveBackgroundJob,
normalizeRuntimeProjection,
Expand Down Expand Up @@ -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<void>;
private resolveDone!: () => void;
private stopped = false;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand Down
168 changes: 168 additions & 0 deletions packages/zcode-tui/src/skills.ts
Original file line number Diff line number Diff line change
@@ -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<SkillEntry[]>;

constructor(private readonly listSkills?: ListSkills) {}

async list(): Promise<SkillEntry[]> {
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<PreparedSkillPrompt | undefined> {
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<string>();
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<string, SkillEntry[]>();
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<string>();
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;
}
18 changes: 18 additions & 0 deletions packages/zcode-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ export type ListWorkspacePathSuggestions = (
request: WorkspacePathSuggestionRequest
) => Promise<WorkspacePathSuggestionResult>;

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<SkillSuggestionResult>;

export interface TuiOptions {
initialMode?: string;
initialModel?: unknown;
Expand All @@ -67,6 +84,7 @@ export interface TuiOptions {
stderr?: NodeJS.WriteStream;
loadSessionTranscript?: () => Promise<unknown>;
listWorkspacePathSuggestions?: ListWorkspacePathSuggestions;
listSkills?: ListSkills;
recallPreviousInput?: (skip: number) => Promise<unknown>;
readGoal?: () => Promise<unknown>;
readTodos?: () => Promise<unknown>;
Expand Down
Loading